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:one": "{{count}} active session",
"body:other": "{{count}} active sessions", "body:other": "{{count}} active sessions",
"heading": "Browsers", "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" "view_all_button": "View all"
}, },
"compat_session_detail": { "compat_session_detail": {
@@ -230,9 +234,11 @@
"no_primary_email_alert": "No primary email address" "no_primary_email_alert": "No primary email address"
}, },
"user_sessions_overview": { "user_sessions_overview": {
"active_sessions:one": "{{count}} active session", "heading": "Where you're signed in",
"active_sessions:other": "{{count}} active sessions", "no_active_sessions": {
"heading": "Where you're signed in" "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": { "verify_email": {
"code_field_error": "Code not recognised", "code_field_error": "Code not recognised",

View File

@@ -87,5 +87,6 @@
"vite-plugin-graphql-codegen": "^3.3.8", "vite-plugin-graphql-codegen": "^3.3.8",
"vite-plugin-manifest-sri": "^0.2.0", "vite-plugin-manifest-sri": "^0.2.0",
"vitest": "^1.4.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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -13,15 +13,13 @@
* limitations under the License. * limitations under the License.
*/ */
.session-list-block { .browser-sessions-overview {
display: flex; display: flex;
flex-direction: row; align-items: center;
justify-content: space-between; gap: var(--cpd-space-4x);
align-items: flex-start; padding: var(--cpd-space-4x);
gap: var(--cpd-space-1x); border-radius: var(--cpd-space-3x);
} background-color: var(--cpd-color-bg-canvas-default);
outline: 1px solid var(--cpd-color-gray-400);
.session-list-block-info { outline-offset: -1px;
display: flex;
flex-direction: column;
} }

View File

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

View File

@@ -3,10 +3,10 @@
exports[`BrowserSessionsOverview > renders with no browser sessions 1`] = ` exports[`BrowserSessionsOverview > renders with no browser sessions 1`] = `
<div> <div>
<div <div
class="_block_17898c _sessionListBlock_c5c17e" class="_browserSessionsOverview_c5c17e"
> >
<div <div
class="_sessionListBlockInfo_c5c17e" class="flex flex-1 flex-col"
> >
<h5 <h5
class="_typography_yh5dq_162 _font-body-lg-semibold_yh5dq_83" 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`] = ` exports[`BrowserSessionsOverview > renders with sessions 1`] = `
<div> <div>
<div <div
class="_block_17898c _sessionListBlock_c5c17e" class="_browserSessionsOverview_c5c17e"
> >
<div <div
class="_sessionListBlockInfo_c5c17e" class="flex flex-1 flex-col"
> >
<h5 <h5
class="_typography_yh5dq_162 _font-body-lg-semibold_yh5dq_83" 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 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 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 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 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 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 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, "\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. * 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. * 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. * 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. * 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 { H5 } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "urql"; import { useQuery } from "urql";
import * as z from "zod";
import BlockList from "../components/BlockList"; import BlockList from "../components/BlockList";
import BrowserSession from "../components/BrowserSession"; import BrowserSession from "../components/BrowserSession";
import { ButtonLink } from "../components/ButtonLink"; import { ButtonLink } from "../components/ButtonLink";
import EmptyState from "../components/EmptyState";
import Filter from "../components/Filter";
import { graphql } from "../gql"; import { graphql } from "../gql";
import { Pagination, paginationSchema, usePages } from "../pagination"; import {
BackwardPagination,
Pagination,
paginationSchema,
usePages,
} from "../pagination";
const PAGE_SIZE = 6; const PAGE_SIZE = 6;
const DEFAULT_PAGE: BackwardPagination = { last: PAGE_SIZE };
const QUERY = graphql(/* GraphQL */ ` const QUERY = graphql(/* GraphQL */ `
query BrowserSessionList( query BrowserSessionList(
@@ -31,6 +40,7 @@ const QUERY = graphql(/* GraphQL */ `
$after: String $after: String
$last: Int $last: Int
$before: String $before: String
$lastActive: DateFilter
) { ) {
viewerSession { viewerSession {
__typename __typename
@@ -45,6 +55,7 @@ const QUERY = graphql(/* GraphQL */ `
after: $after after: $after
last: $last last: $last
before: $before before: $before
lastActive: $lastActive
state: ACTIVE state: ACTIVE
) { ) {
totalCount 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")({ export const Route = createFileRoute("/_account/sessions/browsers")({
// We paginate backwards, so we need to validate the `last` parameter by default // We paginate backwards, so we need to validate the `last` parameter by default
validateSearch: paginationSchema.catch({ validateSearch: paginationSchema.catch(DEFAULT_PAGE).and(searchSchema),
last: PAGE_SIZE,
}),
loaderDeps: ({ search }): Pagination => paginationSchema.parse(search), loaderDeps: ({ search }): Pagination & Search =>
paginationSchema.and(searchSchema).parse(search),
async loader({ context, deps: pagination, abortController: { signal } }) { async loader({
const result = await context.client.query(QUERY, pagination, { context,
deps: { inactive, ...pagination },
abortController: { signal },
}) {
const variables = {
lastActive: inactive ? { before: getNintyDaysAgo() } : undefined,
...pagination,
};
const result = await context.client.query(QUERY, variables, {
fetchOptions: { signal }, fetchOptions: { signal },
}); });
if (result.error) throw result.error; if (result.error) throw result.error;
@@ -92,8 +124,14 @@ export const Route = createFileRoute("/_account/sessions/browsers")({
function BrowserSessions(): React.ReactElement { function BrowserSessions(): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const pagination = Route.useLoaderDeps(); const { inactive, ...pagination } = Route.useLoaderDeps();
const [list] = useQuery({ query: QUERY, variables: pagination });
const variables = {
lastActive: inactive ? { before: getNintyDaysAgo() } : undefined,
...pagination,
};
const [list] = useQuery({ query: QUERY, variables });
if (list.error) throw list.error; if (list.error) throw list.error;
const currentSession = const currentSession =
list.data?.viewerSession.__typename === "BrowserSession" list.data?.viewerSession.__typename === "BrowserSession"
@@ -113,6 +151,16 @@ function BrowserSessions(): React.ReactElement {
<BlockList> <BlockList>
<H5>{t("frontend.browser_sessions_overview.heading")}</H5> <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) => ( {edges.map((n) => (
<BrowserSession <BrowserSession
key={n.cursor} key={n.cursor}
@@ -121,30 +169,45 @@ function BrowserSessions(): React.ReactElement {
/> />
))} ))}
<div className="flex *:flex-1"> {currentSession.user.browserSessions.totalCount === 0 && (
<ButtonLink <EmptyState>
kind="secondary" {inactive
size="sm" ? t(
disabled={!forwardPage} "frontend.browser_sessions_overview.no_active_sessions.inactive_90_days",
to={Route.fullPath} )
search={forwardPage || pagination} : t(
> "frontend.browser_sessions_overview.no_active_sessions.default",
{t("common.previous")} )}
</ButtonLink> </EmptyState>
)}
{/* Spacer */} {/* Only show the pagination buttons if there are pages to go to */}
<div /> {(forwardPage || backwardPage) && (
<div className="flex *:flex-1">
<ButtonLink
kind="secondary"
size="sm"
disabled={!forwardPage}
to={Route.fullPath}
search={forwardPage || pagination}
>
{t("common.previous")}
</ButtonLink>
<ButtonLink {/* Spacer */}
kind="secondary" <div />
size="sm"
disabled={!backwardPage} <ButtonLink
to={Route.fullPath} kind="secondary"
search={backwardPage || pagination} size="sm"
> disabled={!backwardPage}
{t("common.next")} to={Route.fullPath}
</ButtonLink> search={backwardPage || pagination}
</div> >
{t("common.next")}
</ButtonLink>
</div>
)}
</BlockList> </BlockList>
); );
} }

View File

@@ -13,19 +13,28 @@
// limitations under the License. // limitations under the License.
import { createFileRoute, notFound } from "@tanstack/react-router"; 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 { useTranslation } from "react-i18next";
import { useQuery } from "urql"; import { useQuery } from "urql";
import * as z from "zod";
import BlockList from "../components/BlockList"; import BlockList from "../components/BlockList";
import { ButtonLink } from "../components/ButtonLink"; import { ButtonLink } from "../components/ButtonLink";
import CompatSession from "../components/CompatSession"; import CompatSession from "../components/CompatSession";
import EmptyState from "../components/EmptyState";
import Filter from "../components/Filter";
import OAuth2Session from "../components/OAuth2Session"; import OAuth2Session from "../components/OAuth2Session";
import BrowserSessionsOverview from "../components/UserSessionsOverview/BrowserSessionsOverview"; import BrowserSessionsOverview from "../components/UserSessionsOverview/BrowserSessionsOverview";
import { graphql } from "../gql"; import { graphql } from "../gql";
import { Pagination, paginationSchema, usePages } from "../pagination"; import {
BackwardPagination,
Pagination,
paginationSchema,
usePages,
} from "../pagination";
const PAGE_SIZE = 6; const PAGE_SIZE = 6;
const DEFAULT_PAGE: BackwardPagination = { last: PAGE_SIZE };
const QUERY = graphql(/* GraphQL */ ` const QUERY = graphql(/* GraphQL */ `
query SessionsOverviewQuery { query SessionsOverviewQuery {
@@ -46,6 +55,7 @@ const LIST_QUERY = graphql(/* GraphQL */ `
$after: String $after: String
$first: Int $first: Int
$last: Int $last: Int
$lastActive: DateFilter
) { ) {
viewer { viewer {
__typename __typename
@@ -57,6 +67,7 @@ const LIST_QUERY = graphql(/* GraphQL */ `
after: $after after: $after
first: $first first: $first
last: $last last: $last
lastActive: $lastActive
state: ACTIVE state: ACTIVE
) { ) {
edges { edges {
@@ -86,16 +97,39 @@ const unknownSessionType = (type: never): never => {
throw new Error(`Unknown session type: ${type}`); 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/")({ export const Route = createFileRoute("/_account/sessions/")({
// We paginate backwards, so we need to validate the `last` parameter by default // 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([ const [overview, list] = await Promise.all([
context.client.query(QUERY, {}, { fetchOptions: { signal } }), context.client.query(QUERY, {}, { fetchOptions: { signal } }),
context.client.query(LIST_QUERY, pagination, { context.client.query(LIST_QUERY, variables, {
fetchOptions: { signal }, fetchOptions: { signal },
}), }),
]); ]);
@@ -111,14 +145,19 @@ export const Route = createFileRoute("/_account/sessions/")({
function Sessions(): React.ReactElement { function Sessions(): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const pagination = Route.useLoaderDeps(); const { inactive, ...pagination } = Route.useLoaderDeps();
const [overview] = useQuery({ query: QUERY }); const [overview] = useQuery({ query: QUERY });
if (overview.error) throw overview.error; if (overview.error) throw overview.error;
const user = const user =
overview.data?.viewer.__typename === "User" ? overview.data.viewer : null; overview.data?.viewer.__typename === "User" ? overview.data.viewer : null;
if (user === null) throw notFound(); 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; if (list.error) throw list.error;
const appSessions = const appSessions =
list.data?.viewer.__typename === "User" list.data?.viewer.__typename === "User"
@@ -139,13 +178,16 @@ function Sessions(): React.ReactElement {
<BlockList> <BlockList>
<H3>{t("frontend.user_sessions_overview.heading")}</H3> <H3>{t("frontend.user_sessions_overview.heading")}</H3>
<BrowserSessionsOverview user={user} /> <BrowserSessionsOverview user={user} />
<Separator />
<H5> <div className="flex gap-2 justify-start items-center">
{t("frontend.user_sessions_overview.active_sessions", { <Filter
count: appSessions.totalCount, to={Route.fullPath}
})} enabled={inactive}
</H5> search={{ ...DEFAULT_PAGE, inactive: inactive ? undefined : true }}
>
{t("frontend.last_active.inactive_90_days")}
</Filter>
</div>
{edges.map((session) => { {edges.map((session) => {
const type = session.node.__typename; const type = session.node.__typename;
switch (type) { switch (type) {
@@ -162,30 +204,43 @@ function Sessions(): React.ReactElement {
} }
})} })}
<div className="flex *:flex-1"> {appSessions.totalCount === 0 && (
<ButtonLink <EmptyState>
kind="secondary" {inactive
size="sm" ? t(
disabled={!forwardPage} "frontend.user_sessions_overview.no_active_sessions.inactive_90_days",
to={Route.fullPath} )
search={forwardPage || pagination} : t("frontend.user_sessions_overview.no_active_sessions.default")}
> </EmptyState>
{t("common.previous")} )}
</ButtonLink>
{/* Spacer */} {/* Only show the pagination buttons if there are pages to go to */}
<div /> {(forwardPage || backwardPage) && (
<div className="flex *:flex-1">
<ButtonLink
kind="secondary"
size="sm"
disabled={!forwardPage}
to={Route.fullPath}
search={{ inactive, ...(forwardPage || pagination) }}
>
{t("common.previous")}
</ButtonLink>
<ButtonLink {/* Spacer */}
kind="secondary" <div />
size="sm"
disabled={!backwardPage} <ButtonLink
to={Route.fullPath} kind="secondary"
search={backwardPage || pagination} size="sm"
> disabled={!backwardPage}
{t("common.next")} to={Route.fullPath}
</ButtonLink> search={{ inactive, ...(backwardPage || pagination) }}
</div> >
{t("common.next")}
</ButtonLink>
</div>
)}
</BlockList> </BlockList>
); );
} }

View File

View File

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