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: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",
|
||||||
|
@@ -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"
|
||||||
}
|
}
|
||||||
|
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");
|
* 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;
|
|
||||||
}
|
}
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -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"
|
||||||
|
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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,
|
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,
|
||||||
|
Reference in New Issue
Block a user