1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-08-09 04:22:45 +03:00

frontend: add filter for inactive sessions

This commit is contained in:
Quentin Gliech
2024-07-18 18:59:58 +02:00
parent 6f2ab4f738
commit 3b83b11607
19 changed files with 513 additions and 103 deletions

View File

@@ -54,6 +54,10 @@
"body:one": "{{count}} active session",
"body:other": "{{count}} active sessions",
"heading": "Browsers",
"no_active_sessions": {
"default": "You are not signed in to any web browsers.",
"inactive_90_days": "All your sessions have been active in the last 90 days."
},
"view_all_button": "View all"
},
"compat_session_detail": {
@@ -230,9 +234,11 @@
"no_primary_email_alert": "No primary email address"
},
"user_sessions_overview": {
"active_sessions:one": "{{count}} active session",
"active_sessions:other": "{{count}} active sessions",
"heading": "Where you're signed in"
"heading": "Where you're signed in",
"no_active_sessions": {
"default": "You are not signed in to any application.",
"inactive_90_days": "All your sessions have been active in the last 90 days."
}
},
"verify_email": {
"code_field_error": "Code not recognised",

View File

@@ -87,5 +87,6 @@
"vite-plugin-graphql-codegen": "^3.3.8",
"vite-plugin-manifest-sri": "^0.2.0",
"vitest": "^1.4.0"
}
},
"packageManager": "pnpm@8.6.10+sha1.98fe2755061026799bfa30e7dc8d6d48e9c3edf0"
}

View File

@@ -0,0 +1,25 @@
/* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.empty-state {
display: flex;
flex-direction: column;
gap: var(--cpd-space-2x);
padding: var(--cpd-space-4x);
background: var(--cpd-color-gray-200);
color: var(--cpd-color-text-secondary);
font: var(--cpd-font-body-sm-regular);
letter-spacing: var(--cpd-font-letter-spacing-body-sm);
}

View File

@@ -0,0 +1,35 @@
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Meta, StoryObj } from "@storybook/react";
import { EmptyState } from "./EmptyState";
const meta = {
title: "UI/EmptyState",
component: EmptyState,
tags: ["autodocs"],
args: {
children: "No results",
},
} satisfies Meta<typeof EmptyState>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Basic: Story = {
args: {
children: "No results",
},
};

View File

@@ -0,0 +1,33 @@
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import classNames from "classnames";
import { forwardRef } from "react";
import styles from "./EmptyState.module.css";
/**
* A component to display a message when a list is empty
*/
export const EmptyState = forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<"div">
>(function EmptyState({ children, ...props }, ref) {
const className = classNames(styles.emptyState, props.className);
return (
<div ref={ref} {...props} className={className}>
{children}
</div>
);
});

View File

@@ -0,0 +1,15 @@
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
export { EmptyState as default } from "./EmptyState";

View File

@@ -0,0 +1,59 @@
/* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.filter {
display: flex;
align-items: center;
gap: var(--cpd-space-2x);
font: var(--cpd-font-body-xs-regular);
letter-spacing: var(--cpd-font-letter-spacing-body-xs);
padding: var(--cpd-space-2x) var(--cpd-space-3x);
border-radius: var(--cpd-radius-pill-effect);
}
.enabled-filter {
background: var(--cpd-color-bg-action-primary-rest);
color: var(--cpd-color-text-on-solid-primary);
& > .close-icon {
height: var(--cpd-space-4x);
width: var(--cpd-space-4x);
opacity: 0.5;
}
&:hover {
background: var(--cpd-color-bg-action-primary-hovered);
& > .close-icon {
opacity: 1;
}
}
&:active {
background: var(--cpd-color-bg-action-primary-rest);
& > .close-icon {
opacity: 1;
}
}
}
.disabled-filter {
color: var(--cpd-color-text-action-primary);
background: var(--cpd-color-bg-canvas-default);
outline: 1px solid var(--cpd-color-border-interactive-secondary);
&:hover {
background: var(--cpd-color-bg-subtle-secondary);
}
}

View File

@@ -0,0 +1,53 @@
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Meta, StoryObj } from "@storybook/react";
import { DummyRouter } from "../../test-utils/router";
import { Filter } from "./Filter";
const meta = {
title: "UI/Filter",
component: Filter,
tags: ["autodocs"],
args: {
children: "Filter",
enabled: false,
},
decorators: [
(Story): React.ReactElement => (
<DummyRouter>
<div className="flex gap-4">
<Story />
</div>
</DummyRouter>
),
],
} satisfies Meta<typeof Filter>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Disabled: Story = {
args: {
enabled: false,
},
};
export const Enabled: Story = {
args: {
enabled: true,
},
};

View File

@@ -0,0 +1,47 @@
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { createLink } from "@tanstack/react-router";
import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
import classNames from "classnames";
import { forwardRef } from "react";
import styles from "./Filter.module.css";
type Props = React.ComponentPropsWithRef<"a"> & {
enabled?: boolean;
};
/**
* A link which looks like a chip used when filtering items
*/
export const Filter = createLink(
forwardRef<HTMLAnchorElement, Props>(function Filter(
{ children, enabled, ...props },
ref,
) {
const className = classNames(
styles.filter,
enabled ? styles.enabledFilter : styles.disabledFilter,
props.className,
);
return (
<a {...props} ref={ref} className={className}>
{children}
{enabled && <CloseIcon className={styles.closeIcon} />}
</a>
);
}),
);

