diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index bb1e9adb2..aeb68252f 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -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, @@ -286,15 +286,15 @@ export function graphql(source: "\n query DeviceRedirect($deviceId: String!, $u /** * 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 DoVerifyEmail($id: ID!, $code: String!) {\n completeEmailAuthentication(input: { id: $id, code: $code }) {\n status\n }\n }\n"): typeof import('./graphql').DoVerifyEmailDocument; +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. */ -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; +export function graphql(source: "\n mutation DoVerifyEmail($id: ID!, $code: String!) {\n completeEmailAuthentication(input: { id: $id, code: $code }) {\n status\n }\n }\n"): typeof import('./graphql').DoVerifyEmailDocument; /** * 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; +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. */ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 38795b6cb..7e13fed3d 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -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; +export const VerifyEmailDocument = new TypedDocumentString(` + query VerifyEmail($id: ID!) { + userEmailAuthentication(id: $id) { + id + email + completedAt + } +} + `) as unknown as TypedDocumentString; 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; -export const VerifyEmailDocument = new TypedDocumentString(` - query VerifyEmail($id: ID!) { - userEmailAuthentication(id: $id) { - id - email - completedAt - } -} - `) as unknown as TypedDocumentString; export const ChangePasswordDocument = new TypedDocumentString(` mutation ChangePassword($userId: ID!, $oldPassword: String!, $newPassword: String!) { setPassword( @@ -3285,19 +3285,19 @@ export const mockDeviceRedirectQuery = (resolver: GraphQLResponseResolver { - * const { id, code } = variables; + * const { id } = variables; * return HttpResponse.json({ - * data: { completeEmailAuthentication } + * data: { userEmailAuthentication } * }) * }, * requestOptions * ) */ -export const mockDoVerifyEmailMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => - graphql.mutation( - 'DoVerifyEmail', +export const mockVerifyEmailQuery = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => + graphql.query( + 'VerifyEmail', resolver, options ) @@ -3307,19 +3307,19 @@ export const mockDoVerifyEmailMutation = (resolver: GraphQLResponseResolver { - * const { id, language } = variables; + * const { id, code } = variables; * return HttpResponse.json({ - * data: { resendEmailAuthenticationCode } + * data: { completeEmailAuthentication } * }) * }, * requestOptions * ) */ -export const mockResendEmailAuthenticationCodeMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => - graphql.mutation( - 'ResendEmailAuthenticationCode', +export const mockDoVerifyEmailMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => + graphql.mutation( + 'DoVerifyEmail', resolver, options ) @@ -3329,19 +3329,19 @@ export const mockResendEmailAuthenticationCodeMutation = (resolver: GraphQLRespo * @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( + * mockResendEmailAuthenticationCodeMutation( * ({ query, variables }) => { - * const { id } = variables; + * const { id, language } = variables; * return HttpResponse.json({ - * data: { userEmailAuthentication } + * data: { resendEmailAuthenticationCode } * }) * }, * requestOptions * ) */ -export const mockVerifyEmailQuery = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => - graphql.query( - 'VerifyEmail', +export const mockResendEmailAuthenticationCodeMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => + graphql.mutation( + 'ResendEmailAuthenticationCode', resolver, options ) diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index d36635c93..a7afbbfff 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -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", diff --git a/frontend/src/routes/_account.index.lazy.tsx b/frontend/src/routes/_account.index.lazy.tsx deleted file mode 100644 index ec24f2dfe..000000000 --- a/frontend/src/routes/_account.index.lazy.tsx +++ /dev/null @@ -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 ( - - {t("frontend.account.sign_out.button")} - - } - > - {t("frontend.account.sign_out.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 => { - await navigate({ to: "/emails/$id/verify", params: { id } }); - }; - - return ( - <> -
- {/* 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) && ( - <> - - - - {siteConfig.emailChangeAllowed && ( - - )} - - - - - )} - - {siteConfig.passwordLoginEnabled && viewerSession.user.hasPassword && ( - <> - - - - - - - )} - - - - {t("frontend.reset_cross_signing.description")} - - - {t("frontend.reset_cross_signing.start_reset")} - - - - - - - - {siteConfig.accountDeactivationAllowed && ( - <> - - - - )} - - -
- - ); -} diff --git a/frontend/src/routes/_account.index.tsx b/frontend/src/routes/_account.index.tsx index bdeb470fa..5718eee16 100644 --- a/frontend/src/routes/_account.index.tsx +++ b/frontend/src/routes/_account.index.tsx @@ -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 ( + + {t("frontend.account.sign_out.button")} + + } + > + {t("frontend.account.sign_out.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 => { + await navigate({ to: "/emails/$id/verify", params: { id } }); + }; + + return ( + <> +
+ {/* 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) && ( + <> + + + + {siteConfig.emailChangeAllowed && ( + + )} + + + + + )} + + {siteConfig.passwordLoginEnabled && viewerSession.user.hasPassword && ( + <> + + + + + + + )} + + + + {t("frontend.reset_cross_signing.description")} + + + {t("frontend.reset_cross_signing.start_reset")} + + + + + + + + {siteConfig.accountDeactivationAllowed && ( + <> + + + + )} + + +
+ + ); +} diff --git a/frontend/src/routes/_account.lazy.tsx b/frontend/src/routes/_account.lazy.tsx deleted file mode 100644 index 817a6fc91..000000000 --- a/frontend/src/routes/_account.lazy.tsx +++ /dev/null @@ -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 ( - -
- - {t("frontend.account.title")} - - -
- - - - {t("frontend.nav.settings")} - {t("frontend.nav.devices")} - -
-
- - -
- ); -} diff --git a/frontend/src/routes/_account.sessions.browsers.lazy.tsx b/frontend/src/routes/_account.sessions.browsers.lazy.tsx deleted file mode 100644 index ee299aaa1..000000000 --- a/frontend/src/routes/_account.sessions.browsers.lazy.tsx +++ /dev/null @@ -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 ( -
-
{t("frontend.browser_sessions_overview.heading")}
- -
- - {t("frontend.last_active.inactive_90_days")} - -
- - {edges.map((n) => ( - - ))} - - {viewerSession.user.browserSessions.totalCount === 0 && ( - - {inactive - ? t( - "frontend.browser_sessions_overview.no_active_sessions.inactive_90_days", - ) - : t( - "frontend.browser_sessions_overview.no_active_sessions.default", - )} - - )} - - {/* Only show the pagination buttons if there are pages to go to */} - {(forwardPage || backwardPage) && ( -
- - {t("common.previous")} - - - {/* Spacer */} -
- - - {t("common.next")} - -
- )} -
- ); -} diff --git a/frontend/src/routes/_account.sessions.browsers.tsx b/frontend/src/routes/_account.sessions.browsers.tsx index 77a8c8c0b..2cb023d38 100644 --- a/frontend/src/routes/_account.sessions.browsers.tsx +++ b/frontend/src/routes/_account.sessions.browsers.tsx @@ -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 ( +
+
{t("frontend.browser_sessions_overview.heading")}
+ +
+ + {t("frontend.last_active.inactive_90_days")} + +
+ + {edges.map((n) => ( + + ))} + + {viewerSession.user.browserSessions.totalCount === 0 && ( + + {inactive + ? t( + "frontend.browser_sessions_overview.no_active_sessions.inactive_90_days", + ) + : t( + "frontend.browser_sessions_overview.no_active_sessions.default", + )} + + )} + + {/* Only show the pagination buttons if there are pages to go to */} + {(forwardPage || backwardPage) && ( +
+ + {t("common.previous")} + + + {/* Spacer */} +
+ + + {t("common.next")} + +
+ )} +
+ ); +} diff --git a/frontend/src/routes/_account.sessions.index.lazy.tsx b/frontend/src/routes/_account.sessions.index.lazy.tsx deleted file mode 100644 index d6d1a8ff2..000000000 --- a/frontend/src/routes/_account.sessions.index.lazy.tsx +++ /dev/null @@ -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 ( -
-

{t("frontend.user_sessions_overview.heading")}

- - -
- - {t("frontend.last_active.inactive_90_days")} - -
- {edges.map((session) => { - const type = session.node.__typename; - switch (type) { - case "Oauth2Session": - return ( - - ); - case "CompatSession": - return ( - - ); - default: - unknownSessionType(type); - } - })} - - {appSessions.totalCount === 0 && ( - - {inactive - ? t( - "frontend.user_sessions_overview.no_active_sessions.inactive_90_days", - ) - : t("frontend.user_sessions_overview.no_active_sessions.default")} - - )} - - {/* Only show the pagination buttons if there are pages to go to */} - {(forwardPage || backwardPage) && ( -
- - {t("common.previous")} - - - {/* Spacer */} -
- - - {t("common.next")} - -
- )} -
- ); -} diff --git a/frontend/src/routes/_account.sessions.index.tsx b/frontend/src/routes/_account.sessions.index.tsx index 355b50923..58d621a93 100644 --- a/frontend/src/routes/_account.sessions.index.tsx +++ b/frontend/src/routes/_account.sessions.index.tsx @@ -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 { 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 { queryOptions } from "@tanstack/react-query"; +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 ( +
+

{t("frontend.user_sessions_overview.heading")}

+ + +
+ + {t("frontend.last_active.inactive_90_days")} + +
+ {edges.map((session) => { + const type = session.node.__typename; + switch (type) { + case "Oauth2Session": + return ( + + ); + case "CompatSession": + return ( + + ); + default: + unknownSessionType(type); + } + })} + + {appSessions.totalCount === 0 && ( + + {inactive + ? t( + "frontend.user_sessions_overview.no_active_sessions.inactive_90_days", + ) + : t("frontend.user_sessions_overview.no_active_sessions.default")} + + )} + + {/* Only show the pagination buttons if there are pages to go to */} + {(forwardPage || backwardPage) && ( +
+ + {t("common.previous")} + + + {/* Spacer */} +
+ + + {t("common.next")} + +
+ )} +
+ ); +} diff --git a/frontend/src/routes/_account.tsx b/frontend/src/routes/_account.tsx index 9c7bdc714..72676f968 100644 --- a/frontend/src/routes/_account.tsx +++ b/frontend/src/routes/_account.tsx @@ -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 ( + +
+ + {t("frontend.account.title")} + + +
+ + + + {t("frontend.nav.settings")} + {t("frontend.nav.devices")} + +
+
+ + +
+ ); +} diff --git a/frontend/src/routes/clients.$id.lazy.tsx b/frontend/src/routes/clients.$id.lazy.tsx deleted file mode 100644 index 1ff460e84..000000000 --- a/frontend/src/routes/clients.$id.lazy.tsx +++ /dev/null @@ -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 ( - - - - ); -} diff --git a/frontend/src/routes/clients.$id.tsx b/frontend/src/routes/clients.$id.tsx index 1496d036b..8115d7e10 100644 --- a/frontend/src/routes/clients.$id.tsx +++ b/frontend/src/routes/clients.$id.tsx @@ -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 ( + + + + ); +} diff --git a/frontend/src/routes/emails.$id.verify.lazy.tsx b/frontend/src/routes/emails.$id.verify.lazy.tsx deleted file mode 100644 index 169798ca6..000000000 --- a/frontend/src/routes/emails.$id.verify.lazy.tsx +++ /dev/null @@ -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 { - 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(null); - const { t, i18n } = useTranslation(); - - const onFormSubmit = (e: React.FormEvent): 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 ( - - }} - /> - } - /> - - - {emailSent && ( - - {t("frontend.verify_email.email_sent_alert.description")} - - )} - - {invalidCode && ( - - {t("frontend.verify_email.invalid_code_alert.description")} - - )} - - {codeExpired && ( - - {t("frontend.verify_email.code_expired_alert.description")} - - )} - - {rateLimited && ( - - )} - - - {t("frontend.verify_email.code_field_label")} - - - {invalidCode && ( - - {t("frontend.verify_email.code_field_error")} - - )} - - - {t("frontend.verify_email.code_field_wrong_shape")} - - - - - {verifyEmail.isPending && } - {t("action.continue")} - - - - - - {t("action.back")} - - - - ); -} diff --git a/frontend/src/routes/emails.$id.verify.tsx b/frontend/src/routes/emails.$id.verify.tsx index d1d73121f..6c03f19c3 100644 --- a/frontend/src/routes/emails.$id.verify.tsx +++ b/frontend/src/routes/emails.$id.verify.tsx @@ -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 { + 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(null); + const { t, i18n } = useTranslation(); + + const onFormSubmit = (e: React.FormEvent): 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 ( + + }} + /> + } + /> + + + {emailSent && ( + + {t("frontend.verify_email.email_sent_alert.description")} + + )} + + {invalidCode && ( + + {t("frontend.verify_email.invalid_code_alert.description")} + + )} + + {codeExpired && ( + + {t("frontend.verify_email.code_expired_alert.description")} + + )} + + {rateLimited && ( + + )} + + + {t("frontend.verify_email.code_field_label")} + + + {invalidCode && ( + + {t("frontend.verify_email.code_field_error")} + + )} + + + {t("frontend.verify_email.code_field_wrong_shape")} + + + + + {verifyEmail.isPending && } + {t("action.continue")} + + + + + + {t("action.back")} + + + + ); +} diff --git a/frontend/src/routes/password.change.index.lazy.tsx b/frontend/src/routes/password.change.index.lazy.tsx deleted file mode 100644 index 57dcb1ba7..000000000 --- a/frontend/src/routes/password.change.index.lazy.tsx +++ /dev/null @@ -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(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): Promise => { - 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 ( - -
- - - - {/* - 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 && ( - - {t("frontend.password_change.failure.description.unspecified")} - - )} - - {errorMsg !== undefined && ( - - {errorMsg} - - )} - - - - {t("frontend.password_change.current_password_label")} - - - - - - {t("frontend.errors.field_required")} - - - {mutation.data && mutation.data.status === "WRONG_PASSWORD" && ( - - {t( - "frontend.password_change.failure.description.wrong_password", - )} - - )} - - - - - - - - {!!mutation.isPending && } - {t("action.save")} - - - - {t("action.cancel")} - - -
-
- ); -} diff --git a/frontend/src/routes/password.change.index.tsx b/frontend/src/routes/password.change.index.tsx index 2771c1fc6..749d0b90b 100644 --- a/frontend/src/routes/password.change.index.tsx +++ b/frontend/src/routes/password.change.index.tsx @@ -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(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): Promise => { + 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 ( + +
+ + + + {/* + 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 && ( + + {t("frontend.password_change.failure.description.unspecified")} + + )} + + {errorMsg !== undefined && ( + + {errorMsg} + + )} + + + + {t("frontend.password_change.current_password_label")} + + + + + + {t("frontend.errors.field_required")} + + + {mutation.data && mutation.data.status === "WRONG_PASSWORD" && ( + + {t( + "frontend.password_change.failure.description.wrong_password", + )} + + )} + + + + + + + + {!!mutation.isPending && } + {t("action.save")} + + + + {t("action.cancel")} + + +
+
+ ); +} diff --git a/frontend/src/routes/password.change.success.lazy.tsx b/frontend/src/routes/password.change.success.tsx similarity index 85% rename from frontend/src/routes/password.change.success.lazy.tsx rename to frontend/src/routes/password.change.success.tsx index 15357e0e0..368ca9b22 100644 --- a/frontend/src/routes/password.change.success.lazy.tsx +++ b/frontend/src/routes/password.change.success.tsx @@ -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, }); diff --git a/frontend/src/routes/password.recovery.index.lazy.tsx b/frontend/src/routes/password.recovery.index.lazy.tsx deleted file mode 100644 index 96605056a..000000000 --- a/frontend/src/routes/password.recovery.index.lazy.tsx +++ /dev/null @@ -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 ( - - - - - {t("action.start_over")} - - - ); -}; - -const EmailExpired: React.FC<{ - userRecoveryTicket: FragmentType; - 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): void => { - event.preventDefault(); - mutation.mutate({ ticket: props.ticket }); - }; - - return ( - - - - {mutation.data?.status === "RATE_LIMITED" && ( - - )} - - - - - {t("action.start_over")} - - - ); -}; - -const EmailRecovery: React.FC<{ - siteConfig: FragmentType; - userRecoveryTicket: FragmentType; - 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): Promise => { - 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 ( - -
- - - - {/* - 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 && ( - - {t("frontend.password_change.failure.description.unspecified")} - - )} - - {errorMsg !== undefined && ( - - {errorMsg} - - )} - - - - - - - {!!mutation.isPending && } - {t("action.save_and_continue")} - - -
-
- ); -}; - -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 ( - - ); - case "CONSUMED": - return ; - case "VALID": - return ( - - ); - default: { - const exhaustiveCheck: never = userRecoveryTicket.status; - throw new Error(`Unhandled case: ${exhaustiveCheck}`); - } - } -} diff --git a/frontend/src/routes/password.recovery.index.tsx b/frontend/src/routes/password.recovery.index.tsx index 2efa5811f..87c4f9210 100644 --- a/frontend/src/routes/password.recovery.index.tsx +++ b/frontend/src/routes/password.recovery.index.tsx @@ -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 ( + + + + + {t("action.start_over")} + + + ); +}; + +const EmailExpired: React.FC<{ + userRecoveryTicket: FragmentType; + 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): void => { + event.preventDefault(); + mutation.mutate({ ticket: props.ticket }); + }; + + return ( + + + + {mutation.data?.status === "RATE_LIMITED" && ( + + )} + + + + + {t("action.start_over")} + + + ); +}; + +const EmailRecovery: React.FC<{ + siteConfig: FragmentType; + userRecoveryTicket: FragmentType; + 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): Promise => { + 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 ( + +
+ + + + {/* + 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 && ( + + {t("frontend.password_change.failure.description.unspecified")} + + )} + + {errorMsg !== undefined && ( + + {errorMsg} + + )} + + + + + + + {!!mutation.isPending && } + {t("action.save_and_continue")} + + +
+
+ ); +}; + +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 ( + + ); + case "CONSUMED": + return ; + case "VALID": + return ( + + ); + default: { + const exhaustiveCheck: never = userRecoveryTicket.status; + throw new Error(`Unhandled case: ${exhaustiveCheck}`); + } + } +} diff --git a/frontend/src/routes/sessions.$id.lazy.tsx b/frontend/src/routes/sessions.$id.lazy.tsx deleted file mode 100644 index bc9524bf2..000000000 --- a/frontend/src/routes/sessions.$id.lazy.tsx +++ /dev/null @@ -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 ( - - - {t("frontend.session_detail.alert.text")} - {t("frontend.session_detail.alert.button")} - - - ); -} - -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 ( - - - - ); - case "Oauth2Session": - return ( - - - - ); - case "BrowserSession": - return ( - - - - ); - default: - throw new Error("Unknown session type"); - } -} diff --git a/frontend/src/routes/sessions.$id.tsx b/frontend/src/routes/sessions.$id.tsx index 9139aed31..6db9ecd79 100644 --- a/frontend/src/routes/sessions.$id.tsx +++ b/frontend/src/routes/sessions.$id.tsx @@ -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 ( + + + {t("frontend.session_detail.alert.text")} + {t("frontend.session_detail.alert.button")} + + + ); +} + +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 ( + + + + ); + case "Oauth2Session": + return ( + + + + ); + case "BrowserSession": + return ( + + + + ); + default: + throw new Error("Unknown session type"); + } +} diff --git a/frontend/tests/routes/__snapshots__/reset-cross-signing.test.tsx.snap b/frontend/tests/routes/__snapshots__/reset-cross-signing.test.tsx.snap index 06c4756ad..2b13c2cd4 100644 --- a/frontend/tests/routes/__snapshots__/reset-cross-signing.test.tsx.snap +++ b/frontend/tests/routes/__snapshots__/reset-cross-signing.test.tsx.snap @@ -43,6 +43,43 @@ exports[`Reset cross signing > renders the cancelled page 1`] = ` > If you're signed out everywhere and don't remember your recovery code, you'll still need to reset your identity.

+
`; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c250dbf92..58e8b70cb 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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,