You've already forked authentication-service
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:
@@ -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",
|
||||
|
@@ -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"
|
||||
}
|
||||
|
25
frontend/src/components/EmptyState/EmptyState.module.css
Normal file
25
frontend/src/components/EmptyState/EmptyState.module.css
Normal 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);
|
||||
}
|
35
frontend/src/components/EmptyState/EmptyState.stories.tsx
Normal file
35
frontend/src/components/EmptyState/EmptyState.stories.tsx
Normal 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",
|
||||
},
|
||||
};
|
33
frontend/src/components/EmptyState/EmptyState.tsx
Normal file
33
frontend/src/components/EmptyState/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
});
|
15
frontend/src/components/EmptyState/index.ts
Normal file
15
frontend/src/components/EmptyState/index.ts
Normal 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";
|
59
frontend/src/components/Filter/Filter.module.css
Normal file
59
frontend/src/components/Filter/Filter.module.css
Normal 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);
|
||||
}
|
||||
}
|
53
frontend/src/components/Filter/Filter.stories.tsx
Normal file
53
frontend/src/components/Filter/Filter.stories.tsx
Normal 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,
|
||||
},
|
||||
};
|
47
frontend/src/components/Filter/Filter.tsx
Normal file
47
frontend/src/components/Filter/Filter.tsx
Normal 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>
|
||||
);
|
||||
}),
|
||||
);
|
15
frontend/src/components/Filter/index.ts
Normal file
15
frontend/src/components/Filter/index.ts
Normal 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";
|
@@ -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;
|
||||
}
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -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"
|
||||
|
@@ -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
@@ -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,30 +169,45 @@ function BrowserSessions(): React.ReactElement {
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="flex *:flex-1">
|
||||
<ButtonLink
|
||||
kind="secondary"
|
||||
size="sm"
|
||||
disabled={!forwardPage}
|
||||
to={Route.fullPath}
|
||||
search={forwardPage || pagination}
|
||||
>
|
||||
{t("common.previous")}
|
||||
</ButtonLink>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div />
|
||||
{/* 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}
|
||||
>
|
||||
{t("common.previous")}
|
||||
</ButtonLink>
|
||||
|
||||
<ButtonLink
|
||||
kind="secondary"
|
||||
size="sm"
|
||||
disabled={!backwardPage}
|
||||
to={Route.fullPath}
|
||||
search={backwardPage || pagination}
|
||||
>
|
||||
{t("common.next")}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
{/* Spacer */}
|
||||
<div />
|
||||
|
||||
<ButtonLink
|
||||
kind="secondary"
|
||||
size="sm"
|
||||
disabled={!backwardPage}
|
||||
to={Route.fullPath}
|
||||
search={backwardPage || pagination}
|
||||
>
|
||||
{t("common.next")}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
)}
|
||||
</BlockList>
|
||||
);
|
||||
}
|
||||
|
@@ -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,30 +204,43 @@ function Sessions(): React.ReactElement {
|
||||
}
|
||||
})}
|
||||
|
||||
<div className="flex *:flex-1">
|
||||
<ButtonLink
|
||||
kind="secondary"
|
||||
size="sm"
|
||||
disabled={!forwardPage}
|
||||
to={Route.fullPath}
|
||||
search={forwardPage || pagination}
|
||||
>
|
||||
{t("common.previous")}
|
||||
</ButtonLink>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div />
|
||||
{/* 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={{ inactive, ...(forwardPage || pagination) }}
|
||||
>
|
||||
{t("common.previous")}
|
||||
</ButtonLink>
|
||||
|
||||
<ButtonLink
|
||||
kind="secondary"
|
||||
size="sm"
|
||||
disabled={!backwardPage}
|
||||
to={Route.fullPath}
|
||||
search={backwardPage || pagination}
|
||||
>
|
||||
{t("common.next")}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
{/* Spacer */}
|
||||
<div />
|
||||
|
||||
<ButtonLink
|
||||
kind="secondary"
|
||||
size="sm"
|
||||
disabled={!backwardPage}
|
||||
to={Route.fullPath}
|
||||
search={{ inactive, ...(backwardPage || pagination) }}
|
||||
>
|
||||
{t("common.next")}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
)}
|
||||
</BlockList>
|
||||
);
|
||||
}
|
||||
|
0
frontend/src/test-utils/dummy-router.tsx
Normal file
0
frontend/src/test-utils/dummy-router.tsx
Normal 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();
|
||||
// Make sure the router is ready before running any tests
|
||||
beforeAll(async () => await routerReady);
|
||||
// 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
|
||||
import("vitest").then(({ beforeAll }) =>
|
||||
beforeAll(async () => await routerReady),
|
||||
);
|
||||
}
|
||||
|
||||
export const DummyRouter: React.FC<React.PropsWithChildren> = ({
|
||||
children,
|
||||
|
Reference in New Issue
Block a user