View File

@@ -0,0 +1,15 @@
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
export { Filter as default } from "./Filter";

View File

@@ -1,4 +1,4 @@
/* Copyright 2023 The Matrix.org Foundation C.I.C.
/* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -13,15 +13,13 @@
* limitations under the License.
*/
.session-list-block {
.browser-sessions-overview {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
gap: var(--cpd-space-1x);
}
.session-list-block-info {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--cpd-space-4x);
padding: var(--cpd-space-4x);
border-radius: var(--cpd-space-3x);
background-color: var(--cpd-color-bg-canvas-default);
outline: 1px solid var(--cpd-color-gray-400);
outline-offset: -1px;
}

View File

@@ -12,11 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Body, H5 } from "@vector-im/compound-web";
import { Text, H5 } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import { FragmentType, graphql, useFragment } from "../../gql";
import Block from "../Block";
import { Link } from "../Link";
import styles from "./BrowserSessionsOverview.module.css";
@@ -38,19 +37,19 @@ const BrowserSessionsOverview: React.FC<{
const { t } = useTranslation();
return (
<Block className={styles.sessionListBlock}>
<div className={styles.sessionListBlockInfo}>
<div className={styles.browserSessionsOverview}>
<div className="flex flex-1 flex-col">
<H5>{t("frontend.browser_sessions_overview.heading")}</H5>
<Body>
<Text>
{t("frontend.browser_sessions_overview.body", {
count: data.browserSessions.totalCount,
})}
</Body>
</Text>
</div>
<Link to="/sessions/browsers" search={{ first: 6 }}>
{t("frontend.browser_sessions_overview.view_all_button")}
</Link>
</Block>
</div>
);
};

View File

