Use automatic route code splitting

This commit is contained in:
Quentin Gliech
2025-03-24 18:03:44 +01:00
parent 976dd898c7
commit 1e75c61bf6
23 changed files with 1212 additions and 1341 deletions

View File

@@ -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.
*/

View File

@@ -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))

View File

@@ -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",

View File

@@ -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>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
});

View File

@@ -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}`);
}
}
}

View File

@@ -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}`);
}
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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,