From 58f474939f2c153641cbe3e8c5c2287ab82da603 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 10 Jan 2025 16:12:53 +0100 Subject: [PATCH] Use the new GraphQL APIs in the frontend to add emails --- frontend/locales/en.json | 2 +- .../components/PageHeading/PageHeading.tsx | 4 +- .../components/UserProfile/AddEmailForm.tsx | 41 +- .../VerifyEmail/VerifyEmail.module.css | 32 -- .../components/VerifyEmail/VerifyEmail.tsx | 169 -------- .../__snapshots__/VerifyEmail.test.tsx.snap | 179 -------- frontend/src/components/VerifyEmail/index.ts | 7 - frontend/src/gql/gql.ts | 37 +- frontend/src/gql/graphql.ts | 381 ++++++++++-------- frontend/src/routes/_account.index.lazy.tsx | 4 +- frontend/src/routes/_account.index.tsx | 2 - .../src/routes/emails.$id.verify.lazy.tsx | 159 +++++++- frontend/src/routes/emails.$id.verify.tsx | 20 +- frontend/stories/routes/index.stories.tsx | 1 - frontend/tailwind.config.cjs | 1 + frontend/tests/mocks/handlers.ts | 1 - 16 files changed, 420 insertions(+), 620 deletions(-) delete mode 100644 frontend/src/components/VerifyEmail/VerifyEmail.module.css delete mode 100644 frontend/src/components/VerifyEmail/VerifyEmail.tsx delete mode 100644 frontend/src/components/VerifyEmail/__snapshots__/VerifyEmail.test.tsx.snap delete mode 100644 frontend/src/components/VerifyEmail/index.ts diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 697123442..cc977da72 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -49,9 +49,9 @@ }, "add_email_form": { "email_denied_error": "The entered email is not allowed by the server policy", - "email_exists_error": "The entered email is already added to this account", "email_field_help": "Add an alternative email you can use to access this account.", "email_field_label": "Add email", + "email_in_use_error": "The entered email is already in use", "email_invalid_error": "The entered email is invalid" }, "browser_session_details": { diff --git a/frontend/src/components/PageHeading/PageHeading.tsx b/frontend/src/components/PageHeading/PageHeading.tsx index ef3a94cd4..72efbb1e0 100644 --- a/frontend/src/components/PageHeading/PageHeading.tsx +++ b/frontend/src/components/PageHeading/PageHeading.tsx @@ -12,8 +12,8 @@ type Props = { Icon: React.ComponentType>; invalid?: boolean; success?: boolean; - title: string; - subtitle?: string; + title: React.ReactNode; + subtitle?: React.ReactNode; }; const PageHeading: React.FC = ({ diff --git a/frontend/src/components/UserProfile/AddEmailForm.tsx b/frontend/src/components/UserProfile/AddEmailForm.tsx index 83ed59302..d96061122 100644 --- a/frontend/src/components/UserProfile/AddEmailForm.tsx +++ b/frontend/src/components/UserProfile/AddEmailForm.tsx @@ -15,44 +15,42 @@ import { graphql } from "../../gql"; import { graphqlRequest } from "../../graphql"; const ADD_EMAIL_MUTATION = graphql(/* GraphQL */ ` - mutation AddEmail($userId: ID!, $email: String!) { - addEmail(input: { userId: $userId, email: $email }) { + mutation AddEmail($email: String!, $language: String!) { + startEmailAuthentication(input: { email: $email, language: $language }) { status violations - email { + authentication { id - ...UserEmail_email } } } `); const AddEmailForm: React.FC<{ - userId: string; onAdd: (id: string) => Promise; -}> = ({ userId, onAdd }) => { - const { t } = useTranslation(); +}> = ({ onAdd }) => { + const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); const addEmail = useMutation({ - mutationFn: ({ userId, email }: { userId: string; email: string }) => + mutationFn: ({ email, language }: { email: string; language: string }) => graphqlRequest({ query: ADD_EMAIL_MUTATION, - variables: { userId, email }, + variables: { email, language }, }), onSuccess: async (data) => { queryClient.invalidateQueries({ queryKey: ["userEmails"] }); // Don't clear the form if the email was invalid or already exists - if (data.addEmail.status !== "ADDED") { + if (data.startEmailAuthentication.status !== "STARTED") { return; } - if (!data.addEmail.email?.id) { + if (!data.startEmailAuthentication.authentication?.id) { throw new Error("Unexpected response from server"); } // Call the onAdd callback - await onAdd(data.addEmail.email?.id); + await onAdd(data.startEmailAuthentication.authentication?.id); }, }); @@ -63,20 +61,18 @@ const AddEmailForm: React.FC<{ const formData = new FormData(e.currentTarget); const email = formData.get("input") as string; - addEmail.mutate({ userId, email }); + addEmail.mutate({ email, language: i18n.languages[0] }); }; - const status = addEmail.data?.addEmail.status ?? null; - const violations = addEmail.data?.addEmail.violations ?? []; + const status = addEmail.data?.startEmailAuthentication.status ?? null; + const violations = addEmail.data?.startEmailAuthentication.violations ?? []; return ( - + {t("frontend.add_email_form.email_invalid_error")} - {status === "EXISTS" && ( + {status === "IN_USE" && ( - {t("frontend.add_email_form.email_exists_error")} + {t("frontend.add_email_form.email_in_use_error")} )} diff --git a/frontend/src/components/VerifyEmail/VerifyEmail.module.css b/frontend/src/components/VerifyEmail/VerifyEmail.module.css deleted file mode 100644 index a13de89ab..000000000 --- a/frontend/src/components/VerifyEmail/VerifyEmail.module.css +++ /dev/null @@ -1,32 +0,0 @@ -/* Copyright 2024 New Vector Ltd. -* Copyright 2023, 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. - */ - -.header { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--cpd-space-2x); - text-align: center; -} - -.tagline { - color: var(--cpd-color-text-secondary); - - & > span { - color: var(--cpd-color-text-primary); - } -} - -.icon { - height: var(--cpd-space-16x); - width: var(--cpd-space-16x); - color: var(--cpd-color-icon-secondary); - background: var(--cpd-color-bg-subtle-secondary); - padding: var(--cpd-space-2x); - border-radius: var(--cpd-space-2x); - margin-bottom: var(--cpd-space-2x); -} diff --git a/frontend/src/components/VerifyEmail/VerifyEmail.tsx b/frontend/src/components/VerifyEmail/VerifyEmail.tsx deleted file mode 100644 index 42ee6bc93..000000000 --- a/frontend/src/components/VerifyEmail/VerifyEmail.tsx +++ /dev/null @@ -1,169 +0,0 @@ -// Copyright 2024, 2025 New Vector Ltd. -// Copyright 2023, 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 { useLinkProps, 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, H1, Text } from "@vector-im/compound-web"; -import { useRef } from "react"; -import { Trans, useTranslation } from "react-i18next"; -import { type FragmentType, graphql, useFragment } from "../../gql"; -import { graphqlRequest } from "../../graphql"; -import styles from "./VerifyEmail.module.css"; - -const FRAGMENT = graphql(/* GraphQL */ ` - fragment UserEmail_verifyEmail on UserEmail { - id - email - } -`); - -const VERIFY_EMAIL_MUTATION = graphql(/* GraphQL */ ` - mutation DoVerifyEmail($id: ID!, $code: String!) { - verifyEmail(input: { userEmailId: $id, code: $code }) { - status - } - } -`); - -const RESEND_VERIFICATION_EMAIL_MUTATION = graphql(/* GraphQL */ ` - mutation ResendVerificationEmail($id: ID!) { - sendVerificationEmail(input: { userEmailId: $id }) { - status - } - } -`); - -const BackButton: React.FC = () => { - const props = useLinkProps({ to: "/" }); - const { t } = useTranslation(); - - return ( - - ); -}; - -const VerifyEmail: React.FC<{ - email: FragmentType; -}> = ({ email }) => { - const data = useFragment(FRAGMENT, email); - const queryClient = useQueryClient(); - const verifyEmail = useMutation({ - mutationFn: ({ id, code }: { id: string; code: string }) => - graphqlRequest({ query: VERIFY_EMAIL_MUTATION, variables: { id, code } }), - onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ["currentUserGreeting"] }); - queryClient.invalidateQueries({ queryKey: ["userProfile"] }); - queryClient.invalidateQueries({ queryKey: ["userEmails"] }); - - if (data.verifyEmail.status === "VERIFIED") { - navigate({ to: "/" }); - } - }, - }); - - const resendVerificationEmail = useMutation({ - mutationFn: (id: string) => - graphqlRequest({ - query: RESEND_VERIFICATION_EMAIL_MUTATION, - variables: { id }, - }), - onSuccess: () => { - fieldRef.current?.focus(); - }, - }); - const navigate = useNavigate(); - const fieldRef = useRef(null); - const { t } = 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: data.id, code }).finally(() => form.reset()); - }; - - const onResendClick = (): void => { - resendVerificationEmail.mutate(data.id); - }; - - const emailSent = - resendVerificationEmail.data?.sendVerificationEmail.status === "SENT"; - const invalidCode = verifyEmail.data?.verifyEmail.status === "INVALID_CODE"; - const { email: codeEmail } = data; - - return ( - <> -
- -

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

- - }} - /> - -
- - - {emailSent && ( - - {t("frontend.verify_email.email_sent_alert.description")} - - )} - {invalidCode && ( - - {t("frontend.verify_email.invalid_code_alert.description")} - - )} - - {t("frontend.verify_email.code_field_label")} - - - {invalidCode && ( - - {t("frontend.verify_email.code_field_error")} - - )} - - - {t("frontend.verify_email.code_field_wrong_shape")} - - - - - {t("action.continue")} - - - - - - ); -}; - -export default VerifyEmail; diff --git a/frontend/src/components/VerifyEmail/__snapshots__/VerifyEmail.test.tsx.snap b/frontend/src/components/VerifyEmail/__snapshots__/VerifyEmail.test.tsx.snap deleted file mode 100644 index 9e89b6078..000000000 --- a/frontend/src/components/VerifyEmail/__snapshots__/VerifyEmail.test.tsx.snap +++ /dev/null @@ -1,179 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[` > renders an active session 1`] = ` -[ -
- - - -

- Verify your email -

-

- Enter the 6-digit code sent to - - - ernie@sesame.st - -

-
, -
-
- - -
- - -
, -] -`; - -exports[` > renders verify screen for email 1`] = ` -[ -
- - - -

- Verify your email -

-

- Enter the 6-digit code sent to - - - ernie@sesame.st - -

-
, -
-
- - -
- - -
, -] -`; diff --git a/frontend/src/components/VerifyEmail/index.ts b/frontend/src/components/VerifyEmail/index.ts deleted file mode 100644 index 6b4461f11..000000000 --- a/frontend/src/components/VerifyEmail/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright 2024 New Vector Ltd. -// Copyright 2023, 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. - -export { default } from "./VerifyEmail"; diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index 89d6d8e47..e7d2ce0f6 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -35,14 +35,11 @@ const documents = { "\n fragment UserGreeting_user on User {\n id\n matrix {\n mxid\n displayName\n }\n }\n": types.UserGreeting_UserFragmentDoc, "\n fragment UserGreeting_siteConfig on SiteConfig {\n displayNameChangeAllowed\n }\n": types.UserGreeting_SiteConfigFragmentDoc, "\n mutation SetDisplayName($userId: ID!, $displayName: String) {\n setDisplayName(input: { userId: $userId, displayName: $displayName }) {\n status\n }\n }\n": types.SetDisplayNameDocument, - "\n mutation AddEmail($userId: ID!, $email: String!) {\n addEmail(input: { userId: $userId, email: $email }) {\n status\n violations\n email {\n id\n ...UserEmail_email\n }\n }\n }\n": types.AddEmailDocument, + "\n mutation AddEmail($email: String!, $language: String!) {\n startEmailAuthentication(input: { email: $email, language: $language }) {\n status\n violations\n authentication {\n id\n }\n }\n }\n": types.AddEmailDocument, "\n query UserEmailList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n viewer {\n __typename\n ... on User {\n emails(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n ...UserEmail_email\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n": types.UserEmailListDocument, "\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n }\n": types.UserEmailList_SiteConfigFragmentDoc, "\n fragment BrowserSessionsOverview_user on User {\n id\n\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n": types.BrowserSessionsOverview_UserFragmentDoc, - "\n fragment UserEmail_verifyEmail on UserEmail {\n id\n email\n }\n": types.UserEmail_VerifyEmailFragmentDoc, - "\n mutation DoVerifyEmail($id: ID!, $code: String!) {\n verifyEmail(input: { userEmailId: $id, code: $code }) {\n status\n }\n }\n": types.DoVerifyEmailDocument, - "\n mutation ResendVerificationEmail($id: ID!) {\n sendVerificationEmail(input: { userEmailId: $id }) {\n status\n }\n }\n": types.ResendVerificationEmailDocument, - "\n query UserProfile {\n viewer {\n __typename\n ... on User {\n id\n\n emails(first: 0) {\n totalCount\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n ...UserEmailList_siteConfig\n ...UserEmail_siteConfig\n ...PasswordChange_siteConfig\n }\n }\n": types.UserProfileDocument, + "\n query UserProfile {\n viewer {\n __typename\n ... on User {\n emails(first: 0) {\n totalCount\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n ...UserEmailList_siteConfig\n ...UserEmail_siteConfig\n ...PasswordChange_siteConfig\n }\n }\n": types.UserProfileDocument, "\n query SessionDetail($id: ID!) {\n viewerSession {\n ... on Node {\n id\n }\n }\n\n node(id: $id) {\n __typename\n id\n ...CompatSession_detail\n ...OAuth2Session_detail\n ...BrowserSession_detail\n }\n }\n": types.SessionDetailDocument, "\n query BrowserSessionList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n $lastActive: DateFilter\n ) {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n\n user {\n id\n\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n lastActive: $lastActive\n state: ACTIVE\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n }\n": types.BrowserSessionListDocument, "\n query SessionsOverview {\n viewer {\n __typename\n\n ... on User {\n id\n ...BrowserSessionsOverview_user\n }\n }\n }\n": types.SessionsOverviewDocument, @@ -51,7 +48,9 @@ const 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 userEmail(id: $id) {\n ...UserEmail_verifyEmail\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, @@ -145,7 +144,7 @@ export function graphql(source: "\n mutation SetDisplayName($userId: ID!, $disp /** * 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 AddEmail($userId: ID!, $email: String!) {\n addEmail(input: { userId: $userId, email: $email }) {\n status\n violations\n email {\n id\n ...UserEmail_email\n }\n }\n }\n"): typeof import('./graphql').AddEmailDocument; +export function graphql(source: "\n mutation AddEmail($email: String!, $language: String!) {\n startEmailAuthentication(input: { email: $email, language: $language }) {\n status\n violations\n authentication {\n id\n }\n }\n }\n"): typeof import('./graphql').AddEmailDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -161,19 +160,7 @@ export function graphql(source: "\n fragment BrowserSessionsOverview_user on Us /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n fragment UserEmail_verifyEmail on UserEmail {\n id\n email\n }\n"): typeof import('./graphql').UserEmail_VerifyEmailFragmentDoc; -/** - * 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 verifyEmail(input: { userEmailId: $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 mutation ResendVerificationEmail($id: ID!) {\n sendVerificationEmail(input: { userEmailId: $id }) {\n status\n }\n }\n"): typeof import('./graphql').ResendVerificationEmailDocument; -/** - * 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 UserProfile {\n viewer {\n __typename\n ... on User {\n id\n\n emails(first: 0) {\n totalCount\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n ...UserEmailList_siteConfig\n ...UserEmail_siteConfig\n ...PasswordChange_siteConfig\n }\n }\n"): typeof import('./graphql').UserProfileDocument; +export function graphql(source: "\n query UserProfile {\n viewer {\n __typename\n ... on User {\n emails(first: 0) {\n totalCount\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n ...UserEmailList_siteConfig\n ...UserEmail_siteConfig\n ...PasswordChange_siteConfig\n }\n }\n"): typeof import('./graphql').UserProfileDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -209,7 +196,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 query VerifyEmail($id: ID!) {\n userEmail(id: $id) {\n ...UserEmail_verifyEmail\n }\n }\n"): typeof import('./graphql').VerifyEmailDocument; +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 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. */ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 75b44f5ac..458c68f02 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -326,6 +326,30 @@ export type CompatSsoLoginEdge = { node: CompatSsoLogin; }; +/** The input for the `completeEmailAuthentication` mutation */ +export type CompleteEmailAuthenticationInput = { + /** The authentication code to use */ + code: Scalars['String']['input']; + /** The ID of the authentication session to complete */ + id: Scalars['ID']['input']; +}; + +/** The payload of the `completeEmailAuthentication` mutation */ +export type CompleteEmailAuthenticationPayload = { + __typename?: 'CompleteEmailAuthenticationPayload'; + /** Status of the operation */ + status: CompleteEmailAuthenticationStatus; +}; + +/** The status of the `completeEmailAuthentication` mutation */ +export type CompleteEmailAuthenticationStatus = + /** The authentication code has expired */ + | 'CODE_EXPIRED' + /** The authentication was completed */ + | 'COMPLETED' + /** The authentication code is invalid */ + | 'INVALID_CODE'; + /** The input of the `createOauth2Session` mutation. */ export type CreateOAuth2SessionInput = { /** Whether the session should issue a never-expiring access token */ @@ -474,12 +498,17 @@ export type MatrixUser = { /** The mutations root of the GraphQL interface. */ export type Mutation = { __typename?: 'Mutation'; - /** Add an email address to the specified user */ + /** + * Add an email address to the specified user + * @deprecated Use `startEmailAuthentication` instead. + */ addEmail: AddEmailPayload; /** Add a user. This is only available to administrators. */ addUser: AddUserPayload; /** Temporarily allow user to reset their cross-signing keys. */ allowUserCrossSigningReset: AllowUserCrossSigningResetPayload; + /** Complete the email authentication flow */ + completeEmailAuthentication: CompleteEmailAuthenticationPayload; /** * Create a new arbitrary OAuth 2.0 Session. * @@ -493,6 +522,8 @@ export type Mutation = { lockUser: LockUserPayload; /** Remove an email address */ removeEmail: RemoveEmailPayload; + /** Resend the email authentication code */ + resendEmailAuthenticationCode: ResendEmailAuthenticationCodePayload; /** * Resend a user recovery email * @@ -501,8 +532,6 @@ export type Mutation = { * calls this mutation. */ resendRecoveryEmail: ResendRecoveryEmailPayload; - /** Send a verification code for an email address */ - sendVerificationEmail: SendVerificationEmailPayload; /** * Set whether a user can request admin. This is only available to * administrators. @@ -526,10 +555,10 @@ export type Mutation = { * @deprecated This doesn't do anything anymore, but is kept to avoid breaking existing queries */ setPrimaryEmail: SetPrimaryEmailPayload; + /** Start a new email authentication flow */ + startEmailAuthentication: StartEmailAuthenticationPayload; /** Unlock a user. This is only available to administrators. */ unlockUser: UnlockUserPayload; - /** Submit a verification code for an email address */ - verifyEmail: VerifyEmailPayload; }; @@ -551,6 +580,12 @@ export type MutationAllowUserCrossSigningResetArgs = { }; +/** The mutations root of the GraphQL interface. */ +export type MutationCompleteEmailAuthenticationArgs = { + input: CompleteEmailAuthenticationInput; +}; + + /** The mutations root of the GraphQL interface. */ export type MutationCreateOauth2SessionArgs = { input: CreateOAuth2SessionInput; @@ -588,14 +623,14 @@ export type MutationRemoveEmailArgs = { /** The mutations root of the GraphQL interface. */ -export type MutationResendRecoveryEmailArgs = { - input: ResendRecoveryEmailInput; +export type MutationResendEmailAuthenticationCodeArgs = { + input: ResendEmailAuthenticationCodeInput; }; /** The mutations root of the GraphQL interface. */ -export type MutationSendVerificationEmailArgs = { - input: SendVerificationEmailInput; +export type MutationResendRecoveryEmailArgs = { + input: ResendRecoveryEmailInput; }; @@ -630,14 +665,14 @@ export type MutationSetPrimaryEmailArgs = { /** The mutations root of the GraphQL interface. */ -export type MutationUnlockUserArgs = { - input: UnlockUserInput; +export type MutationStartEmailAuthenticationArgs = { + input: StartEmailAuthenticationInput; }; /** The mutations root of the GraphQL interface. */ -export type MutationVerifyEmailArgs = { - input: VerifyEmailInput; +export type MutationUnlockUserArgs = { + input: UnlockUserInput; }; /** An object with an ID. */ @@ -779,6 +814,8 @@ export type Query = { userByUsername?: Maybe; /** Fetch a user email by its ID. */ userEmail?: Maybe; + /** Fetch a user email authentication session */ + userEmailAuthentication?: Maybe; /** Fetch a user recovery ticket. */ userRecoveryTicket?: Maybe; /** @@ -870,6 +907,12 @@ export type QueryUserEmailArgs = { }; +/** The query root of the GraphQL interface. */ +export type QueryUserEmailAuthenticationArgs = { + id: Scalars['ID']['input']; +}; + + /** The query root of the GraphQL interface. */ export type QueryUserRecoveryTicketArgs = { ticket: Scalars['String']['input']; @@ -910,6 +953,28 @@ export type RemoveEmailStatus = /** The email address was removed */ | 'REMOVED'; +/** The input for the `resendEmailAuthenticationCode` mutation */ +export type ResendEmailAuthenticationCodeInput = { + /** The ID of the authentication session to resend the code for */ + id: Scalars['ID']['input']; + /** The language to use for the email */ + language?: Scalars['String']['input']; +}; + +/** The payload of the `resendEmailAuthenticationCode` mutation */ +export type ResendEmailAuthenticationCodePayload = { + __typename?: 'ResendEmailAuthenticationCodePayload'; + /** Status of the operation */ + status: ResendEmailAuthenticationCodeStatus; +}; + +/** The status of the `resendEmailAuthenticationCode` mutation */ +export type ResendEmailAuthenticationCodeStatus = + /** The email authentication session is already completed */ + | 'COMPLETED' + /** The email was resent */ + | 'RESENT'; + /** The input for the `resendRecoveryEmail` mutation. */ export type ResendRecoveryEmailInput = { /** The recovery ticket to use. */ @@ -934,28 +999,6 @@ export type ResendRecoveryEmailStatus = /** The recovery email was sent. */ | 'SENT'; -/** The input for the `sendVerificationEmail` mutation */ -export type SendVerificationEmailInput = { - /** The ID of the email address to verify */ - userEmailId: Scalars['ID']['input']; -}; - -/** The payload of the `sendVerificationEmail` mutation */ -export type SendVerificationEmailPayload = { - __typename?: 'SendVerificationEmailPayload'; - /** The email address to which the verification email was sent */ - email: UserEmail; - /** Status of the operation */ - status: SendVerificationEmailStatus; - /** The user to whom the email address belongs */ - user: User; -}; - -/** The status of the `sendVerificationEmail` mutation */ -export type SendVerificationEmailStatus = - /** The email address is already verified */ - | 'ALREADY_VERIFIED'; - /** A client session, either compat or OAuth 2.0 */ export type Session = CompatSession | Oauth2Session; @@ -1135,6 +1178,36 @@ export type SiteConfig = Node & { tosUri?: Maybe; }; +/** The input for the `startEmailAuthentication` mutation */ +export type StartEmailAuthenticationInput = { + /** The email address to add to the account */ + email: Scalars['String']['input']; + /** The language to use for the email */ + language?: Scalars['String']['input']; +}; + +/** The payload of the `startEmailAuthentication` mutation */ +export type StartEmailAuthenticationPayload = { + __typename?: 'StartEmailAuthenticationPayload'; + /** The email authentication session that was started */ + authentication?: Maybe; + /** Status of the operation */ + status: StartEmailAuthenticationStatus; + /** The list of policy violations if the email address was denied */ + violations?: Maybe>; +}; + +/** The status of the `startEmailAuthentication` mutation */ +export type StartEmailAuthenticationStatus = + /** The email address isn't allowed by the policy */ + | 'DENIED' + /** The email address is invalid */ + | 'INVALID_EMAIL_ADDRESS' + /** The email address is already in use */ + | 'IN_USE' + /** The email address was started */ + | 'STARTED'; + /** The input for the `unlockUser` mutation. */ export type UnlockUserInput = { /** The ID of the user to unlock */ @@ -1404,6 +1477,19 @@ export type UserEmail = CreationEvent & Node & { id: Scalars['ID']['output']; }; +/** A email authentication session */ +export type UserEmailAuthentication = CreationEvent & Node & { + __typename?: 'UserEmailAuthentication'; + /** When the object was last updated. */ + completedAt?: Maybe; + /** When the object was created. */ + createdAt: Scalars['DateTime']['output']; + /** The email address associated with this session */ + email: Scalars['String']['output']; + /** ID of the object. */ + id: Scalars['ID']['output']; +}; + export type UserEmailConnection = { __typename?: 'UserEmailConnection'; /** A list of edges. */ @@ -1463,30 +1549,6 @@ export type UserState = /** The user is locked. */ | 'LOCKED'; -/** The input for the `verifyEmail` mutation */ -export type VerifyEmailInput = { - /** The verification code */ - code: Scalars['String']['input']; - /** The ID of the email address to verify */ - userEmailId: Scalars['ID']['input']; -}; - -/** The payload of the `verifyEmail` mutation */ -export type VerifyEmailPayload = { - __typename?: 'VerifyEmailPayload'; - /** The email address that was verified */ - email?: Maybe; - /** Status of the operation */ - status: VerifyEmailStatus; - /** The user to whom the email address belongs */ - user?: Maybe; -}; - -/** The status of the `verifyEmail` mutation */ -export type VerifyEmailStatus = - /** The email address was already verified before */ - | 'ALREADY_VERIFIED'; - /** Represents the current viewer */ export type Viewer = Anonymous | User; @@ -1569,15 +1631,12 @@ export type SetDisplayNameMutationVariables = Exact<{ export type SetDisplayNameMutation = { __typename?: 'Mutation', setDisplayName: { __typename?: 'SetDisplayNamePayload', status: SetDisplayNameStatus } }; export type AddEmailMutationVariables = Exact<{ - userId: Scalars['ID']['input']; email: Scalars['String']['input']; + language: Scalars['String']['input']; }>; -export type AddEmailMutation = { __typename?: 'Mutation', addEmail: { __typename?: 'AddEmailPayload', status: AddEmailStatus, violations?: Array | null, email?: ( - { __typename?: 'UserEmail', id: string } - & { ' $fragmentRefs'?: { 'UserEmail_EmailFragment': UserEmail_EmailFragment } } - ) | null } }; +export type AddEmailMutation = { __typename?: 'Mutation', startEmailAuthentication: { __typename?: 'StartEmailAuthenticationPayload', status: StartEmailAuthenticationStatus, violations?: Array | null, authentication?: { __typename?: 'UserEmailAuthentication', id: string } | null } }; export type UserEmailListQueryVariables = Exact<{ first?: InputMaybe; @@ -1596,27 +1655,10 @@ export type UserEmailList_SiteConfigFragment = { __typename?: 'SiteConfig', emai export type BrowserSessionsOverview_UserFragment = { __typename?: 'User', id: string, browserSessions: { __typename?: 'BrowserSessionConnection', totalCount: number } } & { ' $fragmentName'?: 'BrowserSessionsOverview_UserFragment' }; -export type UserEmail_VerifyEmailFragment = { __typename?: 'UserEmail', id: string, email: string } & { ' $fragmentName'?: 'UserEmail_VerifyEmailFragment' }; - -export type DoVerifyEmailMutationVariables = Exact<{ - id: Scalars['ID']['input']; - code: Scalars['String']['input']; -}>; - - -export type DoVerifyEmailMutation = { __typename?: 'Mutation', verifyEmail: { __typename?: 'VerifyEmailPayload', status: VerifyEmailStatus } }; - -export type ResendVerificationEmailMutationVariables = Exact<{ - id: Scalars['ID']['input']; -}>; - - -export type ResendVerificationEmailMutation = { __typename?: 'Mutation', sendVerificationEmail: { __typename?: 'SendVerificationEmailPayload', status: SendVerificationEmailStatus } }; - export type UserProfileQueryVariables = Exact<{ [key: string]: never; }>; -export type UserProfileQuery = { __typename?: 'Query', viewer: { __typename: 'Anonymous' } | { __typename: 'User', id: string, emails: { __typename?: 'UserEmailConnection', totalCount: number } }, siteConfig: ( +export type UserProfileQuery = { __typename?: 'Query', viewer: { __typename: 'Anonymous' } | { __typename: 'User', emails: { __typename?: 'UserEmailConnection', totalCount: number } }, siteConfig: ( { __typename?: 'SiteConfig', emailChangeAllowed: boolean, passwordLoginEnabled: boolean } & { ' $fragmentRefs'?: { 'UserEmailList_SiteConfigFragment': UserEmailList_SiteConfigFragment;'UserEmail_SiteConfigFragment': UserEmail_SiteConfigFragment;'PasswordChange_SiteConfigFragment': PasswordChange_SiteConfigFragment } } ) }; @@ -1635,7 +1677,7 @@ export type SessionDetailQuery = { __typename?: 'Query', viewerSession: { __type ) | { __typename: 'CompatSsoLogin', id: string } | { __typename: 'Oauth2Client', id: string } | ( { __typename: 'Oauth2Session', id: string } & { ' $fragmentRefs'?: { 'OAuth2Session_DetailFragment': OAuth2Session_DetailFragment } } - ) | { __typename: 'SiteConfig', id: string } | { __typename: 'UpstreamOAuth2Link', id: string } | { __typename: 'UpstreamOAuth2Provider', id: string } | { __typename: 'User', id: string } | { __typename: 'UserEmail', id: string } | { __typename: 'UserRecoveryTicket', id: string } | null }; + ) | { __typename: 'SiteConfig', id: string } | { __typename: 'UpstreamOAuth2Link', id: string } | { __typename: 'UpstreamOAuth2Provider', id: string } | { __typename: 'User', id: string } | { __typename: 'UserEmail', id: string } | { __typename: 'UserEmailAuthentication', id: string } | { __typename: 'UserRecoveryTicket', id: string } | null }; export type BrowserSessionListQueryVariables = Exact<{ first?: InputMaybe; @@ -1710,15 +1752,28 @@ export type DeviceRedirectQueryVariables = Exact<{ export type DeviceRedirectQuery = { __typename?: 'Query', session?: { __typename: 'CompatSession', id: string } | { __typename: 'Oauth2Session', id: string } | null }; +export type DoVerifyEmailMutationVariables = Exact<{ + id: Scalars['ID']['input']; + code: Scalars['String']['input']; +}>; + + +export type DoVerifyEmailMutation = { __typename?: 'Mutation', completeEmailAuthentication: { __typename?: 'CompleteEmailAuthenticationPayload', status: CompleteEmailAuthenticationStatus } }; + +export type ResendEmailAuthenticationCodeMutationVariables = Exact<{ + id: Scalars['ID']['input']; + language: Scalars['String']['input']; +}>; + + +export type ResendEmailAuthenticationCodeMutation = { __typename?: 'Mutation', resendEmailAuthenticationCode: { __typename?: 'ResendEmailAuthenticationCodePayload', status: ResendEmailAuthenticationCodeStatus } }; + export type VerifyEmailQueryVariables = Exact<{ id: Scalars['ID']['input']; }>; -export type VerifyEmailQuery = { __typename?: 'Query', userEmail?: ( - { __typename?: 'UserEmail' } - & { ' $fragmentRefs'?: { 'UserEmail_VerifyEmailFragment': UserEmail_VerifyEmailFragment } } - ) | null }; +export type VerifyEmailQuery = { __typename?: 'Query', userEmailAuthentication?: { __typename?: 'UserEmailAuthentication', id: string, email: string, completedAt?: string | null } | null }; export type ChangePasswordMutationVariables = Exact<{ userId: Scalars['ID']['input']; @@ -1977,12 +2032,6 @@ export const BrowserSessionsOverview_UserFragmentDoc = new TypedDocumentString(` } } `, {"fragmentName":"BrowserSessionsOverview_user"}) as unknown as TypedDocumentString; -export const UserEmail_VerifyEmailFragmentDoc = new TypedDocumentString(` - fragment UserEmail_verifyEmail on UserEmail { - id - email -} - `, {"fragmentName":"UserEmail_verifyEmail"}) as unknown as TypedDocumentString; export const RecoverPassword_UserRecoveryTicketFragmentDoc = new TypedDocumentString(` fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket { username @@ -2082,20 +2131,16 @@ export const SetDisplayNameDocument = new TypedDocumentString(` } `) as unknown as TypedDocumentString; export const AddEmailDocument = new TypedDocumentString(` - mutation AddEmail($userId: ID!, $email: String!) { - addEmail(input: {userId: $userId, email: $email}) { + mutation AddEmail($email: String!, $language: String!) { + startEmailAuthentication(input: {email: $email, language: $language}) { status violations - email { + authentication { id - ...UserEmail_email } } } - fragment UserEmail_email on UserEmail { - id - email -}`) as unknown as TypedDocumentString; + `) as unknown as TypedDocumentString; export const UserEmailListDocument = new TypedDocumentString(` query UserEmailList($first: Int, $after: String, $last: Int, $before: String) { viewer { @@ -2123,26 +2168,11 @@ export const UserEmailListDocument = new TypedDocumentString(` id email }`) as unknown as TypedDocumentString; -export const DoVerifyEmailDocument = new TypedDocumentString(` - mutation DoVerifyEmail($id: ID!, $code: String!) { - verifyEmail(input: {userEmailId: $id, code: $code}) { - status - } -} - `) as unknown as TypedDocumentString; -export const ResendVerificationEmailDocument = new TypedDocumentString(` - mutation ResendVerificationEmail($id: ID!) { - sendVerificationEmail(input: {userEmailId: $id}) { - status - } -} - `) as unknown as TypedDocumentString; export const UserProfileDocument = new TypedDocumentString(` query UserProfile { viewer { __typename ... on User { - id emails(first: 0) { totalCount } @@ -2434,16 +2464,29 @@ export const DeviceRedirectDocument = new TypedDocumentString(` } } `) as unknown as TypedDocumentString; +export const DoVerifyEmailDocument = new TypedDocumentString(` + mutation DoVerifyEmail($id: ID!, $code: String!) { + completeEmailAuthentication(input: {id: $id, code: $code}) { + status + } +} + `) as unknown as TypedDocumentString; +export const ResendEmailAuthenticationCodeDocument = new TypedDocumentString(` + mutation ResendEmailAuthenticationCode($id: ID!, $language: String!) { + resendEmailAuthenticationCode(input: {id: $id, language: $language}) { + status + } +} + `) as unknown as TypedDocumentString; export const VerifyEmailDocument = new TypedDocumentString(` query VerifyEmail($id: ID!) { - userEmail(id: $id) { - ...UserEmail_verifyEmail + userEmailAuthentication(id: $id) { + id + email + completedAt } } - fragment UserEmail_verifyEmail on UserEmail { - id - email -}`) as unknown as TypedDocumentString; + `) as unknown as TypedDocumentString; export const ChangePasswordDocument = new TypedDocumentString(` mutation ChangePassword($userId: ID!, $oldPassword: String!, $newPassword: String!) { setPassword( @@ -2653,9 +2696,9 @@ export const mockSetDisplayNameMutation = (resolver: GraphQLResponseResolver { - * const { userId, email } = variables; + * const { email, language } = variables; * return HttpResponse.json({ - * data: { addEmail } + * data: { startEmailAuthentication } * }) * }, * requestOptions @@ -2690,50 +2733,6 @@ export const mockUserEmailListQuery = (resolver: GraphQLResponseResolver { - * const { id, code } = variables; - * return HttpResponse.json({ - * data: { verifyEmail } - * }) - * }, - * requestOptions - * ) - */ -export const mockDoVerifyEmailMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => - graphql.mutation( - 'DoVerifyEmail', - 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)) - * @see https://mswjs.io/docs/basics/response-resolver - * @example - * mockResendVerificationEmailMutation( - * ({ query, variables }) => { - * const { id } = variables; - * return HttpResponse.json({ - * data: { sendVerificationEmail } - * }) - * }, - * requestOptions - * ) - */ -export const mockResendVerificationEmailMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => - graphql.mutation( - 'ResendVerificationEmail', - 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)) @@ -2928,6 +2927,50 @@ export const mockDeviceRedirectQuery = (resolver: GraphQLResponseResolver { + * const { id, code } = variables; + * return HttpResponse.json({ + * data: { completeEmailAuthentication } + * }) + * }, + * requestOptions + * ) + */ +export const mockDoVerifyEmailMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => + graphql.mutation( + 'DoVerifyEmail', + 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)) + * @see https://mswjs.io/docs/basics/response-resolver + * @example + * mockResendEmailAuthenticationCodeMutation( + * ({ query, variables }) => { + * const { id, language } = variables; + * return HttpResponse.json({ + * data: { resendEmailAuthenticationCode } + * }) + * }, + * requestOptions + * ) + */ +export const mockResendEmailAuthenticationCodeMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => + graphql.mutation( + 'ResendEmailAuthenticationCode', + 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)) @@ -2937,7 +2980,7 @@ export const mockDeviceRedirectQuery = (resolver: GraphQLResponseResolver { * const { id } = variables; * return HttpResponse.json({ - * data: { userEmail } + * data: { userEmailAuthentication } * }) * }, * requestOptions diff --git a/frontend/src/routes/_account.index.lazy.tsx b/frontend/src/routes/_account.index.lazy.tsx index 81792e216..f831f19d3 100644 --- a/frontend/src/routes/_account.index.lazy.tsx +++ b/frontend/src/routes/_account.index.lazy.tsx @@ -50,9 +50,7 @@ function Index(): React.ReactElement { > - {siteConfig.emailChangeAllowed && ( - - )} + {siteConfig.emailChangeAllowed && } diff --git a/frontend/src/routes/_account.index.tsx b/frontend/src/routes/_account.index.tsx index 6422b749b..9aac0815b 100644 --- a/frontend/src/routes/_account.index.tsx +++ b/frontend/src/routes/_account.index.tsx @@ -17,8 +17,6 @@ const QUERY = graphql(/* GraphQL */ ` viewer { __typename ... on User { - id - emails(first: 0) { totalCount } diff --git a/frontend/src/routes/emails.$id.verify.lazy.tsx b/frontend/src/routes/emails.$id.verify.lazy.tsx index 30d3525a5..2ed624088 100644 --- a/frontend/src/routes/emails.$id.verify.lazy.tsx +++ b/frontend/src/routes/emails.$id.verify.lazy.tsx @@ -1,17 +1,42 @@ -// 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 { 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 VerifyEmailComponent from "../components/VerifyEmail"; - +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, }); @@ -19,13 +44,133 @@ export const Route = createLazyFileRoute("/emails/$id/verify")({ function EmailVerify(): React.ReactElement { const { id } = Route.useParams(); const { - data: { userEmail }, + data: { userEmailAuthentication }, } = useSuspenseQuery(query(id)); - if (!userEmail) throw notFound(); + 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: "/" }); + } + }, + }); + + 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"; return ( - + }} + /> + } + /> + + + {emailSent && ( + + {t("frontend.verify_email.email_sent_alert.description")} + + )} + + {invalidCode && ( + + {t("frontend.verify_email.invalid_code_alert.description")} + + )} + + + {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 8ddbb19e3..d1d73121f 100644 --- a/frontend/src/routes/emails.$id.verify.tsx +++ b/frontend/src/routes/emails.$id.verify.tsx @@ -5,14 +5,16 @@ // Please see LICENSE in the repository root for full details. import { queryOptions } from "@tanstack/react-query"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, notFound, redirect } from "@tanstack/react-router"; import { graphql } from "../gql"; import { graphqlRequest } from "../graphql"; const QUERY = graphql(/* GraphQL */ ` query VerifyEmail($id: ID!) { - userEmail(id: $id) { - ...UserEmail_verifyEmail + userEmailAuthentication(id: $id) { + id + email + completedAt } } `); @@ -25,6 +27,14 @@ export const query = (id: string) => }); export const Route = createFileRoute("/emails/$id/verify")({ - loader: ({ context, params }) => - context.queryClient.ensureQueryData(query(params.id)), + async loader({ context, params }): Promise { + const data = await context.queryClient.ensureQueryData(query(params.id)); + if (!data.userEmailAuthentication) { + throw notFound(); + } + + if (data.userEmailAuthentication.completedAt) { + throw redirect({ to: "/" }); + } + }, }); diff --git a/frontend/stories/routes/index.stories.tsx b/frontend/stories/routes/index.stories.tsx index 4ddc0d746..9156ff0fa 100644 --- a/frontend/stories/routes/index.stories.tsx +++ b/frontend/stories/routes/index.stories.tsx @@ -45,7 +45,6 @@ const userProfileHandler = ({ data: { viewer: { __typename: "User", - id: "user-id", emails: { totalCount: emailTotalCount, }, diff --git a/frontend/tailwind.config.cjs b/frontend/tailwind.config.cjs index 5afbbca73..7e4840f00 100644 --- a/frontend/tailwind.config.cjs +++ b/frontend/tailwind.config.cjs @@ -14,6 +14,7 @@ module.exports = { theme: { colors: { white: "#FFFFFF", + primary: "var(--cpd-color-text-primary)", secondary: "var(--cpd-color-text-secondary)", critical: "var(--cpd-color-text-critical-primary)", alert: "#FF5B55", diff --git a/frontend/tests/mocks/handlers.ts b/frontend/tests/mocks/handlers.ts index a7b110c60..dcba3b5cc 100644 --- a/frontend/tests/mocks/handlers.ts +++ b/frontend/tests/mocks/handlers.ts @@ -93,7 +93,6 @@ export const handlers = [ data: { viewer: { __typename: "User", - id: "user-id", emails: { totalCount: 1, },