Use automatic route code splitting
This commit is contained in:
@@ -55,9 +55,9 @@ type Documents = {
|
||||
"\n query OAuth2Client($id: ID!) {\n oauth2Client(id: $id) {\n ...OAuth2Client_detail\n }\n }\n": typeof types.OAuth2ClientDocument,
|
||||
"\n query CurrentViewer {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n }\n": typeof types.CurrentViewerDocument,
|
||||
"\n query DeviceRedirect($deviceId: String!, $userId: ID!) {\n session(deviceId: $deviceId, userId: $userId) {\n __typename\n ... on Node {\n id\n }\n }\n }\n": typeof types.DeviceRedirectDocument,
|
||||
"\n query VerifyEmail($id: ID!) {\n userEmailAuthentication(id: $id) {\n id\n email\n completedAt\n }\n }\n": typeof types.VerifyEmailDocument,
|
||||
"\n mutation DoVerifyEmail($id: ID!, $code: String!) {\n completeEmailAuthentication(input: { id: $id, code: $code }) {\n status\n }\n }\n": typeof types.DoVerifyEmailDocument,
|
||||
"\n mutation ResendEmailAuthenticationCode($id: ID!, $language: String!) {\n resendEmailAuthenticationCode(input: { id: $id, language: $language }) {\n status\n }\n }\n": typeof types.ResendEmailAuthenticationCodeDocument,
|
||||
"\n query VerifyEmail($id: ID!) {\n userEmailAuthentication(id: $id) {\n id\n email\n completedAt\n }\n }\n": typeof types.VerifyEmailDocument,
|
||||
"\n mutation ChangePassword(\n $userId: ID!\n $oldPassword: String!\n $newPassword: String!\n ) {\n setPassword(\n input: {\n userId: $userId\n currentPassword: $oldPassword\n newPassword: $newPassword\n }\n ) {\n status\n }\n }\n": typeof types.ChangePasswordDocument,
|
||||
"\n query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n }\n": typeof types.PasswordChangeDocument,
|
||||
"\n mutation RecoverPassword($ticket: String!, $newPassword: String!) {\n setPasswordByRecovery(\n input: { ticket: $ticket, newPassword: $newPassword }\n ) {\n status\n }\n }\n": typeof types.RecoverPasswordDocument,
|
||||
@@ -109,9 +109,9 @@ const documents: Documents = {
|
||||
"\n query OAuth2Client($id: ID!) {\n oauth2Client(id: $id) {\n ...OAuth2Client_detail\n }\n }\n": types.OAuth2ClientDocument,
|
||||
"\n query CurrentViewer {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n }\n": types.CurrentViewerDocument,
|
||||
"\n query DeviceRedirect($deviceId: String!, $userId: ID!) {\n session(deviceId: $deviceId, userId: $userId) {\n __typename\n ... on Node {\n id\n }\n }\n }\n": types.DeviceRedirectDocument,
|
||||
"\n query VerifyEmail($id: ID!) {\n userEmailAuthentication(id: $id) {\n id\n email\n completedAt\n }\n }\n": types.VerifyEmailDocument,
|
||||
"\n mutation DoVerifyEmail($id: ID!, $code: String!) {\n completeEmailAuthentication(input: { id: $id, code: $code }) {\n status\n }\n }\n": types.DoVerifyEmailDocument,
|
||||
"\n mutation ResendEmailAuthenticationCode($id: ID!, $language: String!) {\n resendEmailAuthenticationCode(input: { id: $id, language: $language }) {\n status\n }\n }\n": types.ResendEmailAuthenticationCodeDocument,
|
||||
"\n query VerifyEmail($id: ID!) {\n userEmailAuthentication(id: $id) {\n id\n email\n completedAt\n }\n }\n": types.VerifyEmailDocument,
|
||||
"\n mutation ChangePassword(\n $userId: ID!\n $oldPassword: String!\n $newPassword: String!\n ) {\n setPassword(\n input: {\n userId: $userId\n currentPassword: $oldPassword\n newPassword: $newPassword\n }\n ) {\n status\n }\n }\n": types.ChangePasswordDocument,
|
||||
"\n query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n }\n": types.PasswordChangeDocument,
|
||||
"\n mutation RecoverPassword($ticket: String!, $newPassword: String!) {\n setPasswordByRecovery(\n input: { ticket: $ticket, newPassword: $newPassword }\n ) {\n status\n }\n }\n": types.RecoverPasswordDocument,
|
||||
@@ -283,6 +283,10 @@ export function graphql(source: "\n query CurrentViewer {\n viewer {\n
|
||||
* 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 DeviceRedirect($deviceId: String!, $userId: ID!) {\n session(deviceId: $deviceId, userId: $userId) {\n __typename\n ... on Node {\n id\n }\n }\n }\n"): typeof import('./graphql').DeviceRedirectDocument;
|
||||
/**
|
||||
* 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 VerifyEmail($id: ID!) {\n userEmailAuthentication(id: $id) {\n id\n email\n completedAt\n }\n }\n"): typeof import('./graphql').VerifyEmailDocument;
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -291,10 +295,6 @@ export function graphql(source: "\n mutation DoVerifyEmail($id: ID!, $code: Str
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation ResendEmailAuthenticationCode($id: ID!, $language: String!) {\n resendEmailAuthenticationCode(input: { id: $id, language: $language }) {\n status\n }\n }\n"): typeof import('./graphql').ResendEmailAuthenticationCodeDocument;
|
||||
/**
|
||||
* 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 VerifyEmail($id: ID!) {\n userEmailAuthentication(id: $id) {\n id\n email\n completedAt\n }\n }\n"): typeof import('./graphql').VerifyEmailDocument;
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
||||
@@ -1849,6 +1849,13 @@ export type DeviceRedirectQueryVariables = Exact<{
|
||||
|
||||
export type DeviceRedirectQuery = { __typename?: 'Query', session?: { __typename: 'CompatSession', id: string } | { __typename: 'Oauth2Session', id: string } | null };
|
||||
|
||||
export type VerifyEmailQueryVariables = Exact<{
|
||||
id: Scalars['ID']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type VerifyEmailQuery = { __typename?: 'Query', userEmailAuthentication?: { __typename?: 'UserEmailAuthentication', id: string, email: string, completedAt?: string | null } | null };
|
||||
|
||||
export type DoVerifyEmailMutationVariables = Exact<{
|
||||
id: Scalars['ID']['input'];
|
||||
code: Scalars['String']['input'];
|
||||
@@ -1865,13 +1872,6 @@ export type ResendEmailAuthenticationCodeMutationVariables = Exact<{
|
||||
|
||||
export type ResendEmailAuthenticationCodeMutation = { __typename?: 'Mutation', resendEmailAuthenticationCode: { __typename?: 'ResendEmailAuthenticationCodePayload', status: ResendEmailAuthenticationCodeStatus } };
|
||||
|
||||
export type VerifyEmailQueryVariables = Exact<{
|
||||
id: Scalars['ID']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type VerifyEmailQuery = { __typename?: 'Query', userEmailAuthentication?: { __typename?: 'UserEmailAuthentication', id: string, email: string, completedAt?: string | null } | null };
|
||||
|
||||
export type ChangePasswordMutationVariables = Exact<{
|
||||
userId: Scalars['ID']['input'];
|
||||
oldPassword: Scalars['String']['input'];
|
||||
@@ -2705,6 +2705,15 @@ export const DeviceRedirectDocument = new TypedDocumentString(`
|
||||
}
|
||||
}
|
||||
`) as unknown as TypedDocumentString<DeviceRedirectQuery, DeviceRedirectQueryVariables>;
|
||||
export const VerifyEmailDocument = new TypedDocumentString(`
|
||||
query VerifyEmail($id: ID!) {
|
||||
userEmailAuthentication(id: $id) {
|
||||
id
|
||||
email
|
||||
completedAt
|
||||
}
|
||||
}
|
||||
`) as unknown as TypedDocumentString<VerifyEmailQuery, VerifyEmailQueryVariables>;
|
||||
export const DoVerifyEmailDocument = new TypedDocumentString(`
|
||||
mutation DoVerifyEmail($id: ID!, $code: String!) {
|
||||
completeEmailAuthentication(input: {id: $id, code: $code}) {
|
||||
@@ -2719,15 +2728,6 @@ export const ResendEmailAuthenticationCodeDocument = new TypedDocumentString(`
|
||||
}
|
||||
}
|
||||
`) as unknown as TypedDocumentString<ResendEmailAuthenticationCodeMutation, ResendEmailAuthenticationCodeMutationVariables>;
|
||||
export const VerifyEmailDocument = new TypedDocumentString(`
|
||||
query VerifyEmail($id: ID!) {
|
||||
userEmailAuthentication(id: $id) {
|
||||
id
|
||||
email
|
||||
completedAt
|
||||
}
|
||||
}
|
||||
`) as unknown as TypedDocumentString<VerifyEmailQuery, VerifyEmailQueryVariables>;
|
||||
export const ChangePasswordDocument = new TypedDocumentString(`
|
||||
mutation ChangePassword($userId: ID!, $oldPassword: String!, $newPassword: String!) {
|
||||
setPassword(
|
||||
@@ -3280,6 +3280,28 @@ export const mockDeviceRedirectQuery = (resolver: GraphQLResponseResolver<Device
|
||||
options
|
||||
)
|
||||
|
||||
/**
|
||||
* @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions))
|
||||
* @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options))
|
||||
* @see https://mswjs.io/docs/basics/response-resolver
|
||||
* @example
|
||||
* mockVerifyEmailQuery(
|
||||
* ({ query, variables }) => {
|
||||
* const { id } = variables;
|
||||
* return HttpResponse.json({
|
||||
* data: { userEmailAuthentication }
|
||||
* })
|
||||
* },
|
||||
* requestOptions
|
||||
* )
|
||||
*/
|
||||
export const mockVerifyEmailQuery = (resolver: GraphQLResponseResolver<VerifyEmailQuery, VerifyEmailQueryVariables>, options?: RequestHandlerOptions) =>
|
||||
graphql.query<VerifyEmailQuery, VerifyEmailQueryVariables>(
|
||||
'VerifyEmail',
|
||||
resolver,
|
||||
options
|
||||
)
|
||||
|
||||
/**
|
||||
* @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions))
|
||||
* @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options))
|
||||
@@ -3324,28 +3346,6 @@ export const mockResendEmailAuthenticationCodeMutation = (resolver: GraphQLRespo
|
||||
options
|
||||
)
|
||||
|
||||
/**
|
||||
* @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions))
|
||||
* @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options))
|
||||
* @see https://mswjs.io/docs/basics/response-resolver
|
||||
* @example
|
||||
* mockVerifyEmailQuery(
|
||||
* ({ query, variables }) => {
|
||||
* const { id } = variables;
|
||||
* return HttpResponse.json({
|
||||
* data: { userEmailAuthentication }
|
||||
* })
|
||||
* },
|
||||
* requestOptions
|
||||
* )
|
||||
*/
|
||||
export const mockVerifyEmailQuery = (resolver: GraphQLResponseResolver<VerifyEmailQuery, VerifyEmailQueryVariables>, options?: RequestHandlerOptions) =>
|
||||
graphql.query<VerifyEmailQuery, VerifyEmailQueryVariables>(
|
||||
'VerifyEmail',
|
||||
resolver,
|
||||
options
|
||||
)
|
||||
|
||||
/**
|
||||
* @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions))
|
||||
* @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options))
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
// Import Routes
|
||||
|
||||
import { Route as rootRoute } from './routes/__root'
|
||||
@@ -25,16 +23,11 @@ import { Route as ClientsIdImport } from './routes/clients.$id'
|
||||
import { Route as PasswordRecoveryIndexImport } from './routes/password.recovery.index'
|
||||
import { Route as PasswordChangeIndexImport } from './routes/password.change.index'
|
||||
import { Route as AccountSessionsIndexImport } from './routes/_account.sessions.index'
|
||||
import { Route as PasswordChangeSuccessImport } from './routes/password.change.success'
|
||||
import { Route as EmailsIdVerifyImport } from './routes/emails.$id.verify'
|
||||
import { Route as EmailsIdInUseImport } from './routes/emails.$id.in-use'
|
||||
import { Route as AccountSessionsBrowsersImport } from './routes/_account.sessions.browsers'
|
||||
|
||||
// Create Virtual Routes
|
||||
|
||||
const PasswordChangeSuccessLazyImport = createFileRoute(
|
||||
'/password/change/success',
|
||||
)()
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
const ResetCrossSigningRoute = ResetCrossSigningImport.update({
|
||||
@@ -46,7 +39,7 @@ const ResetCrossSigningRoute = ResetCrossSigningImport.update({
|
||||
const AccountRoute = AccountImport.update({
|
||||
id: '/_account',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any).lazy(() => import('./routes/_account.lazy').then((d) => d.Route))
|
||||
} as any)
|
||||
|
||||
const ResetCrossSigningIndexRoute = ResetCrossSigningIndexImport.update({
|
||||
id: '/',
|
||||
@@ -58,15 +51,13 @@ const AccountIndexRoute = AccountIndexImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AccountRoute,
|
||||
} as any).lazy(() =>
|
||||
import('./routes/_account.index.lazy').then((d) => d.Route),
|
||||
)
|
||||
} as any)
|
||||
|
||||
const SessionsIdRoute = SessionsIdImport.update({
|
||||
id: '/sessions/$id',
|
||||
path: '/sessions/$id',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any).lazy(() => import('./routes/sessions.$id.lazy').then((d) => d.Route))
|
||||
} as any)
|
||||
|
||||
const ResetCrossSigningSuccessRoute = ResetCrossSigningSuccessImport.update({
|
||||
id: '/success',
|
||||
@@ -92,47 +83,37 @@ const ClientsIdRoute = ClientsIdImport.update({
|
||||
id: '/clients/$id',
|
||||
path: '/clients/$id',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any).lazy(() => import('./routes/clients.$id.lazy').then((d) => d.Route))
|
||||
} as any)
|
||||
|
||||
const PasswordRecoveryIndexRoute = PasswordRecoveryIndexImport.update({
|
||||
id: '/password/recovery/',
|
||||
path: '/password/recovery/',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any).lazy(() =>
|
||||
import('./routes/password.recovery.index.lazy').then((d) => d.Route),
|
||||
)
|
||||
} as any)
|
||||
|
||||
const PasswordChangeIndexRoute = PasswordChangeIndexImport.update({
|
||||
id: '/password/change/',
|
||||
path: '/password/change/',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any).lazy(() =>
|
||||
import('./routes/password.change.index.lazy').then((d) => d.Route),
|
||||
)
|
||||
} as any)
|
||||
|
||||
const AccountSessionsIndexRoute = AccountSessionsIndexImport.update({
|
||||
id: '/sessions/',
|
||||
path: '/sessions/',
|
||||
getParentRoute: () => AccountRoute,
|
||||
} as any).lazy(() =>
|
||||
import('./routes/_account.sessions.index.lazy').then((d) => d.Route),
|
||||
)
|
||||
} as any)
|
||||
|
||||
const PasswordChangeSuccessLazyRoute = PasswordChangeSuccessLazyImport.update({
|
||||
const PasswordChangeSuccessRoute = PasswordChangeSuccessImport.update({
|
||||
id: '/password/change/success',
|
||||
path: '/password/change/success',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any).lazy(() =>
|
||||
import('./routes/password.change.success.lazy').then((d) => d.Route),
|
||||
)
|
||||
} as any)
|
||||
|
||||
const EmailsIdVerifyRoute = EmailsIdVerifyImport.update({
|
||||
id: '/emails/$id/verify',
|
||||
path: '/emails/$id/verify',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any).lazy(() =>
|
||||
import('./routes/emails.$id.verify.lazy').then((d) => d.Route),
|
||||
)
|
||||
} as any)
|
||||
|
||||
const EmailsIdInUseRoute = EmailsIdInUseImport.update({
|
||||
id: '/emails/$id/in-use',
|
||||
@@ -144,9 +125,7 @@ const AccountSessionsBrowsersRoute = AccountSessionsBrowsersImport.update({
|
||||
id: '/sessions/browsers',
|
||||
path: '/sessions/browsers',
|
||||
getParentRoute: () => AccountRoute,
|
||||
} as any).lazy(() =>
|
||||
import('./routes/_account.sessions.browsers.lazy').then((d) => d.Route),
|
||||
)
|
||||
} as any)
|
||||
|
||||
// Populate the FileRoutesByPath interface
|
||||
|
||||
@@ -240,7 +219,7 @@ declare module '@tanstack/react-router' {
|
||||
id: '/password/change/success'
|
||||
path: '/password/change/success'
|
||||
fullPath: '/password/change/success'
|
||||
preLoaderRoute: typeof PasswordChangeSuccessLazyImport
|
||||
preLoaderRoute: typeof PasswordChangeSuccessImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/_account/sessions/': {
|
||||
@@ -312,7 +291,7 @@ export interface FileRoutesByFullPath {
|
||||
'/sessions/browsers': typeof AccountSessionsBrowsersRoute
|
||||
'/emails/$id/in-use': typeof EmailsIdInUseRoute
|
||||
'/emails/$id/verify': typeof EmailsIdVerifyRoute
|
||||
'/password/change/success': typeof PasswordChangeSuccessLazyRoute
|
||||
'/password/change/success': typeof PasswordChangeSuccessRoute
|
||||
'/sessions': typeof AccountSessionsIndexRoute
|
||||
'/password/change': typeof PasswordChangeIndexRoute
|
||||
'/password/recovery': typeof PasswordRecoveryIndexRoute
|
||||
@@ -329,7 +308,7 @@ export interface FileRoutesByTo {
|
||||
'/sessions/browsers': typeof AccountSessionsBrowsersRoute
|
||||
'/emails/$id/in-use': typeof EmailsIdInUseRoute
|
||||
'/emails/$id/verify': typeof EmailsIdVerifyRoute
|
||||
'/password/change/success': typeof PasswordChangeSuccessLazyRoute
|
||||
'/password/change/success': typeof PasswordChangeSuccessRoute
|
||||
'/sessions': typeof AccountSessionsIndexRoute
|
||||
'/password/change': typeof PasswordChangeIndexRoute
|
||||
'/password/recovery': typeof PasswordRecoveryIndexRoute
|
||||
@@ -349,7 +328,7 @@ export interface FileRoutesById {
|
||||
'/_account/sessions/browsers': typeof AccountSessionsBrowsersRoute
|
||||
'/emails/$id/in-use': typeof EmailsIdInUseRoute
|
||||
'/emails/$id/verify': typeof EmailsIdVerifyRoute
|
||||
'/password/change/success': typeof PasswordChangeSuccessLazyRoute
|
||||
'/password/change/success': typeof PasswordChangeSuccessRoute
|
||||
'/_account/sessions/': typeof AccountSessionsIndexRoute
|
||||
'/password/change/': typeof PasswordChangeIndexRoute
|
||||
'/password/recovery/': typeof PasswordRecoveryIndexRoute
|
||||
@@ -419,7 +398,7 @@ export interface RootRouteChildren {
|
||||
SessionsIdRoute: typeof SessionsIdRoute
|
||||
EmailsIdInUseRoute: typeof EmailsIdInUseRoute
|
||||
EmailsIdVerifyRoute: typeof EmailsIdVerifyRoute
|
||||
PasswordChangeSuccessLazyRoute: typeof PasswordChangeSuccessLazyRoute
|
||||
PasswordChangeSuccessRoute: typeof PasswordChangeSuccessRoute
|
||||
PasswordChangeIndexRoute: typeof PasswordChangeIndexRoute
|
||||
PasswordRecoveryIndexRoute: typeof PasswordRecoveryIndexRoute
|
||||
}
|
||||
@@ -432,7 +411,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
SessionsIdRoute: SessionsIdRoute,
|
||||
EmailsIdInUseRoute: EmailsIdInUseRoute,
|
||||
EmailsIdVerifyRoute: EmailsIdVerifyRoute,
|
||||
PasswordChangeSuccessLazyRoute: PasswordChangeSuccessLazyRoute,
|
||||
PasswordChangeSuccessRoute: PasswordChangeSuccessRoute,
|
||||
PasswordChangeIndexRoute: PasswordChangeIndexRoute,
|
||||
PasswordRecoveryIndexRoute: PasswordRecoveryIndexRoute,
|
||||
}
|
||||
@@ -511,7 +490,7 @@ export const routeTree = rootRoute
|
||||
"filePath": "emails.$id.verify.tsx"
|
||||
},
|
||||
"/password/change/success": {
|
||||
"filePath": "password.change.success.lazy.tsx"
|
||||
"filePath": "password.change.success.tsx"
|
||||
},
|
||||
"/_account/sessions/": {
|
||||
"filePath": "_account.sessions.index.tsx",
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
// Copyright 2024, 2025 New Vector Ltd.
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
createLazyFileRoute,
|
||||
notFound,
|
||||
useNavigate,
|
||||
} from "@tanstack/react-router";
|
||||
import IconSignOut from "@vector-im/compound-design-tokens/assets/web/icons/sign-out";
|
||||
import { Button, Text } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AccountDeleteButton from "../components/AccountDeleteButton";
|
||||
import AccountManagementPasswordPreview from "../components/AccountManagementPasswordPreview";
|
||||
import { ButtonLink } from "../components/ButtonLink";
|
||||
import * as Collapsible from "../components/Collapsible";
|
||||
import * as Dialog from "../components/Dialog";
|
||||
import LoadingSpinner from "../components/LoadingSpinner";
|
||||
import Separator from "../components/Separator";
|
||||
import { useEndBrowserSession } from "../components/Session/EndBrowserSessionButton";
|
||||
import AddEmailForm from "../components/UserProfile/AddEmailForm";
|
||||
import UserEmailList from "../components/UserProfile/UserEmailList";
|
||||
import { query } from "./_account.index";
|
||||
|
||||
export const Route = createLazyFileRoute("/_account/")({
|
||||
component: Index,
|
||||
});
|
||||
|
||||
const SignOutButton: React.FC<{ id: string }> = ({ id }) => {
|
||||
const { t } = useTranslation();
|
||||
const mutation = useEndBrowserSession(id, true);
|
||||
|
||||
return (
|
||||
<Dialog.Dialog
|
||||
trigger={
|
||||
<Button kind="primary" destructive size="lg" Icon={IconSignOut}>
|
||||
{t("frontend.account.sign_out.button")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Dialog.Title>{t("frontend.account.sign_out.dialog")}</Dialog.Title>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
kind="primary"
|
||||
destructive
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending}
|
||||
Icon={mutation.isPending ? undefined : IconSignOut}
|
||||
>
|
||||
{mutation.isPending && <LoadingSpinner inline />}
|
||||
{t("action.sign_out")}
|
||||
</Button>
|
||||
|
||||
<Dialog.Close asChild>
|
||||
<Button kind="tertiary">{t("action.cancel")}</Button>
|
||||
</Dialog.Close>
|
||||
</Dialog.Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
function Index(): React.ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data: { viewerSession, siteConfig },
|
||||
} = useSuspenseQuery(query);
|
||||
if (viewerSession?.__typename !== "BrowserSession") throw notFound();
|
||||
|
||||
// When adding an email, we want to go to the email verification form
|
||||
const onAdd = async (id: string): Promise<void> => {
|
||||
await navigate({ to: "/emails/$id/verify", params: { id } });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Only display this section if the user can add email addresses to their
|
||||
account *or* if they have any existing email addresses */}
|
||||
{(siteConfig.emailChangeAllowed ||
|
||||
viewerSession.user.emails.totalCount > 0) && (
|
||||
<>
|
||||
<Collapsible.Section
|
||||
defaultOpen
|
||||
title={t("frontend.account.contact_info")}
|
||||
>
|
||||
<UserEmailList
|
||||
user={viewerSession.user}
|
||||
siteConfig={siteConfig}
|
||||
/>
|
||||
|
||||
{siteConfig.emailChangeAllowed && (
|
||||
<AddEmailForm
|
||||
user={viewerSession.user}
|
||||
siteConfig={siteConfig}
|
||||
onAdd={onAdd}
|
||||
/>
|
||||
)}
|
||||
</Collapsible.Section>
|
||||
|
||||
<Separator kind="section" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{siteConfig.passwordLoginEnabled && viewerSession.user.hasPassword && (
|
||||
<>
|
||||
<Collapsible.Section
|
||||
defaultOpen
|
||||
title={t("frontend.account.account_password")}
|
||||
>
|
||||
<AccountManagementPasswordPreview siteConfig={siteConfig} />
|
||||
</Collapsible.Section>
|
||||
|
||||
<Separator kind="section" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Collapsible.Section title={t("common.e2ee")}>
|
||||
<Text className="text-secondary" size="md">
|
||||
{t("frontend.reset_cross_signing.description")}
|
||||
</Text>
|
||||
<ButtonLink to="/reset-cross-signing" kind="secondary" destructive>
|
||||
{t("frontend.reset_cross_signing.start_reset")}
|
||||
</ButtonLink>
|
||||
</Collapsible.Section>
|
||||
|
||||
<Separator kind="section" />
|
||||
|
||||
<SignOutButton id={viewerSession.id} />
|
||||
|
||||
{siteConfig.accountDeactivationAllowed && (
|
||||
<>
|
||||
<Separator />
|
||||
<AccountDeleteButton
|
||||
user={viewerSession.user}
|
||||
siteConfig={siteConfig}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,29 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
createFileRoute,
|
||||
notFound,
|
||||
redirect,
|
||||
useNavigate,
|
||||
} from "@tanstack/react-router";
|
||||
import IconSignOut from "@vector-im/compound-design-tokens/assets/web/icons/sign-out";
|
||||
import { Button, Text } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as v from "valibot";
|
||||
import { query as userEmailListQuery } from "../components/UserProfile/UserEmailList";
|
||||
import AccountDeleteButton from "../components/AccountDeleteButton";
|
||||
import AccountManagementPasswordPreview from "../components/AccountManagementPasswordPreview";
|
||||
import { ButtonLink } from "../components/ButtonLink";
|
||||
import * as Collapsible from "../components/Collapsible";
|
||||
import * as Dialog from "../components/Dialog";
|
||||
import LoadingSpinner from "../components/LoadingSpinner";
|
||||
import Separator from "../components/Separator";
|
||||
import { useEndBrowserSession } from "../components/Session/EndBrowserSessionButton";
|
||||
import AddEmailForm from "../components/UserProfile/AddEmailForm";
|
||||
import UserEmailList, {
|
||||
query as userEmailListQuery,
|
||||
} from "../components/UserProfile/UserEmailList";
|
||||
import { graphql } from "../gql";
|
||||
import { graphqlRequest } from "../graphql";
|
||||
|
||||
@@ -41,7 +60,7 @@ const QUERY = graphql(/* GraphQL */ `
|
||||
}
|
||||
`);
|
||||
|
||||
export const query = queryOptions({
|
||||
const query = queryOptions({
|
||||
queryKey: ["userProfile"],
|
||||
queryFn: ({ signal }) => graphqlRequest({ query: QUERY, signal }),
|
||||
});
|
||||
@@ -115,4 +134,124 @@ export const Route = createFileRoute("/_account/")({
|
||||
context.queryClient.ensureQueryData(userEmailListQuery()),
|
||||
context.queryClient.ensureQueryData(query),
|
||||
]),
|
||||
|
||||
component: Index,
|
||||
});
|
||||
|
||||
const SignOutButton: React.FC<{ id: string }> = ({ id }) => {
|
||||
const { t } = useTranslation();
|
||||
const mutation = useEndBrowserSession(id, true);
|
||||
|
||||
return (
|
||||
<Dialog.Dialog
|
||||
trigger={
|
||||
<Button kind="primary" destructive size="lg" Icon={IconSignOut}>
|
||||
{t("frontend.account.sign_out.button")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Dialog.Title>{t("frontend.account.sign_out.dialog")}</Dialog.Title>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
kind="primary"
|
||||
destructive
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending}
|
||||
Icon={mutation.isPending ? undefined : IconSignOut}
|
||||
>
|
||||
{mutation.isPending && <LoadingSpinner inline />}
|
||||
{t("action.sign_out")}
|
||||
</Button>
|
||||
|
||||
<Dialog.Close asChild>
|
||||
<Button kind="tertiary">{t("action.cancel")}</Button>
|
||||
</Dialog.Close>
|
||||
</Dialog.Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
function Index(): React.ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data: { viewerSession, siteConfig },
|
||||
} = useSuspenseQuery(query);
|
||||
if (viewerSession?.__typename !== "BrowserSession") throw notFound();
|
||||
|
||||
// When adding an email, we want to go to the email verification form
|
||||
const onAdd = async (id: string): Promise<void> => {
|
||||
await navigate({ to: "/emails/$id/verify", params: { id } });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Only display this section if the user can add email addresses to their
|
||||
account *or* if they have any existing email addresses */}
|
||||
{(siteConfig.emailChangeAllowed ||
|
||||
viewerSession.user.emails.totalCount > 0) && (
|
||||
<>
|
||||
<Collapsible.Section
|
||||
defaultOpen
|
||||
title={t("frontend.account.contact_info")}
|
||||
>
|
||||
<UserEmailList
|
||||
user={viewerSession.user}
|
||||
siteConfig={siteConfig}
|
||||
/>
|
||||
|
||||
{siteConfig.emailChangeAllowed && (
|
||||
<AddEmailForm
|
||||
user={viewerSession.user}
|
||||
siteConfig={siteConfig}
|
||||
onAdd={onAdd}
|
||||
/>
|
||||
)}
|
||||
</Collapsible.Section>
|
||||
|
||||
<Separator kind="section" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{siteConfig.passwordLoginEnabled && viewerSession.user.hasPassword && (
|
||||
<>
|
||||
<Collapsible.Section
|
||||
defaultOpen
|
||||
title={t("frontend.account.account_password")}
|
||||
>
|
||||
<AccountManagementPasswordPreview siteConfig={siteConfig} />
|
||||
</Collapsible.Section>
|
||||
|
||||
<Separator kind="section" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Collapsible.Section title={t("common.e2ee")}>
|
||||
<Text className="text-secondary" size="md">
|
||||
{t("frontend.reset_cross_signing.description")}
|
||||
</Text>
|
||||
<ButtonLink to="/reset-cross-signing" kind="secondary" destructive>
|
||||
{t("frontend.reset_cross_signing.start_reset")}
|
||||
</ButtonLink>
|
||||
</Collapsible.Section>
|
||||
|
||||
<Separator kind="section" />
|
||||
|
||||
<SignOutButton id={viewerSession.id} />
|
||||
|
||||
{siteConfig.accountDeactivationAllowed && (
|
||||
<>
|
||||
<Separator />
|
||||
<AccountDeleteButton
|
||||
user={viewerSession.user}
|
||||
siteConfig={siteConfig}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright 2024, 2025 New Vector Ltd.
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { Outlet, createLazyFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { Heading } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Layout from "../components/Layout";
|
||||
import NavBar from "../components/NavBar";
|
||||
import NavItem from "../components/NavItem";
|
||||
import UserGreeting from "../components/UserGreeting";
|
||||
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { query } from "./_account";
|
||||
|
||||
export const Route = createLazyFileRoute("/_account")({
|
||||
component: Account,
|
||||
});
|
||||
|
||||
function Account(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const result = useSuspenseQuery(query);
|
||||
const viewer = result.data.viewer;
|
||||
if (viewer?.__typename !== "User") throw notFound();
|
||||
const siteConfig = result.data.siteConfig;
|
||||
|
||||
return (
|
||||
<Layout wide>
|
||||
<div className="flex flex-col gap-10">
|
||||
<Heading size="md" weight="semibold">
|
||||
{t("frontend.account.title")}
|
||||
</Heading>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<UserGreeting user={viewer} siteConfig={siteConfig} />
|
||||
|
||||
<NavBar>
|
||||
<NavItem to="/">{t("frontend.nav.settings")}</NavItem>
|
||||
<NavItem to="/sessions">{t("frontend.nav.devices")}</NavItem>
|
||||
</NavBar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Outlet />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { createLazyFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { H5 } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import BrowserSession from "../components/BrowserSession";
|
||||
import { ButtonLink } from "../components/ButtonLink";
|
||||
import EmptyState from "../components/EmptyState";
|
||||
import Filter from "../components/Filter";
|
||||
import { usePages } from "../pagination";
|
||||
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { query } from "./_account.sessions.browsers";
|
||||
|
||||
const PAGE_SIZE = 6;
|
||||
|
||||
export const Route = createLazyFileRoute("/_account/sessions/browsers")({
|
||||
component: BrowserSessions,
|
||||
});
|
||||
|
||||
function BrowserSessions(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { inactive, pagination } = Route.useLoaderDeps();
|
||||
|
||||
const {
|
||||
data: { viewerSession },
|
||||
} = useSuspenseQuery(query(pagination, inactive));
|
||||
if (viewerSession.__typename !== "BrowserSession") throw notFound();
|
||||
|
||||
const [backwardPage, forwardPage] = usePages(
|
||||
pagination,
|
||||
viewerSession.user.browserSessions.pageInfo,
|
||||
PAGE_SIZE,
|
||||
);
|
||||
|
||||
// We reverse the list as we are paginating backwards
|
||||
const edges = [...viewerSession.user.browserSessions.edges].reverse();
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<H5>{t("frontend.browser_sessions_overview.heading")}</H5>
|
||||
|
||||
<div className="flex gap-2 items-start">
|
||||
<Filter
|
||||
to="/sessions/browsers"
|
||||
enabled={inactive}
|
||||
search={{ inactive: inactive ? undefined : true }}
|
||||
>
|
||||
{t("frontend.last_active.inactive_90_days")}
|
||||
</Filter>
|
||||
</div>
|
||||
|
||||
{edges.map((n) => (
|
||||
<BrowserSession
|
||||
key={n.cursor}
|
||||
session={n.node}
|
||||
isCurrent={viewerSession.id === n.node.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
{viewerSession.user.browserSessions.totalCount === 0 && (
|
||||
<EmptyState>
|
||||
{inactive
|
||||
? t(
|
||||
"frontend.browser_sessions_overview.no_active_sessions.inactive_90_days",
|
||||
)
|
||||
: t(
|
||||
"frontend.browser_sessions_overview.no_active_sessions.default",
|
||||
)}
|
||||
</EmptyState>
|
||||
)}
|
||||
|
||||
{/* Only show the pagination buttons if there are pages to go to */}
|
||||
{(forwardPage || backwardPage) && (
|
||||
<div className="flex *:flex-1">
|
||||
<ButtonLink
|
||||
kind="secondary"
|
||||
size="sm"
|
||||
disabled={!forwardPage}
|
||||
to="/sessions/browsers"
|
||||
search={forwardPage || pagination}
|
||||
resetScroll
|
||||
>
|
||||
{t("common.previous")}
|
||||
</ButtonLink>
|
||||
|
||||
{/* Spacer */}
|
||||
<div />
|
||||
|
||||
<ButtonLink
|
||||
kind="secondary"
|
||||
size="sm"
|
||||
disabled={!backwardPage}
|
||||
to="/sessions/browsers"
|
||||
search={backwardPage || pagination}
|
||||
resetScroll
|
||||
>
|
||||
{t("common.next")}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,25 @@
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
// Copyright 2024, 2025 New Vector Ltd.
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { H5 } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as v from "valibot";
|
||||
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
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 { graphqlRequest } from "../graphql";
|
||||
import {
|
||||
type AnyPagination,
|
||||
anyPaginationSchema,
|
||||
normalizePagination,
|
||||
usePages,
|
||||
} from "../pagination";
|
||||
import { getNinetyDaysAgo } from "../utils/dates";
|
||||
|
||||
@@ -66,7 +72,7 @@ const QUERY = graphql(/* GraphQL */ `
|
||||
}
|
||||
`);
|
||||
|
||||
export const query = (pagination: AnyPagination, inactive: true | undefined) =>
|
||||
const query = (pagination: AnyPagination, inactive: true | undefined) =>
|
||||
queryOptions({
|
||||
queryKey: ["browserSessionList", inactive, pagination],
|
||||
queryFn: ({ signal }) =>
|
||||
@@ -97,4 +103,90 @@ export const Route = createFileRoute("/_account/sessions/browsers")({
|
||||
|
||||
loader: ({ context, deps: { inactive, pagination } }) =>
|
||||
context.queryClient.ensureQueryData(query(pagination, inactive)),
|
||||
|
||||
component: BrowserSessions,
|
||||
});
|
||||
|
||||
function BrowserSessions(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { inactive, pagination } = Route.useLoaderDeps();
|
||||
|
||||
const {
|
||||
data: { viewerSession },
|
||||
} = useSuspenseQuery(query(pagination, inactive));
|
||||
if (viewerSession.__typename !== "BrowserSession") throw notFound();
|
||||
|
||||
const [backwardPage, forwardPage] = usePages(
|
||||
pagination,
|
||||
viewerSession.user.browserSessions.pageInfo,
|
||||
PAGE_SIZE,
|
||||
);
|
||||
|
||||
// We reverse the list as we are paginating backwards
|
||||
const edges = [...viewerSession.user.browserSessions.edges].reverse();
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<H5>{t("frontend.browser_sessions_overview.heading")}</H5>
|
||||
|
||||
<div className="flex gap-2 items-start">
|
||||
<Filter
|
||||
to="/sessions/browsers"
|
||||
enabled={inactive}
|
||||
search={{ inactive: inactive ? undefined : true }}
|
||||
>
|
||||
{t("frontend.last_active.inactive_90_days")}
|
||||
</Filter>
|
||||
</div>
|
||||
|
||||
{edges.map((n) => (
|
||||
<BrowserSession
|
||||
key={n.cursor}
|
||||
session={n.node}
|
||||
isCurrent={viewerSession.id === n.node.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
{viewerSession.user.browserSessions.totalCount === 0 && (
|
||||
<EmptyState>
|
||||
{inactive
|
||||
? t(
|
||||
"frontend.browser_sessions_overview.no_active_sessions.inactive_90_days",
|
||||
)
|
||||
: t(
|
||||
"frontend.browser_sessions_overview.no_active_sessions.default",
|
||||
)}
|
||||
</EmptyState>
|
||||
)}
|
||||
|
||||
{/* Only show the pagination buttons if there are pages to go to */}
|
||||
{(forwardPage || backwardPage) && (
|
||||
<div className="flex *:flex-1">
|
||||
<ButtonLink
|
||||
kind="secondary"
|
||||
size="sm"
|
||||
disabled={!forwardPage}
|
||||
to="/sessions/browsers"
|
||||
search={forwardPage || pagination}
|
||||
resetScroll
|
||||
>
|
||||
{t("common.previous")}
|
||||
</ButtonLink>
|
||||
|
||||
{/* Spacer */}
|
||||
<div />
|
||||
|
||||
<ButtonLink
|
||||
kind="secondary"
|
||||
size="sm"
|
||||
disabled={!backwardPage}
|
||||
to="/sessions/browsers"
|
||||
search={backwardPage || pagination}
|
||||
resetScroll
|
||||
>
|
||||
{t("common.next")}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { createLazyFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { H3 } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
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 { usePages } from "../pagination";
|
||||
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import Separator from "../components/Separator";
|
||||
import { listQuery, query } from "./_account.sessions.index";
|
||||
|
||||
const PAGE_SIZE = 6;
|
||||
|
||||
// A type-safe way to ensure we've handled all session types
|
||||
const unknownSessionType = (type: never): never => {
|
||||
throw new Error(`Unknown session type: ${type}`);
|
||||
};
|
||||
|
||||
export const Route = createLazyFileRoute("/_account/sessions/")({
|
||||
component: Sessions,
|
||||
});
|
||||
|
||||
function Sessions(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { inactive, pagination } = Route.useLoaderDeps();
|
||||
const {
|
||||
data: { viewer },
|
||||
} = useSuspenseQuery(query);
|
||||
if (viewer.__typename !== "User") throw notFound();
|
||||
|
||||
const { data } = useSuspenseQuery(listQuery(pagination, inactive));
|
||||
if (data.viewer.__typename !== "User") throw notFound();
|
||||
const appSessions = data.viewer.appSessions;
|
||||
|
||||
const [backwardPage, forwardPage] = usePages(
|
||||
pagination,
|
||||
appSessions.pageInfo,
|
||||
PAGE_SIZE,
|
||||
);
|
||||
|
||||
// We reverse the list as we are paginating backwards
|
||||
const edges = [...appSessions.edges].reverse();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<H3>{t("frontend.user_sessions_overview.heading")}</H3>
|
||||
<BrowserSessionsOverview user={viewer} />
|
||||
<Separator kind="section" />
|
||||
<div className="flex gap-2 justify-start items-center">
|
||||
<Filter
|
||||
to="/sessions"
|
||||
enabled={inactive}
|
||||
search={{ inactive: inactive ? undefined : true }}
|
||||
>
|
||||
{t("frontend.last_active.inactive_90_days")}
|
||||
</Filter>
|
||||
</div>
|
||||
{edges.map((session) => {
|
||||
const type = session.node.__typename;
|
||||
switch (type) {
|
||||
case "Oauth2Session":
|
||||
return (
|
||||
<OAuth2Session key={session.cursor} session={session.node} />
|
||||
);
|
||||
case "CompatSession":
|
||||
return (
|
||||
<CompatSession key={session.cursor} session={session.node} />
|
||||
);
|
||||
default:
|
||||
unknownSessionType(type);
|
||||
}
|
||||
})}
|
||||
|
||||
{appSessions.totalCount === 0 && (
|
||||
<EmptyState>
|
||||
{inactive
|
||||
? t(
|
||||
"frontend.user_sessions_overview.no_active_sessions.inactive_90_days",
|
||||
)
|
||||
: t("frontend.user_sessions_overview.no_active_sessions.default")}
|
||||
</EmptyState>
|
||||
)}
|
||||
|
||||
{/* Only show the pagination buttons if there are pages to go to */}
|
||||
{(forwardPage || backwardPage) && (
|
||||
<div className="flex *:flex-1">
|
||||
<ButtonLink
|
||||
kind="secondary"
|
||||
size="sm"
|
||||
disabled={!forwardPage}
|
||||
to="/sessions"
|
||||
search={{ inactive, ...(forwardPage || pagination) }}
|
||||
resetScroll
|
||||
>
|
||||
{t("common.previous")}
|
||||
</ButtonLink>
|
||||
|
||||
{/* Spacer */}
|
||||
<div />
|
||||
|
||||
<ButtonLink
|
||||
kind="secondary"
|
||||
size="sm"
|
||||
disabled={!backwardPage}
|
||||
to="/sessions"
|
||||
search={{ inactive, ...(backwardPage || pagination) }}
|
||||
resetScroll
|
||||
>
|
||||
{t("common.next")}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,26 @@
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
// Copyright 2024, 2025 New Vector Ltd.
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import * as v from "valibot";
|
||||
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { notFound } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { H3 } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as v from "valibot";
|
||||
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 Separator from "../components/Separator";
|
||||
import BrowserSessionsOverview from "../components/UserSessionsOverview/BrowserSessionsOverview";
|
||||
import { graphql } from "../gql";
|
||||
import { graphqlRequest } from "../graphql";
|
||||
import { usePages } from "../pagination";
|
||||
import {
|
||||
type AnyPagination,
|
||||
anyPaginationSchema,
|
||||
@@ -32,7 +43,7 @@ const QUERY = graphql(/* GraphQL */ `
|
||||
}
|
||||
`);
|
||||
|
||||
export const query = queryOptions({
|
||||
const query = queryOptions({
|
||||
queryKey: ["sessionsOverview"],
|
||||
queryFn: ({ signal }) => graphqlRequest({ query: QUERY, signal }),
|
||||
});
|
||||
@@ -80,10 +91,7 @@ const LIST_QUERY = graphql(/* GraphQL */ `
|
||||
}
|
||||
`);
|
||||
|
||||
export const listQuery = (
|
||||
pagination: AnyPagination,
|
||||
inactive: true | undefined,
|
||||
) =>
|
||||
const listQuery = (pagination: AnyPagination, inactive: true | undefined) =>
|
||||
queryOptions({
|
||||
queryKey: ["appSessionList", inactive, pagination],
|
||||
queryFn: ({ signal }) =>
|
||||
@@ -117,4 +125,105 @@ export const Route = createFileRoute("/_account/sessions/")({
|
||||
context.queryClient.ensureQueryData(query),
|
||||
context.queryClient.ensureQueryData(listQuery(pagination, inactive)),
|
||||
]),
|
||||
|
||||
component: Sessions,
|
||||
});
|
||||
|
||||
// A type-safe way to ensure we've handled all session types
|
||||
const unknownSessionType = (type: never): never => {
|
||||
throw new Error(`Unknown session type: ${type}`);
|
||||
};
|
||||
|
||||
function Sessions(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { inactive, pagination } = Route.useLoaderDeps();
|
||||
const {
|
||||
data: { viewer },
|
||||
} = useSuspenseQuery(query);
|
||||
if (viewer.__typename !== "User") throw notFound();
|
||||
|
||||
const { data } = useSuspenseQuery(listQuery(pagination, inactive));
|
||||
if (data.viewer.__typename !== "User") throw notFound();
|
||||
const appSessions = data.viewer.appSessions;
|
||||
|
||||
const [backwardPage, forwardPage] = usePages(
|
||||
pagination,
|
||||
appSessions.pageInfo,
|
||||
PAGE_SIZE,
|
||||
);
|
||||
|
||||
// We reverse the list as we are paginating backwards
|
||||
const edges = [...appSessions.edges].reverse();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<H3>{t("frontend.user_sessions_overview.heading")}</H3>
|
||||
<BrowserSessionsOverview user={viewer} />
|
||||
<Separator kind="section" />
|
||||
<div className="flex gap-2 justify-start items-center">
|
||||
<Filter
|
||||
to="/sessions"
|
||||
enabled={inactive}
|
||||
search={{ inactive: inactive ? undefined : true }}
|
||||
>
|
||||
{t("frontend.last_active.inactive_90_days")}
|
||||
</Filter>
|
||||
</div>
|
||||
{edges.map((session) => {
|
||||
const type = session.node.__typename;
|
||||
switch (type) {
|
||||
case "Oauth2Session":
|
||||
return (
|
||||
<OAuth2Session key={session.cursor} session={session.node} />
|
||||
);
|
||||
case "CompatSession":
|
||||
return (
|
||||
<CompatSession key={session.cursor} session={session.node} />
|
||||
);
|
||||
default:
|
||||
unknownSessionType(type);
|
||||
}
|
||||
})}
|
||||
|
||||
{appSessions.totalCount === 0 && (
|
||||
<EmptyState>
|
||||
{inactive
|
||||
? t(
|
||||
"frontend.user_sessions_overview.no_active_sessions.inactive_90_days",
|
||||
)
|
||||
: t("frontend.user_sessions_overview.no_active_sessions.default")}
|
||||
</EmptyState>
|
||||
)}
|
||||
|
||||
{/* Only show the pagination buttons if there are pages to go to */}
|
||||
{(forwardPage || backwardPage) && (
|
||||
<div className="flex *:flex-1">
|
||||
<ButtonLink
|
||||
kind="secondary"
|
||||
size="sm"
|
||||
disabled={!forwardPage}
|
||||
to="/sessions"
|
||||
search={{ inactive, ...(forwardPage || pagination) }}
|
||||
resetScroll
|
||||
>
|
||||
{t("common.previous")}
|
||||
</ButtonLink>
|
||||
|
||||
{/* Spacer */}
|
||||
<div />
|
||||
|
||||
<ButtonLink
|
||||
kind="secondary"
|
||||
size="sm"
|
||||
disabled={!backwardPage}
|
||||
to="/sessions"
|
||||
search={{ inactive, ...(backwardPage || pagination) }}
|
||||
resetScroll
|
||||
>
|
||||
{t("common.next")}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { Outlet, createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { Heading } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Layout from "../components/Layout";
|
||||
import NavBar from "../components/NavBar";
|
||||
import NavItem from "../components/NavItem";
|
||||
import UserGreeting from "../components/UserGreeting";
|
||||
import { graphql } from "../gql";
|
||||
import { graphqlRequest } from "../graphql";
|
||||
|
||||
@@ -24,11 +30,41 @@ const QUERY = graphql(/* GraphQL */ `
|
||||
}
|
||||
`);
|
||||
|
||||
export const query = queryOptions({
|
||||
const query = queryOptions({
|
||||
queryKey: ["currentUserGreeting"],
|
||||
queryFn: ({ signal }) => graphqlRequest({ query: QUERY, signal }),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/_account")({
|
||||
loader: ({ context }) => context.queryClient.ensureQueryData(query),
|
||||
component: Account,
|
||||
});
|
||||
|
||||
function Account(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const result = useSuspenseQuery(query);
|
||||
const viewer = result.data.viewer;
|
||||
if (viewer?.__typename !== "User") throw notFound();
|
||||
const siteConfig = result.data.siteConfig;
|
||||
|
||||
return (
|
||||
<Layout wide>
|
||||
<div className="flex flex-col gap-10">
|
||||
<Heading size="md" weight="semibold">
|
||||
{t("frontend.account.title")}
|
||||
</Heading>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<UserGreeting user={viewer} siteConfig={siteConfig} />
|
||||
|
||||
<NavBar>
|
||||
<NavItem to="/">{t("frontend.nav.settings")}</NavItem>
|
||||
<NavItem to="/sessions">{t("frontend.nav.devices")}</NavItem>
|
||||
</NavBar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Outlet />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { createLazyFileRoute, notFound } from "@tanstack/react-router";
|
||||
|
||||
import OAuth2ClientDetail from "../components/Client/OAuth2ClientDetail";
|
||||
import Layout from "../components/Layout";
|
||||
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { query } from "./clients.$id";
|
||||
|
||||
export const Route = createLazyFileRoute("/clients/$id")({
|
||||
component: ClientDetail,
|
||||
});
|
||||
|
||||
function ClientDetail(): React.ReactElement {
|
||||
const { id } = Route.useParams();
|
||||
const {
|
||||
data: { oauth2Client },
|
||||
} = useSuspenseQuery(query(id));
|
||||
if (!oauth2Client) throw notFound();
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<OAuth2ClientDetail client={oauth2Client} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
// Copyright 2024, 2025 New Vector Ltd.
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { notFound } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import OAuth2ClientDetail from "../components/Client/OAuth2ClientDetail";
|
||||
import Layout from "../components/Layout";
|
||||
import { graphql } from "../gql";
|
||||
import { graphqlRequest } from "../graphql";
|
||||
|
||||
@@ -17,7 +21,7 @@ const QUERY = graphql(/* GraphQL */ `
|
||||
}
|
||||
`);
|
||||
|
||||
export const query = (id: string) =>
|
||||
const query = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: ["oauth2Client", id],
|
||||
queryFn: ({ signal }) =>
|
||||
@@ -27,4 +31,19 @@ export const query = (id: string) =>
|
||||
export const Route = createFileRoute("/clients/$id")({
|
||||
loader: ({ context, params }) =>
|
||||
context.queryClient.ensureQueryData(query(params.id)),
|
||||
component: ClientDetail,
|
||||
});
|
||||
|
||||
function ClientDetail(): React.ReactElement {
|
||||
const { id } = Route.useParams();
|
||||
const {
|
||||
data: { oauth2Client },
|
||||
} = useSuspenseQuery(query(id));
|
||||
if (!oauth2Client) throw notFound();
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<OAuth2ClientDetail client={oauth2Client} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
// Copyright 2024, 2025 New Vector Ltd.
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { createLazyFileRoute, notFound } from "@tanstack/react-router";
|
||||
import IconArrowLeft from "@vector-im/compound-design-tokens/assets/web/icons/arrow-left";
|
||||
import IconSend from "@vector-im/compound-design-tokens/assets/web/icons/send-solid";
|
||||
import { Alert, Button, Form } from "@vector-im/compound-web";
|
||||
import { useRef } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { ButtonLink } from "../components/ButtonLink";
|
||||
import Layout from "../components/Layout";
|
||||
import LoadingSpinner from "../components/LoadingSpinner";
|
||||
import PageHeading from "../components/PageHeading";
|
||||
import { graphql } from "../gql";
|
||||
import { graphqlRequest } from "../graphql";
|
||||
import { query } from "./emails.$id.verify";
|
||||
|
||||
const VERIFY_EMAIL_MUTATION = graphql(/* GraphQL */ `
|
||||
mutation DoVerifyEmail($id: ID!, $code: String!) {
|
||||
completeEmailAuthentication(input: { id: $id, code: $code }) {
|
||||
status
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const RESEND_EMAIL_AUTHENTICATION_CODE_MUTATION = graphql(/* GraphQL */ `
|
||||
mutation ResendEmailAuthenticationCode($id: ID!, $language: String!) {
|
||||
resendEmailAuthenticationCode(input: { id: $id, language: $language }) {
|
||||
status
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const Route = createLazyFileRoute("/emails/$id/verify")({
|
||||
component: EmailVerify,
|
||||
});
|
||||
|
||||
function EmailVerify(): React.ReactElement {
|
||||
const { id } = Route.useParams();
|
||||
const {
|
||||
data: { userEmailAuthentication },
|
||||
} = useSuspenseQuery(query(id));
|
||||
if (!userEmailAuthentication) throw notFound();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const verifyEmail = useMutation({
|
||||
mutationFn: ({ id, code }: { id: string; code: string }) =>
|
||||
graphqlRequest({ query: VERIFY_EMAIL_MUTATION, variables: { id, code } }),
|
||||
async onSuccess(data): Promise<void> {
|
||||
await queryClient.invalidateQueries({ queryKey: ["userEmails"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["verifyEmail", id] });
|
||||
|
||||
if (data.completeEmailAuthentication.status === "COMPLETED") {
|
||||
await navigate({ to: "/" });
|
||||
} else if (data.completeEmailAuthentication.status === "IN_USE") {
|
||||
await navigate({ to: "/emails/$id/in-use", params: { id } });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const resendEmailAuthenticationCode = useMutation({
|
||||
mutationFn: ({ id, language }: { id: string; language: string }) =>
|
||||
graphqlRequest({
|
||||
query: RESEND_EMAIL_AUTHENTICATION_CODE_MUTATION,
|
||||
variables: { id, language },
|
||||
}),
|
||||
onSuccess() {
|
||||
fieldRef.current?.focus();
|
||||
},
|
||||
});
|
||||
|
||||
const fieldRef = useRef<HTMLInputElement>(null);
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const onFormSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
const code = formData.get("code") as string;
|
||||
verifyEmail
|
||||
.mutateAsync({ id: userEmailAuthentication.id, code })
|
||||
.finally(() => form.reset());
|
||||
};
|
||||
|
||||
const onResendClick = (): void => {
|
||||
resendEmailAuthenticationCode.mutate({
|
||||
id: userEmailAuthentication.id,
|
||||
language: i18n.languages[0],
|
||||
});
|
||||
};
|
||||
|
||||
const emailSent =
|
||||
resendEmailAuthenticationCode.data?.resendEmailAuthenticationCode.status ===
|
||||
"RESENT";
|
||||
const invalidCode =
|
||||
verifyEmail.data?.completeEmailAuthentication.status === "INVALID_CODE";
|
||||
const codeExpired =
|
||||
verifyEmail.data?.completeEmailAuthentication.status === "CODE_EXPIRED";
|
||||
const rateLimited =
|
||||
verifyEmail.data?.completeEmailAuthentication.status === "RATE_LIMITED";
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<PageHeading
|
||||
Icon={IconSend}
|
||||
title={t("frontend.verify_email.heading")}
|
||||
subtitle={
|
||||
<Trans
|
||||
i18nKey="frontend.verify_email.enter_code_prompt"
|
||||
values={{ email: userEmailAuthentication.email }}
|
||||
components={{ email: <span className="text-primary" /> }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form.Root onSubmit={onFormSubmit}>
|
||||
{emailSent && (
|
||||
<Alert
|
||||
type="success"
|
||||
title={t("frontend.verify_email.email_sent_alert.title")}
|
||||
>
|
||||
{t("frontend.verify_email.email_sent_alert.description")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{invalidCode && (
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.verify_email.invalid_code_alert.title")}
|
||||
>
|
||||
{t("frontend.verify_email.invalid_code_alert.description")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{codeExpired && (
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.verify_email.code_expired_alert.title")}
|
||||
>
|
||||
{t("frontend.verify_email.code_expired_alert.description")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{rateLimited && (
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.errors.rate_limit_exceeded")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form.Field
|
||||
name="code"
|
||||
serverInvalid={invalidCode || rateLimited}
|
||||
className="self-center mb-4"
|
||||
>
|
||||
<Form.Label>{t("frontend.verify_email.code_field_label")}</Form.Label>
|
||||
<Form.MFAControl ref={fieldRef} />
|
||||
|
||||
{invalidCode && (
|
||||
<Form.ErrorMessage>
|
||||
{t("frontend.verify_email.code_field_error")}
|
||||
</Form.ErrorMessage>
|
||||
)}
|
||||
|
||||
<Form.ErrorMessage match="patternMismatch">
|
||||
{t("frontend.verify_email.code_field_wrong_shape")}
|
||||
</Form.ErrorMessage>
|
||||
</Form.Field>
|
||||
|
||||
<Form.Submit type="submit" disabled={verifyEmail.isPending}>
|
||||
{verifyEmail.isPending && <LoadingSpinner inline />}
|
||||
{t("action.continue")}
|
||||
</Form.Submit>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
kind="secondary"
|
||||
disabled={resendEmailAuthenticationCode.isPending}
|
||||
onClick={onResendClick}
|
||||
>
|
||||
{resendEmailAuthenticationCode.isPending && <LoadingSpinner inline />}
|
||||
{t("frontend.verify_email.resend_code")}
|
||||
</Button>
|
||||
|
||||
<ButtonLink Icon={IconArrowLeft} kind="tertiary" to="/">
|
||||
{t("action.back")}
|
||||
</ButtonLink>
|
||||
</Form.Root>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,30 @@
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
// Copyright 2024, 2025 New Vector Ltd.
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
|
||||
import {
|
||||
queryOptions,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
useSuspenseQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
createFileRoute,
|
||||
notFound,
|
||||
redirect,
|
||||
useNavigate,
|
||||
} from "@tanstack/react-router";
|
||||
import IconArrowLeft from "@vector-im/compound-design-tokens/assets/web/icons/arrow-left";
|
||||
import IconSend from "@vector-im/compound-design-tokens/assets/web/icons/send-solid";
|
||||
import { Alert, Button, Form } from "@vector-im/compound-web";
|
||||
import { useRef } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { ButtonLink } from "../components/ButtonLink";
|
||||
import Layout from "../components/Layout";
|
||||
import LoadingSpinner from "../components/LoadingSpinner";
|
||||
import PageHeading from "../components/PageHeading";
|
||||
import { graphql } from "../gql";
|
||||
import { graphqlRequest } from "../graphql";
|
||||
|
||||
@@ -19,6 +38,22 @@ const QUERY = graphql(/* GraphQL */ `
|
||||
}
|
||||
`);
|
||||
|
||||
const VERIFY_EMAIL_MUTATION = graphql(/* GraphQL */ `
|
||||
mutation DoVerifyEmail($id: ID!, $code: String!) {
|
||||
completeEmailAuthentication(input: { id: $id, code: $code }) {
|
||||
status
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const RESEND_EMAIL_AUTHENTICATION_CODE_MUTATION = graphql(/* GraphQL */ `
|
||||
mutation ResendEmailAuthenticationCode($id: ID!, $language: String!) {
|
||||
resendEmailAuthenticationCode(input: { id: $id, language: $language }) {
|
||||
status
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const query = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: ["verifyEmail", id],
|
||||
@@ -37,4 +72,162 @@ export const Route = createFileRoute("/emails/$id/verify")({
|
||||
throw redirect({ to: "/" });
|
||||
}
|
||||
},
|
||||
|
||||
component: EmailVerify,
|
||||
});
|
||||
|
||||
function EmailVerify(): React.ReactElement {
|
||||
const { id } = Route.useParams();
|
||||
const {
|
||||
data: { userEmailAuthentication },
|
||||
} = useSuspenseQuery(query(id));
|
||||
if (!userEmailAuthentication) throw notFound();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const verifyEmail = useMutation({
|
||||
mutationFn: ({ id, code }: { id: string; code: string }) =>
|
||||
graphqlRequest({ query: VERIFY_EMAIL_MUTATION, variables: { id, code } }),
|
||||
async onSuccess(data): Promise<void> {
|
||||
await queryClient.invalidateQueries({ queryKey: ["userEmails"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["verifyEmail", id] });
|
||||
|
||||
if (data.completeEmailAuthentication.status === "COMPLETED") {
|
||||
await navigate({ to: "/" });
|
||||
} else if (data.completeEmailAuthentication.status === "IN_USE") {
|
||||
await navigate({ to: "/emails/$id/in-use", params: { id } });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const resendEmailAuthenticationCode = useMutation({
|
||||
mutationFn: ({ id, language }: { id: string; language: string }) =>
|
||||
graphqlRequest({
|
||||
query: RESEND_EMAIL_AUTHENTICATION_CODE_MUTATION,
|
||||
variables: { id, language },
|
||||
}),
|
||||
onSuccess() {
|
||||
fieldRef.current?.focus();
|
||||
},
|
||||
});
|
||||
|
||||
const fieldRef = useRef<HTMLInputElement>(null);
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const onFormSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
const code = formData.get("code") as string;
|
||||
verifyEmail
|
||||
.mutateAsync({ id: userEmailAuthentication.id, code })
|
||||
.finally(() => form.reset());
|
||||
};
|
||||
|
||||
const onResendClick = (): void => {
|
||||
resendEmailAuthenticationCode.mutate({
|
||||
id: userEmailAuthentication.id,
|
||||
language: i18n.languages[0],
|
||||
});
|
||||
};
|
||||
|
||||
const emailSent =
|
||||
resendEmailAuthenticationCode.data?.resendEmailAuthenticationCode.status ===
|
||||
"RESENT";
|
||||
const invalidCode =
|
||||
verifyEmail.data?.completeEmailAuthentication.status === "INVALID_CODE";
|
||||
const codeExpired =
|
||||
verifyEmail.data?.completeEmailAuthentication.status === "CODE_EXPIRED";
|
||||
const rateLimited =
|
||||
verifyEmail.data?.completeEmailAuthentication.status === "RATE_LIMITED";
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<PageHeading
|
||||
Icon={IconSend}
|
||||
title={t("frontend.verify_email.heading")}
|
||||
subtitle={
|
||||
<Trans
|
||||
i18nKey="frontend.verify_email.enter_code_prompt"
|
||||
values={{ email: userEmailAuthentication.email }}
|
||||
components={{ email: <span className="text-primary" /> }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form.Root onSubmit={onFormSubmit}>
|
||||
{emailSent && (
|
||||
<Alert
|
||||
type="success"
|
||||
title={t("frontend.verify_email.email_sent_alert.title")}
|
||||
>
|
||||
{t("frontend.verify_email.email_sent_alert.description")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{invalidCode && (
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.verify_email.invalid_code_alert.title")}
|
||||
>
|
||||
{t("frontend.verify_email.invalid_code_alert.description")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{codeExpired && (
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.verify_email.code_expired_alert.title")}
|
||||
>
|
||||
{t("frontend.verify_email.code_expired_alert.description")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{rateLimited && (
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.errors.rate_limit_exceeded")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form.Field
|
||||
name="code"
|
||||
serverInvalid={invalidCode || rateLimited}
|
||||
className="self-center mb-4"
|
||||
>
|
||||
<Form.Label>{t("frontend.verify_email.code_field_label")}</Form.Label>
|
||||
<Form.MFAControl ref={fieldRef} />
|
||||
|
||||
{invalidCode && (
|
||||
<Form.ErrorMessage>
|
||||
{t("frontend.verify_email.code_field_error")}
|
||||
</Form.ErrorMessage>
|
||||
)}
|
||||
|
||||
<Form.ErrorMessage match="patternMismatch">
|
||||
{t("frontend.verify_email.code_field_wrong_shape")}
|
||||
</Form.ErrorMessage>
|
||||
</Form.Field>
|
||||
|
||||
<Form.Submit type="submit" disabled={verifyEmail.isPending}>
|
||||
{verifyEmail.isPending && <LoadingSpinner inline />}
|
||||
{t("action.continue")}
|
||||
</Form.Submit>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
kind="secondary"
|
||||
disabled={resendEmailAuthenticationCode.isPending}
|
||||
onClick={onResendClick}
|
||||
>
|
||||
{resendEmailAuthenticationCode.isPending && <LoadingSpinner inline />}
|
||||
{t("frontend.verify_email.resend_code")}
|
||||
</Button>
|
||||
|
||||
<ButtonLink Icon={IconArrowLeft} kind="tertiary" to="/">
|
||||
{t("action.back")}
|
||||
</ButtonLink>
|
||||
</Form.Root>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
createLazyFileRoute,
|
||||
notFound,
|
||||
useRouter,
|
||||
} from "@tanstack/react-router";
|
||||
import IconLockSolid from "@vector-im/compound-design-tokens/assets/web/icons/lock-solid";
|
||||
import { Alert, Form } from "@vector-im/compound-web";
|
||||
import { type FormEvent, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ButtonLink } from "../components/ButtonLink";
|
||||
import Layout from "../components/Layout";
|
||||
import LoadingSpinner from "../components/LoadingSpinner";
|
||||
import PageHeading from "../components/PageHeading";
|
||||
import PasswordCreationDoubleInput from "../components/PasswordCreationDoubleInput";
|
||||
import Separator from "../components/Separator";
|
||||
import { graphql } from "../gql";
|
||||
import { graphqlRequest } from "../graphql";
|
||||
import { translateSetPasswordError } from "../i18n/password_changes";
|
||||
import { query } from "./password.change.index";
|
||||
|
||||
const CHANGE_PASSWORD_MUTATION = graphql(/* GraphQL */ `
|
||||
mutation ChangePassword(
|
||||
$userId: ID!
|
||||
$oldPassword: String!
|
||||
$newPassword: String!
|
||||
) {
|
||||
setPassword(
|
||||
input: {
|
||||
userId: $userId
|
||||
currentPassword: $oldPassword
|
||||
newPassword: $newPassword
|
||||
}
|
||||
) {
|
||||
status
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const Route = createLazyFileRoute("/password/change/")({
|
||||
component: ChangePassword,
|
||||
});
|
||||
|
||||
function ChangePassword(): React.ReactNode {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data: { viewer, siteConfig },
|
||||
} = useSuspenseQuery(query);
|
||||
const router = useRouter();
|
||||
if (viewer.__typename !== "User") throw notFound();
|
||||
const userId = viewer.id;
|
||||
|
||||
const currentPasswordRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
async mutationFn(formData: FormData) {
|
||||
const oldPassword = formData.get("current_password") as string;
|
||||
const newPassword = formData.get("new_password") as string;
|
||||
const newPasswordAgain = formData.get("new_password_again") as string;
|
||||
|
||||
if (newPassword !== newPasswordAgain) {
|
||||
throw new Error(
|
||||
"passwords mismatch; this should be checked by the form",
|
||||
);
|
||||
}
|
||||
|
||||
const response = await graphqlRequest({
|
||||
query: CHANGE_PASSWORD_MUTATION,
|
||||
variables: {
|
||||
userId,
|
||||
oldPassword,
|
||||
newPassword,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.setPassword.status === "ALLOWED") {
|
||||
router.navigate({ to: "/password/change/success" });
|
||||
}
|
||||
|
||||
return response.setPassword;
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
mutation.mutate(formData);
|
||||
};
|
||||
|
||||
const unhandleableError = mutation.error !== null;
|
||||
|
||||
const errorMsg: string | undefined = translateSetPasswordError(
|
||||
t,
|
||||
mutation.data?.status,
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex flex-col gap-10">
|
||||
<PageHeading
|
||||
Icon={IconLockSolid}
|
||||
title={t("frontend.password_change.title")}
|
||||
subtitle={t("frontend.password_change.subtitle")}
|
||||
/>
|
||||
|
||||
<Form.Root onSubmit={onSubmit} method="POST">
|
||||
{/*
|
||||
In normal operation, the submit event should be `preventDefault()`ed.
|
||||
method = POST just prevents sending passwords in the query string,
|
||||
which could be logged, if for some reason the event handler fails.
|
||||
*/}
|
||||
{unhandleableError && (
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.password_change.failure.title")}
|
||||
>
|
||||
{t("frontend.password_change.failure.description.unspecified")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{errorMsg !== undefined && (
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.password_change.failure.title")}
|
||||
>
|
||||
{errorMsg}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form.Field
|
||||
name="current_password"
|
||||
serverInvalid={mutation.data?.status === "WRONG_PASSWORD"}
|
||||
>
|
||||
<Form.Label>
|
||||
{t("frontend.password_change.current_password_label")}
|
||||
</Form.Label>
|
||||
|
||||
<Form.PasswordControl
|
||||
required
|
||||
autoComplete="current-password"
|
||||
ref={currentPasswordRef}
|
||||
/>
|
||||
|
||||
<Form.ErrorMessage match="valueMissing">
|
||||
{t("frontend.errors.field_required")}
|
||||
</Form.ErrorMessage>
|
||||
|
||||
{mutation.data && mutation.data.status === "WRONG_PASSWORD" && (
|
||||
<Form.ErrorMessage>
|
||||
{t(
|
||||
"frontend.password_change.failure.description.wrong_password",
|
||||
)}
|
||||
</Form.ErrorMessage>
|
||||
)}
|
||||
</Form.Field>
|
||||
|
||||
<Separator />
|
||||
|
||||
<PasswordCreationDoubleInput
|
||||
siteConfig={siteConfig}
|
||||
forceShowNewPasswordInvalid={
|
||||
(mutation.data &&
|
||||
mutation.data.status === "INVALID_NEW_PASSWORD") ||
|
||||
false
|
||||
}
|
||||
/>
|
||||
|
||||
<Form.Submit kind="primary" disabled={mutation.isPending}>
|
||||
{!!mutation.isPending && <LoadingSpinner inline />}
|
||||
{t("action.save")}
|
||||
</Form.Submit>
|
||||
|
||||
<ButtonLink to="/" kind="tertiary">
|
||||
{t("action.cancel")}
|
||||
</ButtonLink>
|
||||
</Form.Root>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,46 @@
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
// Copyright 2024, 2025 New Vector Ltd.
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
queryOptions,
|
||||
useMutation,
|
||||
useSuspenseQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { createFileRoute, notFound, useRouter } from "@tanstack/react-router";
|
||||
import IconLockSolid from "@vector-im/compound-design-tokens/assets/web/icons/lock-solid";
|
||||
import { Alert, Form } from "@vector-im/compound-web";
|
||||
import { type FormEvent, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ButtonLink } from "../components/ButtonLink";
|
||||
import Layout from "../components/Layout";
|
||||
import LoadingSpinner from "../components/LoadingSpinner";
|
||||
import PageHeading from "../components/PageHeading";
|
||||
import PasswordCreationDoubleInput from "../components/PasswordCreationDoubleInput";
|
||||
import Separator from "../components/Separator";
|
||||
import { graphql } from "../gql";
|
||||
import { graphqlRequest } from "../graphql";
|
||||
import { translateSetPasswordError } from "../i18n/password_changes";
|
||||
|
||||
const CHANGE_PASSWORD_MUTATION = graphql(/* GraphQL */ `
|
||||
mutation ChangePassword(
|
||||
$userId: ID!
|
||||
$oldPassword: String!
|
||||
$newPassword: String!
|
||||
) {
|
||||
setPassword(
|
||||
input: {
|
||||
userId: $userId
|
||||
currentPassword: $oldPassword
|
||||
newPassword: $newPassword
|
||||
}
|
||||
) {
|
||||
status
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const QUERY = graphql(/* GraphQL */ `
|
||||
query PasswordChange {
|
||||
@@ -24,11 +57,150 @@ const QUERY = graphql(/* GraphQL */ `
|
||||
}
|
||||
`);
|
||||
|
||||
export const query = queryOptions({
|
||||
const query = queryOptions({
|
||||
queryKey: ["passwordChange"],
|
||||
queryFn: ({ signal }) => graphqlRequest({ query: QUERY, signal }),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/password/change/")({
|
||||
loader: ({ context }) => context.queryClient.ensureQueryData(query),
|
||||
component: ChangePassword,
|
||||
});
|
||||
|
||||
function ChangePassword(): React.ReactNode {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data: { viewer, siteConfig },
|
||||
} = useSuspenseQuery(query);
|
||||
const router = useRouter();
|
||||
if (viewer.__typename !== "User") throw notFound();
|
||||
const userId = viewer.id;
|
||||
|
||||
const currentPasswordRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
async mutationFn(formData: FormData) {
|
||||
const oldPassword = formData.get("current_password") as string;
|
||||
const newPassword = formData.get("new_password") as string;
|
||||
const newPasswordAgain = formData.get("new_password_again") as string;
|
||||
|
||||
if (newPassword !== newPasswordAgain) {
|
||||
throw new Error(
|
||||
"passwords mismatch; this should be checked by the form",
|
||||
);
|
||||
}
|
||||
|
||||
const response = await graphqlRequest({
|
||||
query: CHANGE_PASSWORD_MUTATION,
|
||||
variables: {
|
||||
userId,
|
||||
oldPassword,
|
||||
newPassword,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.setPassword.status === "ALLOWED") {
|
||||
router.navigate({ to: "/password/change/success" });
|
||||
}
|
||||
|
||||
return response.setPassword;
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
mutation.mutate(formData);
|
||||
};
|
||||
|
||||
const unhandleableError = mutation.error !== null;
|
||||
|
||||
const errorMsg: string | undefined = translateSetPasswordError(
|
||||
t,
|
||||
mutation.data?.status,
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex flex-col gap-10">
|
||||
<PageHeading
|
||||
Icon={IconLockSolid}
|
||||
title={t("frontend.password_change.title")}
|
||||
subtitle={t("frontend.password_change.subtitle")}
|
||||
/>
|
||||
|
||||
<Form.Root onSubmit={onSubmit} method="POST">
|
||||
{/*
|
||||
In normal operation, the submit event should be `preventDefault()`ed.
|
||||
method = POST just prevents sending passwords in the query string,
|
||||
which could be logged, if for some reason the event handler fails.
|
||||
*/}
|
||||
{unhandleableError && (
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.password_change.failure.title")}
|
||||
>
|
||||
{t("frontend.password_change.failure.description.unspecified")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{errorMsg !== undefined && (
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.password_change.failure.title")}
|
||||
>
|
||||
{errorMsg}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form.Field
|
||||
name="current_password"
|
||||
serverInvalid={mutation.data?.status === "WRONG_PASSWORD"}
|
||||
>
|
||||
<Form.Label>
|
||||
{t("frontend.password_change.current_password_label")}
|
||||
</Form.Label>
|
||||
|
||||
<Form.PasswordControl
|
||||
required
|
||||
autoComplete="current-password"
|
||||
ref={currentPasswordRef}
|
||||
/>
|
||||
|
||||
<Form.ErrorMessage match="valueMissing">
|
||||
{t("frontend.errors.field_required")}
|
||||
</Form.ErrorMessage>
|
||||
|
||||
{mutation.data && mutation.data.status === "WRONG_PASSWORD" && (
|
||||
<Form.ErrorMessage>
|
||||
{t(
|
||||
"frontend.password_change.failure.description.wrong_password",
|
||||
)}
|
||||
</Form.ErrorMessage>
|
||||
)}
|
||||
</Form.Field>
|
||||
|
||||
<Separator />
|
||||
|
||||
<PasswordCreationDoubleInput
|
||||
siteConfig={siteConfig}
|
||||
forceShowNewPasswordInvalid={
|
||||
(mutation.data &&
|
||||
mutation.data.status === "INVALID_NEW_PASSWORD") ||
|
||||
false
|
||||
}
|
||||
/>
|
||||
|
||||
<Form.Submit kind="primary" disabled={mutation.isPending}>
|
||||
{!!mutation.isPending && <LoadingSpinner inline />}
|
||||
{t("action.save")}
|
||||
</Form.Submit>
|
||||
|
||||
<ButtonLink to="/" kind="tertiary">
|
||||
{t("action.cancel")}
|
||||
</ButtonLink>
|
||||
</Form.Root>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
// Copyright 2024, 2025 New Vector Ltd.
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import IconCheckCircle from "@vector-im/compound-design-tokens/assets/web/icons/check-circle-solid";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ButtonLink } from "../components/ButtonLink";
|
||||
import Layout from "../components/Layout";
|
||||
import PageHeading from "../components/PageHeading";
|
||||
|
||||
export const Route = createLazyFileRoute("/password/change/success")({
|
||||
export const Route = createFileRoute("/password/change/success")({
|
||||
component: ChangePasswordSuccess,
|
||||
});
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
// Copyright 2024, 2025 New Vector Ltd.
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
createLazyFileRoute,
|
||||
notFound,
|
||||
useNavigate,
|
||||
useSearch,
|
||||
} from "@tanstack/react-router";
|
||||
import IconErrorSolid from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
|
||||
import IconLockSolid from "@vector-im/compound-design-tokens/assets/web/icons/lock-solid";
|
||||
import { Alert, Button, Form } from "@vector-im/compound-web";
|
||||
import type { FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ButtonLink } from "../components/ButtonLink";
|
||||
import Layout from "../components/Layout";
|
||||
import LoadingSpinner from "../components/LoadingSpinner";
|
||||
import PageHeading from "../components/PageHeading";
|
||||
import PasswordCreationDoubleInput from "../components/PasswordCreationDoubleInput";
|
||||
import { type FragmentType, graphql, useFragment } from "../gql";
|
||||
import { graphqlRequest } from "../graphql";
|
||||
import { translateSetPasswordError } from "../i18n/password_changes";
|
||||
import { query } from "./password.recovery.index";
|
||||
|
||||
const RECOVER_PASSWORD_MUTATION = graphql(/* GraphQL */ `
|
||||
mutation RecoverPassword($ticket: String!, $newPassword: String!) {
|
||||
setPasswordByRecovery(
|
||||
input: { ticket: $ticket, newPassword: $newPassword }
|
||||
) {
|
||||
status
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const RESEND_EMAIL_MUTATION = graphql(/* GraphQL */ `
|
||||
mutation ResendRecoveryEmail($ticket: String!) {
|
||||
resendRecoveryEmail(input: { ticket: $ticket }) {
|
||||
status
|
||||
progressUrl
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const FRAGMENT = graphql(/* GraphQL */ `
|
||||
fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket {
|
||||
username
|
||||
email
|
||||
}
|
||||
`);
|
||||
|
||||
const SITE_CONFIG_FRAGMENT = graphql(/* GraphQL */ `
|
||||
fragment RecoverPassword_siteConfig on SiteConfig {
|
||||
...PasswordCreationDoubleInput_siteConfig
|
||||
}
|
||||
`);
|
||||
|
||||
const EmailConsumed: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Layout>
|
||||
<PageHeading
|
||||
Icon={IconErrorSolid}
|
||||
title={t("frontend.password_reset.consumed.title")}
|
||||
subtitle={t("frontend.password_reset.consumed.subtitle")}
|
||||
invalid
|
||||
/>
|
||||
|
||||
<ButtonLink kind="secondary" to="/" reloadDocument>
|
||||
{t("action.start_over")}
|
||||
</ButtonLink>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
const EmailExpired: React.FC<{
|
||||
userRecoveryTicket: FragmentType<typeof FRAGMENT>;
|
||||
ticket: string;
|
||||
}> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const userRecoveryTicket = useFragment(FRAGMENT, props.userRecoveryTicket);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({ ticket }: { ticket: string }) => {
|
||||
const response = await graphqlRequest({
|
||||
query: RESEND_EMAIL_MUTATION,
|
||||
variables: {
|
||||
ticket,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.resendRecoveryEmail.status === "SENT") {
|
||||
if (!response.resendRecoveryEmail.progressUrl) {
|
||||
throw new Error("Unexpected response, missing progress URL");
|
||||
}
|
||||
|
||||
// Redirect to the URL which confirms that the email was sent
|
||||
window.location.href = response.resendRecoveryEmail.progressUrl;
|
||||
|
||||
// We await an infinite promise here, so that the mutation
|
||||
// doesn't resolve
|
||||
await new Promise(() => undefined);
|
||||
}
|
||||
|
||||
return response.resendRecoveryEmail;
|
||||
},
|
||||
});
|
||||
|
||||
const onClick = (event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
event.preventDefault();
|
||||
mutation.mutate({ ticket: props.ticket });
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<PageHeading
|
||||
Icon={IconErrorSolid}
|
||||
title={t("frontend.password_reset.expired.title")}
|
||||
subtitle={t("frontend.password_reset.expired.subtitle", {
|
||||
email: userRecoveryTicket.email,
|
||||
})}
|
||||
invalid
|
||||
/>
|
||||
|
||||
{mutation.data?.status === "RATE_LIMITED" && (
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.errors.rate_limit_exceeded")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button kind="primary" disabled={mutation.isPending} onClick={onClick}>
|
||||
{!!mutation.isPending && <LoadingSpinner inline />}
|
||||
{t("frontend.password_reset.expired.resend_email")}
|
||||
</Button>
|
||||
|
||||
<ButtonLink kind="secondary" to="/" reloadDocument>
|
||||
{t("action.start_over")}
|
||||
</ButtonLink>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
const EmailRecovery: React.FC<{
|
||||
siteConfig: FragmentType<typeof SITE_CONFIG_FRAGMENT>;
|
||||
userRecoveryTicket: FragmentType<typeof FRAGMENT>;
|
||||
ticket: string;
|
||||
}> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const siteConfig = useFragment(SITE_CONFIG_FRAGMENT, props.siteConfig);
|
||||
const userRecoveryTicket = useFragment(FRAGMENT, props.userRecoveryTicket);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
ticket,
|
||||
form,
|
||||
}: { ticket: string; form: FormData }) => {
|
||||
const newPassword = form.get("new_password") as string;
|
||||
const newPasswordAgain = form.get("new_password_again") as string;
|
||||
|
||||
if (newPassword !== newPasswordAgain) {
|
||||
throw new Error(
|
||||
"passwords mismatch; this should be checked by the form",
|
||||
);
|
||||
}
|
||||
|
||||
const response = await graphqlRequest({
|
||||
query: RECOVER_PASSWORD_MUTATION,
|
||||
variables: {
|
||||
ticket,
|
||||
newPassword,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.setPasswordByRecovery.status === "ALLOWED") {
|
||||
// Redirect to the application root using a full page load
|
||||
// The MAS backend will then redirect to the login page
|
||||
// Unfortunately this won't work in dev mode (`npm run dev`)
|
||||
// as the backend isn't involved there.
|
||||
await navigate({ to: "/", reloadDocument: true });
|
||||
}
|
||||
|
||||
return response.setPasswordByRecovery;
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault();
|
||||
|
||||
const form = new FormData(event.currentTarget);
|
||||
mutation.mutate({ ticket: props.ticket, form });
|
||||
};
|
||||
|
||||
const unhandleableError = mutation.error !== null;
|
||||
|
||||
const errorMsg: string | undefined = translateSetPasswordError(
|
||||
t,
|
||||
mutation.data?.status,
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex flex-col gap-10">
|
||||
<PageHeading
|
||||
Icon={IconLockSolid}
|
||||
title={t("frontend.password_reset.title")}
|
||||
subtitle={t("frontend.password_reset.subtitle")}
|
||||
/>
|
||||
|
||||
<Form.Root onSubmit={onSubmit} method="POST">
|
||||
{/*
|
||||
In normal operation, the submit event should be `preventDefault()`ed.
|
||||
method = POST just prevents sending passwords in the query string,
|
||||
which could be logged, if for some reason the event handler fails.
|
||||
*/}
|
||||
{unhandleableError && (
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.password_change.failure.title")}
|
||||
>
|
||||
{t("frontend.password_change.failure.description.unspecified")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{errorMsg !== undefined && (
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.password_change.failure.title")}
|
||||
>
|
||||
{errorMsg}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
value={userRecoveryTicket.username}
|
||||
/>
|
||||
|
||||
<PasswordCreationDoubleInput
|
||||
siteConfig={siteConfig}
|
||||
forceShowNewPasswordInvalid={
|
||||
mutation.data?.status === "INVALID_NEW_PASSWORD" || false
|
||||
}
|
||||
/>
|
||||
|
||||
<Form.Submit kind="primary" disabled={mutation.isPending}>
|
||||
{!!mutation.isPending && <LoadingSpinner inline />}
|
||||
{t("action.save_and_continue")}
|
||||
</Form.Submit>
|
||||
</Form.Root>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export const Route = createLazyFileRoute("/password/recovery/")({
|
||||
component: RecoverPassword,
|
||||
});
|
||||
|
||||
function RecoverPassword(): React.ReactNode {
|
||||
const { ticket } = useSearch({
|
||||
from: "/password/recovery/",
|
||||
});
|
||||
const {
|
||||
data: { siteConfig, userRecoveryTicket },
|
||||
} = useSuspenseQuery(query(ticket));
|
||||
|
||||
if (!userRecoveryTicket) {
|
||||
throw notFound();
|
||||
}
|
||||
|
||||
switch (userRecoveryTicket.status) {
|
||||
case "EXPIRED":
|
||||
return (
|
||||
<EmailExpired ticket={ticket} userRecoveryTicket={userRecoveryTicket} />
|
||||
);
|
||||
case "CONSUMED":
|
||||
return <EmailConsumed />;
|
||||
case "VALID":
|
||||
return (
|
||||
<EmailRecovery
|
||||
ticket={ticket}
|
||||
siteConfig={siteConfig}
|
||||
userRecoveryTicket={userRecoveryTicket}
|
||||
/>
|
||||
);
|
||||
default: {
|
||||
const exhaustiveCheck: never = userRecoveryTicket.status;
|
||||
throw new Error(`Unhandled case: ${exhaustiveCheck}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,57 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import IconErrorSolid from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
|
||||
import IconLockSolid from "@vector-im/compound-design-tokens/assets/web/icons/lock-solid";
|
||||
import { Alert, Button, Form } from "@vector-im/compound-web";
|
||||
import type { FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as v from "valibot";
|
||||
import { ButtonLink } from "../components/ButtonLink";
|
||||
import Layout from "../components/Layout";
|
||||
import LoadingSpinner from "../components/LoadingSpinner";
|
||||
import PageHeading from "../components/PageHeading";
|
||||
import PasswordCreationDoubleInput from "../components/PasswordCreationDoubleInput";
|
||||
import { type FragmentType, useFragment } from "../gql";
|
||||
import { graphql } from "../gql";
|
||||
import { graphqlRequest } from "../graphql";
|
||||
import { translateSetPasswordError } from "../i18n/password_changes";
|
||||
|
||||
const RECOVER_PASSWORD_MUTATION = graphql(/* GraphQL */ `
|
||||
mutation RecoverPassword($ticket: String!, $newPassword: String!) {
|
||||
setPasswordByRecovery(
|
||||
input: { ticket: $ticket, newPassword: $newPassword }
|
||||
) {
|
||||
status
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const RESEND_EMAIL_MUTATION = graphql(/* GraphQL */ `
|
||||
mutation ResendRecoveryEmail($ticket: String!) {
|
||||
resendRecoveryEmail(input: { ticket: $ticket }) {
|
||||
status
|
||||
progressUrl
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const FRAGMENT = graphql(/* GraphQL */ `
|
||||
fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket {
|
||||
username
|
||||
email
|
||||
}
|
||||
`);
|
||||
|
||||
const SITE_CONFIG_FRAGMENT = graphql(/* GraphQL */ `
|
||||
fragment RecoverPassword_siteConfig on SiteConfig {
|
||||
...PasswordCreationDoubleInput_siteConfig
|
||||
}
|
||||
`);
|
||||
|
||||
const QUERY = graphql(/* GraphQL */ `
|
||||
query PasswordRecovery($ticket: String!) {
|
||||
@@ -23,7 +69,7 @@ const QUERY = graphql(/* GraphQL */ `
|
||||
}
|
||||
`);
|
||||
|
||||
export const query = (ticket: string) =>
|
||||
const query = (ticket: string) =>
|
||||
queryOptions({
|
||||
queryKey: ["passwordRecovery", ticket],
|
||||
queryFn: ({ signal }) =>
|
||||
@@ -48,4 +94,244 @@ export const Route = createFileRoute("/password/recovery/")({
|
||||
throw notFound();
|
||||
}
|
||||
},
|
||||
|
||||
component: RecoverPassword,
|
||||
});
|
||||
|
||||
const EmailConsumed: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Layout>
|
||||
<PageHeading
|
||||
Icon={IconErrorSolid}
|
||||
title={t("frontend.password_reset.consumed.title")}
|
||||
subtitle={t("frontend.password_reset.consumed.subtitle")}
|
||||
invalid
|
||||
/>
|
||||
|
||||
<ButtonLink kind="secondary" to="/" reloadDocument>
|
||||
{t("action.start_over")}
|
||||
</ButtonLink>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
const EmailExpired: React.FC<{
|
||||
userRecoveryTicket: FragmentType<typeof FRAGMENT>;
|
||||
ticket: string;
|
||||
}> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const userRecoveryTicket = useFragment(FRAGMENT, props.userRecoveryTicket);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({ ticket }: { ticket: string }) => {
|
||||
const response = await graphqlRequest({
|
||||
query: RESEND_EMAIL_MUTATION,
|
||||
variables: {
|
||||
ticket,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.resendRecoveryEmail.status === "SENT") {
|
||||
if (!response.resendRecoveryEmail.progressUrl) {
|
||||
throw new Error("Unexpected response, missing progress URL");
|
||||
}
|
||||
|
||||
// Redirect to the URL which confirms that the email was sent
|
||||
window.location.href = response.resendRecoveryEmail.progressUrl;
|
||||
|
||||
// We await an infinite promise here, so that the mutation
|
||||
// doesn't resolve
|
||||
await new Promise(() => undefined);
|
||||
}
|
||||
|
||||
return response.resendRecoveryEmail;
|
||||
},
|
||||
});
|
||||
|
||||
const onClick = (event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
event.preventDefault();
|
||||
mutation.mutate({ ticket: props.ticket });
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<PageHeading
|
||||
Icon={IconErrorSolid}
|
||||
title={t("frontend.password_reset.expired.title")}
|
||||
subtitle={t("frontend.password_reset.expired.subtitle", {
|
||||
email: userRecoveryTicket.email,
|
||||
})}
|
||||
invalid
|
||||
/>
|
||||
|
||||
{mutation.data?.status === "RATE_LIMITED" && (
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.errors.rate_limit_exceeded")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button kind="primary" disabled={mutation.isPending} onClick={onClick}>
|
||||
{!!mutation.isPending && <LoadingSpinner inline />}
|
||||
{t("frontend.password_reset.expired.resend_email")}
|
||||
</Button>
|
||||
|
||||
<ButtonLink kind="secondary" to="/" reloadDocument>
|
||||
{t("action.start_over")}
|
||||
</ButtonLink>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
const EmailRecovery: React.FC<{
|
||||
siteConfig: FragmentType<typeof SITE_CONFIG_FRAGMENT>;
|
||||
userRecoveryTicket: FragmentType<typeof FRAGMENT>;
|
||||
ticket: string;
|
||||
}> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const siteConfig = useFragment(SITE_CONFIG_FRAGMENT, props.siteConfig);
|
||||
const userRecoveryTicket = useFragment(FRAGMENT, props.userRecoveryTicket);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
ticket,
|
||||
form,
|
||||
}: {
|
||||
ticket: string;
|
||||
form: FormData;
|
||||
}) => {
|
||||
const newPassword = form.get("new_password") as string;
|
||||
const newPasswordAgain = form.get("new_password_again") as string;
|
||||
|
||||
if (newPassword !== newPasswordAgain) {
|
||||
throw new Error(
|
||||
"passwords mismatch; this should be checked by the form",
|
||||
);
|
||||
}
|
||||
|
||||
const response = await graphqlRequest({
|
||||
query: RECOVER_PASSWORD_MUTATION,
|
||||
variables: {
|
||||
ticket,
|
||||
newPassword,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.setPasswordByRecovery.status === "ALLOWED") {
|
||||
// Redirect to the application root using a full page load
|
||||
// The MAS backend will then redirect to the login page
|
||||
// Unfortunately this won't work in dev mode (`npm run dev`)
|
||||
// as the backend isn't involved there.
|
||||
await navigate({ to: "/", reloadDocument: true });
|
||||
}
|
||||
|
||||
return response.setPasswordByRecovery;
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault();
|
||||
|
||||
const form = new FormData(event.currentTarget);
|
||||
mutation.mutate({ ticket: props.ticket, form });
|
||||
};
|
||||
|
||||
const unhandleableError = mutation.error !== null;
|
||||
|
||||
const errorMsg: string | undefined = translateSetPasswordError(
|
||||
t,
|
||||
mutation.data?.status,
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex flex-col gap-10">
|
||||
<PageHeading
|
||||
Icon={IconLockSolid}
|
||||
title={t("frontend.password_reset.title")}
|
||||
subtitle={t("frontend.password_reset.subtitle")}
|
||||
/>
|
||||
|
||||
<Form.Root onSubmit={onSubmit} method="POST">
|
||||
{/*
|
||||
In normal operation, the submit event should be `preventDefault()`ed.
|
||||
method = POST just prevents sending passwords in the query string,
|
||||
which could be logged, if for some reason the event handler fails.
|
||||
*/}
|
||||
{unhandleableError && (
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.password_change.failure.title")}
|
||||
>
|
||||
{t("frontend.password_change.failure.description.unspecified")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{errorMsg !== undefined && (
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.password_change.failure.title")}
|
||||
>
|
||||
{errorMsg}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
value={userRecoveryTicket.username}
|
||||
/>
|
||||
|
||||
<PasswordCreationDoubleInput
|
||||
siteConfig={siteConfig}
|
||||
forceShowNewPasswordInvalid={
|
||||
mutation.data?.status === "INVALID_NEW_PASSWORD" || false
|
||||
}
|
||||
/>
|
||||
|
||||
<Form.Submit kind="primary" disabled={mutation.isPending}>
|
||||
{!!mutation.isPending && <LoadingSpinner inline />}
|
||||
{t("action.save_and_continue")}
|
||||
</Form.Submit>
|
||||
</Form.Root>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
function RecoverPassword(): React.ReactNode {
|
||||
const { ticket } = useSearch({
|
||||
from: "/password/recovery/",
|
||||
});
|
||||
const {
|
||||
data: { siteConfig, userRecoveryTicket },
|
||||
} = useSuspenseQuery(query(ticket));
|
||||
|
||||
if (!userRecoveryTicket) {
|
||||
throw notFound();
|
||||
}
|
||||
|
||||
switch (userRecoveryTicket.status) {
|
||||
case "EXPIRED":
|
||||
return (
|
||||
<EmailExpired ticket={ticket} userRecoveryTicket={userRecoveryTicket} />
|
||||
);
|
||||
case "CONSUMED":
|
||||
return <EmailConsumed />;
|
||||
case "VALID":
|
||||
return (
|
||||
<EmailRecovery
|
||||
ticket={ticket}
|
||||
siteConfig={siteConfig}
|
||||
userRecoveryTicket={userRecoveryTicket}
|
||||
/>
|
||||
);
|
||||
default: {
|
||||
const exhaustiveCheck: never = userRecoveryTicket.status;
|
||||
throw new Error(`Unhandled case: ${exhaustiveCheck}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { createLazyFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { Alert } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Layout from "../components/Layout";
|
||||
import { Link } from "../components/Link";
|
||||
import BrowserSessionDetail from "../components/SessionDetail/BrowserSessionDetail";
|
||||
import CompatSessionDetail from "../components/SessionDetail/CompatSessionDetail";
|
||||
import OAuth2SessionDetail from "../components/SessionDetail/OAuth2SessionDetail";
|
||||
import { query } from "./sessions.$id";
|
||||
|
||||
export const Route = createLazyFileRoute("/sessions/$id")({
|
||||
notFoundComponent: NotFound,
|
||||
component: SessionDetail,
|
||||
});
|
||||
|
||||
function NotFound(): React.ReactElement {
|
||||
const { id } = Route.useParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.session_detail.alert.title", { deviceId: id })}
|
||||
>
|
||||
{t("frontend.session_detail.alert.text")}
|
||||
<Link to="/sessions">{t("frontend.session_detail.alert.button")}</Link>
|
||||
</Alert>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionDetail(): React.ReactElement {
|
||||
const { id } = Route.useParams();
|
||||
const {
|
||||
data: { node, viewerSession },
|
||||
} = useSuspenseQuery(query(id));
|
||||
if (!node) throw notFound();
|
||||
|
||||
switch (node.__typename) {
|
||||
case "CompatSession":
|
||||
return (
|
||||
<Layout wide>
|
||||
<CompatSessionDetail session={node} />
|
||||
</Layout>
|
||||
);
|
||||
case "Oauth2Session":
|
||||
return (
|
||||
<Layout wide>
|
||||
<OAuth2SessionDetail session={node} />
|
||||
</Layout>
|
||||
);
|
||||
case "BrowserSession":
|
||||
return (
|
||||
<Layout wide>
|
||||
<BrowserSessionDetail
|
||||
session={node}
|
||||
isCurrent={node.id === viewerSession.id}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
default:
|
||||
throw new Error("Unknown session type");
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
// Copyright 2024, 2025 New Vector Ltd.
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { Alert } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Layout from "../components/Layout";
|
||||
import { Link } from "../components/Link";
|
||||
import BrowserSessionDetail from "../components/SessionDetail/BrowserSessionDetail";
|
||||
import CompatSessionDetail from "../components/SessionDetail/CompatSessionDetail";
|
||||
import OAuth2SessionDetail from "../components/SessionDetail/OAuth2SessionDetail";
|
||||
import { graphql } from "../gql";
|
||||
import { graphqlRequest } from "../graphql";
|
||||
|
||||
@@ -27,7 +34,7 @@ const QUERY = graphql(/* GraphQL */ `
|
||||
}
|
||||
`);
|
||||
|
||||
export const query = (id: string) =>
|
||||
const query = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: ["sessionDetail", id],
|
||||
queryFn: ({ signal }) =>
|
||||
@@ -37,4 +44,57 @@ export const query = (id: string) =>
|
||||
export const Route = createFileRoute("/sessions/$id")({
|
||||
loader: ({ context, params }) =>
|
||||
context.queryClient.ensureQueryData(query(params.id)),
|
||||
notFoundComponent: NotFound,
|
||||
component: SessionDetail,
|
||||
});
|
||||
|
||||
function NotFound(): React.ReactElement {
|
||||
const { id } = Route.useParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Alert
|
||||
type="critical"
|
||||
title={t("frontend.session_detail.alert.title", { deviceId: id })}
|
||||
>
|
||||
{t("frontend.session_detail.alert.text")}
|
||||
<Link to="/sessions">{t("frontend.session_detail.alert.button")}</Link>
|
||||
</Alert>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionDetail(): React.ReactElement {
|
||||
const { id } = Route.useParams();
|
||||
const {
|
||||
data: { node, viewerSession },
|
||||
} = useSuspenseQuery(query(id));
|
||||
if (!node) throw notFound();
|
||||
|
||||
switch (node.__typename) {
|
||||
case "CompatSession":
|
||||
return (
|
||||
<Layout wide>
|
||||
<CompatSessionDetail session={node} />
|
||||
</Layout>
|
||||
);
|
||||
case "Oauth2Session":
|
||||
return (
|
||||
<Layout wide>
|
||||
<OAuth2SessionDetail session={node} />
|
||||
</Layout>
|
||||
);
|
||||
case "BrowserSession":
|
||||
return (
|
||||
<Layout wide>
|
||||
<BrowserSessionDetail
|
||||
session={node}
|
||||
isCurrent={node.id === viewerSession.id}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
default:
|
||||
throw new Error("Unknown session type");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,9 +66,12 @@ export default defineConfig((env) => ({
|
||||
plugins: [
|
||||
codegen(),
|
||||
|
||||
react(),
|
||||
tanStackRouter({
|
||||
target: "react",
|
||||
autoCodeSplitting: true,
|
||||
}),
|
||||
|
||||
tanStackRouter(),
|
||||
react(),
|
||||
|
||||
codecovVitePlugin({
|
||||
enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
|
||||
|
||||
Reference in New Issue
Block a user