@@ -3,10 +3,10 @@
exports[`BrowserSessionsOverview > renders with no browser sessions 1`] = `
<div>
<div
class="_block_17898c _sessionListBlock_c5c17e"
class="_browserSessionsOverview_c5c17e"
>
<div
class="_sessionListBlockInfo_c5c17e"
class="flex flex-1 flex-col"
>
<h5
class="_typography_yh5dq_162 _font-body-lg-semibold_yh5dq_83"
@@ -34,10 +34,10 @@ exports[`BrowserSessionsOverview > renders with no browser sessions 1`] = `
exports[`BrowserSessionsOverview > renders with sessions 1`] = `
<div>
<div
class="_block_17898c _sessionListBlock_c5c17e"
class="_browserSessionsOverview_c5c17e"
>
<div
class="_sessionListBlockInfo_c5c17e"
class="flex flex-1 flex-col"
>
<h5
class="_typography_yh5dq_162 _font-body-lg-semibold_yh5dq_83"

View File

@@ -44,9 +44,9 @@ const documents = {
"\n mutation ResendVerificationEmail($id: ID!) {\n sendVerificationEmail(input: { userEmailId: $id }) {\n status\n\n user {\n id\n primaryEmail {\n id\n }\n }\n\n email {\n id\n ...UserEmail_email\n }\n }\n }\n": types.ResendVerificationEmailDocument,
"\n query UserProfileQuery {\n viewer {\n __typename\n ... on User {\n id\n\n primaryEmail {\n id\n ...UserEmail_email\n }\n\n ...UserEmailList_user\n }\n }\n\n siteConfig {\n id\n emailChangeAllowed\n passwordLoginEnabled\n ...UserEmailList_siteConfig\n ...UserEmail_siteConfig\n ...PasswordChange_siteConfig\n }\n }\n": types.UserProfileQueryDocument,
"\n query SessionDetailQuery($id: ID!) {\n viewerSession {\n ... on Node {\n id\n }\n }\n\n node(id: $id) {\n __typename\n id\n ...CompatSession_detail\n ...OAuth2Session_detail\n ...BrowserSession_detail\n }\n }\n": types.SessionDetailQueryDocument,
"\n query BrowserSessionList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n\n user {\n id\n\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n state: ACTIVE\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n }\n": types.BrowserSessionListDocument,
"\n query BrowserSessionList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n $lastActive: DateFilter\n ) {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n\n user {\n id\n\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n lastActive: $lastActive\n state: ACTIVE\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n }\n": types.BrowserSessionListDocument,
"\n query SessionsOverviewQuery {\n viewer {\n __typename\n\n ... on User {\n id\n ...BrowserSessionsOverview_user\n }\n }\n }\n": types.SessionsOverviewQueryDocument,
"\n query AppSessionsListQuery(\n $before: String\n $after: String\n $first: Int\n $last: Int\n ) {\n viewer {\n __typename\n\n ... on User {\n id\n appSessions(\n before: $before\n after: $after\n first: $first\n last: $last\n state: ACTIVE\n ) {\n edges {\n cursor\n node {\n __typename\n ...CompatSession_session\n ...OAuth2Session_session\n }\n }\n\n totalCount\n pageInfo {\n startCursor\n endCursor\n hasNextPage\n hasPreviousPage\n }\n }\n }\n }\n }\n": types.AppSessionsListQueryDocument,
"\n query AppSessionsListQuery(\n $before: String\n $after: String\n $first: Int\n $last: Int\n $lastActive: DateFilter\n ) {\n viewer {\n __typename\n\n ... on User {\n id\n appSessions(\n before: $before\n after: $after\n first: $first\n last: $last\n lastActive: $lastActive\n state: ACTIVE\n ) {\n edges {\n cursor\n node {\n __typename\n ...CompatSession_session\n ...OAuth2Session_session\n }\n }\n\n totalCount\n pageInfo {\n startCursor\n endCursor\n hasNextPage\n hasPreviousPage\n }\n }\n }\n }\n }\n": types.AppSessionsListQueryDocument,
"\n query CurrentUserGreeting {\n viewerSession {\n __typename\n\n ... on BrowserSession {\n id\n\n user {\n id\n ...UnverifiedEmailAlert_user\n ...UserGreeting_user\n }\n }\n }\n\n siteConfig {\n id\n ...UserGreeting_siteConfig\n }\n }\n": types.CurrentUserGreetingDocument,
"\n query OAuth2ClientQuery($id: ID!) {\n oauth2Client(id: $id) {\n ...OAuth2Client_detail\n }\n }\n": types.OAuth2ClientQueryDocument,
"\n query CurrentViewerQuery {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n }\n": types.CurrentViewerQueryDocument,
@@ -198,7 +198,7 @@ export function graphql(source: "\n query SessionDetailQuery($id: ID!) {\n v
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query BrowserSessionList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n\n user {\n id\n\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n state: ACTIVE\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query BrowserSessionList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n\n user {\n id\n\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n state: ACTIVE\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n }\n"];
export function graphql(source: "\n query BrowserSessionList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n $lastActive: DateFilter\n ) {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n\n user {\n id\n\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n lastActive: $lastActive\n state: ACTIVE\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query BrowserSessionList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n $lastActive: DateFilter\n ) {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n\n user {\n id\n\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n lastActive: $lastActive\n state: ACTIVE\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -206,7 +206,7 @@ export function graphql(source: "\n query SessionsOverviewQuery {\n viewer {
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query AppSessionsListQuery(\n $before: String\n $after: String\n $first: Int\n $last: Int\n ) {\n viewer {\n __typename\n\n ... on User {\n id\n appSessions(\n before: $before\n after: $after\n first: $first\n last: $last\n state: ACTIVE\n ) {\n edges {\n cursor\n node {\n __typename\n ...CompatSession_session\n ...OAuth2Session_session\n }\n }\n\n totalCount\n pageInfo {\n startCursor\n endCursor\n hasNextPage\n hasPreviousPage\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query AppSessionsListQuery(\n $before: String\n $after: String\n $first: Int\n $last: Int\n ) {\n viewer {\n __typename\n\n ... on User {\n id\n appSessions(\n before: $before\n after: $after\n first: $first\n last: $last\n state: ACTIVE\n ) {\n edges {\n cursor\n node {\n __typename\n ...CompatSession_session\n ...OAuth2Session_session\n }\n }\n\n totalCount\n pageInfo {\n startCursor\n endCursor\n hasNextPage\n hasPreviousPage\n }\n }\n }\n }\n }\n"];
export function graphql(source: "\n query AppSessionsListQuery(\n $before: String\n $after: String\n $first: Int\n $last: Int\n $lastActive: DateFilter\n ) {\n viewer {\n __typename\n\n ... on User {\n id\n appSessions(\n before: $before\n after: $after\n first: $first\n last: $last\n lastActive: $lastActive\n state: ACTIVE\n ) {\n edges {\n cursor\n node {\n __typename\n ...CompatSession_session\n ...OAuth2Session_session\n }\n }\n\n totalCount\n pageInfo {\n startCursor\n endCursor\n hasNextPage\n hasPreviousPage\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query AppSessionsListQuery(\n $before: String\n $after: String\n $first: Int\n $last: Int\n $lastActive: DateFilter\n ) {\n viewer {\n __typename\n\n ... on User {\n id\n appSessions(\n before: $before\n after: $after\n first: $first\n last: $last\n lastActive: $lastActive\n state: ACTIVE\n ) {\n edges {\n cursor\n node {\n __typename\n ...CompatSession_session\n ...OAuth2Session_session\n }\n }\n\n totalCount\n pageInfo {\n startCursor\n endCursor\n hasNextPage\n hasPreviousPage\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

File diff suppressed because one or more lines are too long

View File

@@ -16,14 +16,23 @@ import { createFileRoute, notFound } from "@tanstack/react-router";
import { H5 } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import { useQuery } from "urql";
import * as z from "zod";
import BlockList from "../components/BlockList";
import BrowserSession from "../components/BrowserSession";
import { ButtonLink } from "../components/ButtonLink";
import EmptyState from "../components/EmptyState";
import Filter from "../components/Filter";
import { graphql } from "../gql";
import { Pagination, paginationSchema, usePages } from "../pagination";
import {
BackwardPagination,
Pagination,
paginationSchema,
usePages,
} from "../pagination";
const PAGE_SIZE = 6;
const DEFAULT_PAGE: BackwardPagination = { last: PAGE_SIZE };
const QUERY = graphql(/* GraphQL */ `
query BrowserSessionList(
@@ -31,6 +40,7 @@ const QUERY = graphql(/* GraphQL */ `
$after: String
$last: Int
$before: String
$lastActive: DateFilter
) {
viewerSession {
__typename
@@ -45,6 +55,7 @@ const QUERY = graphql(/* GraphQL */ `
after: $after
last: $last
before: $before
lastActive: $lastActive
state: ACTIVE
) {
totalCount
@@ -70,16 +81,37 @@ const QUERY = graphql(/* GraphQL */ `
}
`);
const searchSchema = z.object({
inactive: z.literal(true).optional().catch(undefined),
});
type Search = z.infer<typeof searchSchema>;
const getNintyDaysAgo = (): string => {
const date = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
// Round down to the start of the day to avoid rerendering/requerying
date.setHours(0, 0, 0, 0);
return date.toISOString();
};
export const Route = createFileRoute("/_account/sessions/browsers")({
// We paginate backwards, so we need to validate the `last` parameter by default
validateSearch: paginationSchema.catch({
last: PAGE_SIZE,
}),
validateSearch: paginationSchema.catch(DEFAULT_PAGE).and(searchSchema),
loaderDeps: ({ search }): Pagination => paginationSchema.parse(search),
loaderDeps: ({ search }): Pagination & Search =>
paginationSchema.and(searchSchema).parse(search),
async loader({ context, deps: pagination, abortController: { signal } }) {
const result = await context.client.query(QUERY, pagination, {
async loader({
context,
deps: { inactive, ...pagination },
abortController: { signal },
}) {
const variables = {
lastActive: inactive ? { before: getNintyDaysAgo() } : undefined,
...pagination,
};
const result = await context.client.query(QUERY, variables, {
fetchOptions: { signal },
});
if (result.error) throw result.error;
@@ -92,8 +124,14 @@ export const Route = createFileRoute("/_account/sessions/browsers")({
function BrowserSessions(): React.ReactElement {
const { t } = useTranslation();
const pagination = Route.useLoaderDeps();
const [list] = useQuery({ query: QUERY, variables: pagination });
const { inactive, ...pagination } = Route.useLoaderDeps();
const variables = {
lastActive: inactive ? { before: getNintyDaysAgo() } : undefined,
...pagination,
};
const [list] = useQuery({ query: QUERY, variables });
if (list.error) throw list.error;
const currentSession =
list.data?.viewerSession.__typename === "BrowserSession"
@@ -113,6 +151,16 @@ function BrowserSessions(): React.ReactElement {
<BlockList>
<H5>{t("frontend.browser_sessions_overview.heading")}</H5>
<div className="flex gap-2 items-start">
<Filter
to={Route.fullPath}
enabled={inactive}
search={{ ...DEFAULT_PAGE, inactive: inactive ? undefined : true }}
>
{t("frontend.last_active.inactive_90_days")}
</Filter>
</div>
{edges.map((n) => (
<BrowserSession
key={n.cursor}
@@ -121,6 +169,20 @@ function BrowserSessions(): React.ReactElement {
/>
))}
{currentSession.user.browserSessions.totalCount === 0 && (
<EmptyState>
{inactive
? t(
"frontend.browser_sessions_overview.no_active_sessions.inactive_90_days",
)
: t(
"frontend.browser_sessions_overview.no_active_sessions.default",
)}
</EmptyState>
)}
{/* Only show the pagination buttons if there are pages to go to */}
{(forwardPage || backwardPage) && (
<div className="flex *:flex-1">
<ButtonLink
kind="secondary"
@@ -145,6 +207,7 @@ function BrowserSessions(): React.ReactElement {
{t("common.next")}
</ButtonLink>
</div>
)}
</BlockList>
);
}

View File

@@ -13,19 +13,28 @@
// limitations under the License.
import { createFileRoute, notFound } from "@tanstack/react-router";
import { H3, H5 } from "@vector-im/compound-web";
import { H3, Separator } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import { useQuery } from "urql";
import * as z from "zod";
import BlockList from "../components/BlockList";
import { ButtonLink } from "../components/ButtonLink";
import CompatSession from "../components/CompatSession";
import EmptyState from "../components/EmptyState";
import Filter from "../components/Filter";
import OAuth2Session from "../components/OAuth2Session";
import BrowserSessionsOverview from "../components/UserSessionsOverview/BrowserSessionsOverview";
import { graphql } from "../gql";
import { Pagination, paginationSchema, usePages } from "../pagination";
import {
BackwardPagination,
Pagination,
paginationSchema,
usePages,
} from "../pagination";
const PAGE_SIZE = 6;
const DEFAULT_PAGE: BackwardPagination = { last: PAGE_SIZE };
const QUERY = graphql(/* GraphQL */ `
query SessionsOverviewQuery {
@@ -46,6 +55,7 @@ const LIST_QUERY = graphql(/* GraphQL */ `
$after: String
$first: Int
$last: Int
$lastActive: DateFilter
) {
viewer {
__typename
@@ -57,6 +67,7 @@ const LIST_QUERY = graphql(/* GraphQL */ `
after: $after
first: $first
last: $last
lastActive: $lastActive
state: ACTIVE
) {
edges {
@@ -86,16 +97,39 @@ const unknownSessionType = (type: never): never => {
throw new Error(`Unknown session type: ${type}`);
};
const searchSchema = z.object({
inactive: z.literal(true).optional().catch(undefined),
});
type Search = z.infer<typeof searchSchema>;
const getNintyDaysAgo = (): string => {
const date = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
// Round down to the start of the day to avoid rerendering/requerying
date.setHours(0, 0, 0, 0);
return date.toISOString();
};
export const Route = createFileRoute("/_account/sessions/")({
// We paginate backwards, so we need to validate the `last` parameter by default
validateSearch: paginationSchema.catch({ last: PAGE_SIZE }),
validateSearch: paginationSchema.catch(DEFAULT_PAGE).and(searchSchema),
loaderDeps: ({ search }): Pagination => paginationSchema.parse(search),
loaderDeps: ({ search }): Pagination & Search =>
paginationSchema.and(searchSchema).parse(search),
async loader({
context,
deps: { inactive, ...pagination },
abortController: { signal },
}) {
const variables = {
lastActive: inactive ? { before: getNintyDaysAgo() } : undefined,
...pagination,
};
async loader({ context, deps: pagination, abortController: { signal } }) {
const [overview, list] = await Promise.all([
context.client.query(QUERY, {}, { fetchOptions: { signal } }),
context.client.query(LIST_QUERY, pagination, {
context.client.query(LIST_QUERY, variables, {
fetchOptions: { signal },
}),
]);
@@ -111,14 +145,19 @@ export const Route = createFileRoute("/_account/sessions/")({
function Sessions(): React.ReactElement {
const { t } = useTranslation();
const pagination = Route.useLoaderDeps();
const { inactive, ...pagination } = Route.useLoaderDeps();
const [overview] = useQuery({ query: QUERY });
if (overview.error) throw overview.error;
const user =
overview.data?.viewer.__typename === "User" ? overview.data.viewer : null;
if (user === null) throw notFound();
const [list] = useQuery({ query: LIST_QUERY, variables: pagination });
const variables = {
lastActive: inactive ? { before: getNintyDaysAgo() } : undefined,
...pagination,
};
const [list] = useQuery({ query: LIST_QUERY, variables });
if (list.error) throw list.error;
const appSessions =
list.data?.viewer.__typename === "User"
@@ -139,13 +178,16 @@ function Sessions(): React.ReactElement {
<BlockList>
<H3>{t("frontend.user_sessions_overview.heading")}</H3>
<BrowserSessionsOverview user={user} />
<H5>
{t("frontend.user_sessions_overview.active_sessions", {
count: appSessions.totalCount,
})}
</H5>
<Separator />
<div className="flex gap-2 justify-start items-center">
<Filter
to={Route.fullPath}
enabled={inactive}
search={{ ...DEFAULT_PAGE, inactive: inactive ? undefined : true }}
>
{t("frontend.last_active.inactive_90_days")}
</Filter>
</div>
{edges.map((session) => {
const type = session.node.__typename;
switch (type) {
@@ -162,13 +204,25 @@ function Sessions(): React.ReactElement {
}
})}
{appSessions.totalCount === 0 && (
<EmptyState>
{inactive
? t(
"frontend.user_sessions_overview.no_active_sessions.inactive_90_days",
)
: t("frontend.user_sessions_overview.no_active_sessions.default")}
</EmptyState>
)}
{/* Only show the pagination buttons if there are pages to go to */}
{(forwardPage || backwardPage) && (
<div className="flex *:flex-1">
<ButtonLink
kind="secondary"
size="sm"
disabled={!forwardPage}
to={Route.fullPath}
search={forwardPage || pagination}
search={{ inactive, ...(forwardPage || pagination) }}
>
{t("common.previous")}
</ButtonLink>
@@ -181,11 +235,12 @@ function Sessions(): React.ReactElement {
size="sm"
disabled={!backwardPage}
to={Route.fullPath}
search={backwardPage || pagination}
search={{ inactive, ...(backwardPage || pagination) }}
>
{t("common.next")}
</ButtonLink>
</div>
)}
</BlockList>
);
}

View File

View File

@@ -19,7 +19,6 @@ import {
createRoute,
createRouter,
} from "@tanstack/react-router";
import { beforeAll } from "vitest";
const rootRoute = createRootRoute();
const index = createRoute({ getParentRoute: () => rootRoute, path: "/" });
@@ -31,8 +30,13 @@ const router = createRouter({
});
const routerReady = router.load();
// Because we also use this in the stories, we need to make sure we call this only in tests
if (import.meta.vitest) {
// Make sure the router is ready before running any tests
beforeAll(async () => await routerReady);
import("vitest").then(({ beforeAll }) =>
beforeAll(async () => await routerReady),
);
}
export const DummyRouter: React.FC<React.PropsWithChildren> = ({
children,