From f84ccdac3478e6f93965424a87ceacbabcda2ca9 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 9 Jan 2025 11:21:55 +0100 Subject: [PATCH 01/26] Fix the service worker not loading in dev mode --- biome.json | 2 +- frontend/.storybook/main.ts | 5 ++--- frontend/.storybook/preview.tsx | 3 +-- frontend/.storybook/{ => public}/mockServiceWorker.js | 0 frontend/package.json | 2 +- 5 files changed, 5 insertions(+), 7 deletions(-) rename frontend/.storybook/{ => public}/mockServiceWorker.js (100%) diff --git a/biome.json b/biome.json index f89818139..5b3843b4e 100644 --- a/biome.json +++ b/biome.json @@ -18,7 +18,7 @@ "frontend/src/gql/**", "frontend/src/routeTree.gen.ts", "frontend/.storybook/locales.ts", - "frontend/.storybook/mockServiceWorker.js", + "frontend/.storybook/public/mockServiceWorker.js", "frontend/locales/*.json", "**/coverage/**", "**/dist/**" diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index f09d9e6c3..73a06d8c2 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -57,9 +57,8 @@ const config: StorybookConfig = { }, viteFinal: async (config) => { - // Host all the assets in the root directory, - // so that the service worker is correctly scoped to the root - config.build.assetsDir = ""; + // Serve the storybook-specific assets, which has the service worker + config.publicDir = ".storybook/public"; return config; }, }; diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index b4b4a7a58..268849ffa 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -18,13 +18,12 @@ import i18n, { setupI18n } from "../src/i18n"; import { DummyRouter } from "../src/test-utils/router"; import { handlers } from "../tests/mocks/handlers"; import localazyMetadata from "./locales"; -import swUrl from "./mockServiceWorker.js?url"; initialize( { onUnhandledRequest: "bypass", serviceWorker: { - url: swUrl, + url: "./mockServiceWorker.js", }, }, handlers, diff --git a/frontend/.storybook/mockServiceWorker.js b/frontend/.storybook/public/mockServiceWorker.js similarity index 100% rename from frontend/.storybook/mockServiceWorker.js rename to frontend/.storybook/public/mockServiceWorker.js diff --git a/frontend/package.json b/frontend/package.json index 0b11b2bfc..24cc784b5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -88,7 +88,7 @@ "vitest": "^2.1.8" }, "msw": { - "workerDirectory": [".storybook"] + "workerDirectory": [".storybook/public"] }, "overrides": { "swagger-ui-react": { From 5d69b34c37e2dca16dfcb4d23d1d1a0510baebc4 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 9 Jan 2025 09:41:00 +0100 Subject: [PATCH 02/26] frontend: simplify email list --- frontend/locales/en.json | 13 +- .../UnverifiedEmailAlert.module.css | 10 - .../UnverifiedEmailAlert.test.tsx | 168 -------------- .../UnverifiedEmailAlert.tsx | 62 ------ .../UnverifiedEmailAlert.test.tsx.snap | 78 ------- .../components/UnverifiedEmailAlert/index.ts | 7 - .../src/components/UserEmail/UserEmail.tsx | 73 +----- .../components/UserProfile/UserEmailList.tsx | 106 ++++----- .../components/VerifyEmail/VerifyEmail.tsx | 26 +-- frontend/src/gql/gql.ts | 43 ++-- frontend/src/gql/graphql.ts | 210 ++++-------------- frontend/src/routes/_account.index.lazy.tsx | 51 ++--- frontend/src/routes/_account.index.tsx | 17 +- frontend/src/routes/_account.lazy.tsx | 3 - frontend/src/routes/_account.tsx | 3 +- frontend/tests/mocks/handlers.ts | 57 +++-- .../account/__snapshots__/index.test.tsx.snap | 120 ++++------ 17 files changed, 213 insertions(+), 834 deletions(-) delete mode 100644 frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.module.css delete mode 100644 frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.test.tsx delete mode 100644 frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.tsx delete mode 100644 frontend/src/components/UnverifiedEmailAlert/__snapshots__/UnverifiedEmailAlert.test.tsx.snap delete mode 100644 frontend/src/components/UnverifiedEmailAlert/index.ts diff --git a/frontend/locales/en.json b/frontend/locales/en.json index e139cb724..697123442 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -247,24 +247,13 @@ "title": "Cannot find session: {{deviceId}}" } }, - "unverified_email_alert": { - "button": "Review and verify", - "text:one": "You have {{count}} unverified email address.", - "text:other": "You have {{count}} unverified email addresses.", - "title": "Unverified email" - }, "user_email": { - "cant_delete_primary": "Choose a different primary email to delete this one.", "delete_button_confirmation_modal": { "action": "Delete email", "body": "Delete this email?" }, "delete_button_title": "Remove email address", - "email": "Email", - "make_primary_button": "Make primary", - "not_verified": "Not verified", - "primary_email": "Primary email", - "retry_button": "Resend code" + "email": "Email" }, "user_email_list": { "no_primary_email_alert": "No primary email address" diff --git a/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.module.css b/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.module.css deleted file mode 100644 index 89636451a..000000000 --- a/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.module.css +++ /dev/null @@ -1,10 +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. - */ - -.alert > * { - box-sizing: content-box; -} diff --git a/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.test.tsx b/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.test.tsx deleted file mode 100644 index cd65e2be2..000000000 --- a/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.test.tsx +++ /dev/null @@ -1,168 +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. - -// @vitest-environment happy-dom - -import { fireEvent, render } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; - -import { makeFragmentData } from "../../gql/fragment-masking"; -import { DummyRouter } from "../../test-utils/router"; - -import UnverifiedEmailAlert, { - UNVERIFIED_EMAILS_FRAGMENT, -} from "./UnverifiedEmailAlert"; - -describe("", () => { - it("does not render a warning when there are no unverified emails", () => { - const data = makeFragmentData( - { - id: "abc123", - unverifiedEmails: { - totalCount: 0, - }, - }, - UNVERIFIED_EMAILS_FRAGMENT, - ); - - const { container } = render( - - - , - ); - - expect(container).toMatchInlineSnapshot("
"); - }); - - it("renders a warning when there are unverified emails", () => { - const data = makeFragmentData( - { - id: "abc123", - unverifiedEmails: { - totalCount: 2, - }, - }, - UNVERIFIED_EMAILS_FRAGMENT, - ); - - const { container } = render( - - - , - ); - - expect(container).toMatchSnapshot(); - }); - - it("hides warning after it has been dismissed", () => { - const data = makeFragmentData( - { - id: "abc123", - unverifiedEmails: { - totalCount: 2, - }, - }, - UNVERIFIED_EMAILS_FRAGMENT, - ); - - const { container, getByText, getByLabelText } = render( - - - , - ); - - // warning is rendered - expect(getByText("Unverified email")).toBeTruthy(); - - fireEvent.click(getByLabelText("Close")); - - // no more warning - expect(container).toMatchInlineSnapshot("
"); - }); - - it("hides warning when count of unverified emails becomes 0", () => { - const data = makeFragmentData( - { - id: "abc123", - unverifiedEmails: { - totalCount: 2, - }, - }, - UNVERIFIED_EMAILS_FRAGMENT, - ); - - const { container, getByText, rerender } = render( - - - , - ); - - // warning is rendered - expect(getByText("Unverified email")).toBeTruthy(); - - const newData = makeFragmentData( - { - id: "abc123", - unverifiedEmails: { - totalCount: 0, - }, - }, - UNVERIFIED_EMAILS_FRAGMENT, - ); - rerender( - - - , - ); - - // warning removed - expect(container).toMatchInlineSnapshot("
"); - }); - - it("shows a dismissed warning again when there are new unverified emails", () => { - const data = makeFragmentData( - { - id: "abc123", - unverifiedEmails: { - totalCount: 2, - }, - }, - UNVERIFIED_EMAILS_FRAGMENT, - ); - - const { container, getByText, getByLabelText, rerender } = render( - - - , - ); - - // warning is rendered - expect(getByText("Unverified email")).toBeTruthy(); - - fireEvent.click(getByLabelText("Close")); - - // no more warning - expect(container).toMatchInlineSnapshot("
"); - - const newData = makeFragmentData( - { - id: "abc123", - unverifiedEmails: { - totalCount: 3, - }, - }, - UNVERIFIED_EMAILS_FRAGMENT, - ); - rerender( - - - , - ); - - // warning is rendered - expect(getByText("Unverified email")).toBeTruthy(); - }); -}); diff --git a/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.tsx b/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.tsx deleted file mode 100644 index 319b0f5da..000000000 --- a/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.tsx +++ /dev/null @@ -1,62 +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. - -import { Alert } from "@vector-im/compound-web"; -import { useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import { type FragmentType, graphql, useFragment } from "../../gql"; -import { Link } from "../Link"; - -import styles from "./UnverifiedEmailAlert.module.css"; - -export const UNVERIFIED_EMAILS_FRAGMENT = graphql(/* GraphQL */ ` - fragment UnverifiedEmailAlert_user on User { - unverifiedEmails: emails(first: 0, state: PENDING) { - totalCount - } - } -`); - -const UnverifiedEmailAlert: React.FC<{ - user: FragmentType; -}> = ({ user }) => { - const data = useFragment(UNVERIFIED_EMAILS_FRAGMENT, user); - const [dismiss, setDismiss] = useState(false); - const { t } = useTranslation(); - const currentCount = useRef(data.unverifiedEmails.totalCount); - - const doDismiss = (): void => setDismiss(true); - - useEffect(() => { - if (currentCount.current !== data.unverifiedEmails.totalCount) { - currentCount.current = data.unverifiedEmails.totalCount; - setDismiss(false); - } - }, [data]); - - if (!data.unverifiedEmails.totalCount || dismiss) { - return null; - } - - return ( - - {t("frontend.unverified_email_alert.text", { - count: data.unverifiedEmails.totalCount, - })}{" "} - - {t("frontend.unverified_email_alert.button")} - - - ); -}; - -export default UnverifiedEmailAlert; diff --git a/frontend/src/components/UnverifiedEmailAlert/__snapshots__/UnverifiedEmailAlert.test.tsx.snap b/frontend/src/components/UnverifiedEmailAlert/__snapshots__/UnverifiedEmailAlert.test.tsx.snap deleted file mode 100644 index 040c86a8c..000000000 --- a/frontend/src/components/UnverifiedEmailAlert/__snapshots__/UnverifiedEmailAlert.test.tsx.snap +++ /dev/null @@ -1,78 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[` > renders a warning when there are unverified emails 1`] = ` -
-
- -
-
-

- Unverified email -

-

- You have 2 unverified email addresses. - - - Review and verify - -

-
-
- -
-
-`; diff --git a/frontend/src/components/UnverifiedEmailAlert/index.ts b/frontend/src/components/UnverifiedEmailAlert/index.ts deleted file mode 100644 index 47176edfd..000000000 --- a/frontend/src/components/UnverifiedEmailAlert/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 "./UnverifiedEmailAlert"; diff --git a/frontend/src/components/UserEmail/UserEmail.tsx b/frontend/src/components/UserEmail/UserEmail.tsx index 88dedbb6d..02771412e 100644 --- a/frontend/src/components/UserEmail/UserEmail.tsx +++ b/frontend/src/components/UserEmail/UserEmail.tsx @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -13,7 +13,6 @@ import { Translation, useTranslation } from "react-i18next"; import { type FragmentType, graphql, useFragment } from "../../gql"; import { graphqlRequest } from "../../graphql"; import { Close, Description, Dialog, Title } from "../Dialog"; -import { Link } from "../Link"; import styles from "./UserEmail.module.css"; // This component shows a single user email address, with controls to verify it, @@ -23,7 +22,6 @@ export const FRAGMENT = graphql(/* GraphQL */ ` fragment UserEmail_email on UserEmail { id email - confirmedAt } `); @@ -45,20 +43,6 @@ const REMOVE_EMAIL_MUTATION = graphql(/* GraphQL */ ` } `); -const SET_PRIMARY_EMAIL_MUTATION = graphql(/* GraphQL */ ` - mutation SetPrimaryEmail($id: ID!) { - setPrimaryEmail(input: { userEmailId: $id }) { - status - user { - id - primaryEmail { - id - } - } - } - } -`); - const DeleteButton: React.FC<{ disabled?: boolean; onClick?: () => void }> = ({ disabled, onClick, @@ -123,24 +107,13 @@ const DeleteButtonWithConfirmation: React.FC< const UserEmail: React.FC<{ email: FragmentType; - siteConfig: FragmentType; + canRemove?: boolean; onRemove?: () => void; - isPrimary?: boolean; -}> = ({ email, siteConfig, isPrimary, onRemove }) => { +}> = ({ email, canRemove, onRemove }) => { const { t } = useTranslation(); const data = useFragment(FRAGMENT, email); - const { emailChangeAllowed } = useFragment(CONFIG_FRAGMENT, siteConfig); const queryClient = useQueryClient(); - const setPrimary = useMutation({ - mutationFn: (id: string) => - graphqlRequest({ query: SET_PRIMARY_EMAIL_MUTATION, variables: { id } }), - onSuccess: (_data) => { - queryClient.invalidateQueries({ queryKey: ["currentUserGreeting"] }); - queryClient.invalidateQueries({ queryKey: ["userEmails"] }); - }, - }); - const removeEmail = useMutation({ mutationFn: (id: string) => graphqlRequest({ query: REMOVE_EMAIL_MUTATION, variables: { id } }), @@ -155,18 +128,10 @@ const UserEmail: React.FC<{ removeEmail.mutate(data.id); }; - const onSetPrimaryClick = (): void => { - setPrimary.mutate(data.id); - }; - return ( - - {isPrimary - ? t("frontend.user_email.primary_email") - : t("frontend.user_email.email")} - + {t("frontend.user_email.email")}
- {!isPrimary && emailChangeAllowed && ( + {canRemove && ( )}
- - {isPrimary && emailChangeAllowed && ( - - {t("frontend.user_email.cant_delete_primary")} - - )} - - {data.confirmedAt && !isPrimary && emailChangeAllowed && ( - - - - )} - - {!data.confirmedAt && ( - - {t("frontend.user_email.not_verified")} |{" "} - - {t("frontend.user_email.retry_button")} - - - )}
); diff --git a/frontend/src/components/UserProfile/UserEmailList.tsx b/frontend/src/components/UserProfile/UserEmailList.tsx index 9af5b0fb0..6db4adcf3 100644 --- a/frontend/src/components/UserProfile/UserEmailList.tsx +++ b/frontend/src/components/UserProfile/UserEmailList.tsx @@ -1,10 +1,11 @@ -// Copyright 2024 New Vector Ltd. +// 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 { useSuspenseQuery } from "@tanstack/react-query"; +import { queryOptions, useSuspenseQuery } from "@tanstack/react-query"; +import { notFound } from "@tanstack/react-router"; import { useTransition } from "react"; import { type FragmentType, graphql, useFragment } from "../../gql"; import { graphqlRequest } from "../../graphql"; @@ -20,78 +21,64 @@ import UserEmail from "../UserEmail"; const QUERY = graphql(/* GraphQL */ ` query UserEmailList( - $userId: ID! $first: Int $after: String $last: Int $before: String ) { - user(id: $userId) { - id - - emails(first: $first, after: $after, last: $last, before: $before) { - edges { - cursor - node { - id - ...UserEmail_email + viewer { + __typename + ... on User { + emails(first: $first, after: $after, last: $last, before: $before) { + edges { + cursor + node { + ...UserEmail_email + } + } + totalCount + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor } - } - totalCount - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor } } } } `); -const FRAGMENT = graphql(/* GraphQL */ ` - fragment UserEmailList_user on User { - id - primaryEmail { - id - } - } -`); +export const query = (pagination: AnyPagination = { first: 6 }) => + queryOptions({ + queryKey: ["userEmails", pagination], + queryFn: ({ signal }) => + graphqlRequest({ + query: QUERY, + variables: pagination, + signal, + }), + }); export const CONFIG_FRAGMENT = graphql(/* GraphQL */ ` fragment UserEmailList_siteConfig on SiteConfig { - ...UserEmail_siteConfig + emailChangeAllowed } `); const UserEmailList: React.FC<{ - user: FragmentType; siteConfig: FragmentType; -}> = ({ user, siteConfig }) => { - const data = useFragment(FRAGMENT, user); - const config = useFragment(CONFIG_FRAGMENT, siteConfig); +}> = ({ siteConfig }) => { + const { emailChangeAllowed } = useFragment(CONFIG_FRAGMENT, siteConfig); const [pending, startTransition] = useTransition(); const [pagination, setPagination] = usePagination(); - const result = useSuspenseQuery({ - queryKey: ["userEmails", pagination], - queryFn: ({ signal }) => - graphqlRequest({ - query: QUERY, - variables: { - userId: data.id, - ...(pagination as AnyPagination), - }, - signal, - }), - }); - const emails = result.data.user?.emails; - if (!emails) throw new Error(); + const result = useSuspenseQuery(query(pagination)); + if (result.data.viewer.__typename !== "User") throw notFound(); + const emails = result.data.viewer.emails; const [prevPage, nextPage] = usePages(pagination, emails.pageInfo); - const primaryEmailId = data.primaryEmail?.id; - const paginate = (pagination: Pagination): void => { startTransition(() => { setPagination(pagination); @@ -105,22 +92,23 @@ const UserEmailList: React.FC<{ }); }; + // Is it allowed to remove an email? If there's only one, we can't + const canRemove = emailChangeAllowed && emails.totalCount > 1; + return ( <> - {emails.edges.map((edge) => - primaryEmailId === edge.node.id ? null : ( - - ), - )} + {emails.edges.map((edge) => ( + + ))} paginate(prevPage) : null} onNext={nextPage ? (): void => paginate(nextPage) : null} disabled={pending} diff --git a/frontend/src/components/VerifyEmail/VerifyEmail.tsx b/frontend/src/components/VerifyEmail/VerifyEmail.tsx index c7c696b92..42ee6bc93 100644 --- a/frontend/src/components/VerifyEmail/VerifyEmail.tsx +++ b/frontend/src/components/VerifyEmail/VerifyEmail.tsx @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -26,18 +26,6 @@ const VERIFY_EMAIL_MUTATION = graphql(/* GraphQL */ ` mutation DoVerifyEmail($id: ID!, $code: String!) { verifyEmail(input: { userEmailId: $id, code: $code }) { status - - user { - id - primaryEmail { - id - } - } - - email { - id - ...UserEmail_email - } } } `); @@ -46,18 +34,6 @@ const RESEND_VERIFICATION_EMAIL_MUTATION = graphql(/* GraphQL */ ` mutation ResendVerificationEmail($id: ID!) { sendVerificationEmail(input: { userEmailId: $id }) { status - - user { - id - primaryEmail { - id - } - } - - email { - id - ...UserEmail_email - } } } `); diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index d6334121d..89d6d8e47 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -29,28 +29,25 @@ const documents = { "\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent {\n name\n model\n os\n }\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n": types.BrowserSession_DetailFragmentDoc, "\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n userAgent {\n name\n os\n model\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n": types.CompatSession_DetailFragmentDoc, "\n fragment OAuth2Session_detail on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n client {\n id\n clientId\n clientName\n clientUri\n logoUri\n }\n }\n": types.OAuth2Session_DetailFragmentDoc, - "\n fragment UnverifiedEmailAlert_user on User {\n unverifiedEmails: emails(first: 0, state: PENDING) {\n totalCount\n }\n }\n": types.UnverifiedEmailAlert_UserFragmentDoc, - "\n fragment UserEmail_email on UserEmail {\n id\n email\n confirmedAt\n }\n": types.UserEmail_EmailFragmentDoc, + "\n fragment UserEmail_email on UserEmail {\n id\n email\n }\n": types.UserEmail_EmailFragmentDoc, "\n fragment UserEmail_siteConfig on SiteConfig {\n emailChangeAllowed\n }\n": types.UserEmail_SiteConfigFragmentDoc, "\n mutation RemoveEmail($id: ID!) {\n removeEmail(input: { userEmailId: $id }) {\n status\n\n user {\n id\n }\n }\n }\n": types.RemoveEmailDocument, - "\n mutation SetPrimaryEmail($id: ID!) {\n setPrimaryEmail(input: { userEmailId: $id }) {\n status\n user {\n id\n primaryEmail {\n id\n }\n }\n }\n }\n": types.SetPrimaryEmailDocument, "\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 query UserEmailList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n\n emails(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n id\n ...UserEmail_email\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n": types.UserEmailListDocument, - "\n fragment UserEmailList_user on User {\n id\n primaryEmail {\n id\n }\n }\n": types.UserEmailList_UserFragmentDoc, - "\n fragment UserEmailList_siteConfig on SiteConfig {\n ...UserEmail_siteConfig\n }\n": types.UserEmailList_SiteConfigFragmentDoc, + "\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 user {\n id\n primaryEmail {\n id\n }\n }\n\n email {\n id\n ...UserEmail_email\n }\n }\n }\n": types.DoVerifyEmailDocument, - "\n mutation ResendVerificationEmail($id: ID!) {\n sendVerificationEmail(input: { userEmailId: $id }) {\n status\n\n user {\n id\n primaryEmail {\n id\n }\n }\n\n email {\n id\n ...UserEmail_email\n }\n }\n }\n": types.ResendVerificationEmailDocument, - "\n query UserProfile {\n viewer {\n __typename\n ... on User {\n id\n primaryEmail {\n id\n ...UserEmail_email\n }\n\n ...UserEmailList_user\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n ...UserEmailList_siteConfig\n ...UserEmail_siteConfig\n ...PasswordChange_siteConfig\n }\n }\n": types.UserProfileDocument, + "\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 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, "\n query AppSessionsList(\n $before: String\n $after: String\n $first: Int\n $last: Int\n $lastActive: DateFilter\n ) {\n viewer {\n __typename\n\n ... on User {\n id\n appSessions(\n before: $before\n after: $after\n first: $first\n last: $last\n lastActive: $lastActive\n state: ACTIVE\n ) {\n edges {\n cursor\n node {\n __typename\n ...CompatSession_session\n ...OAuth2Session_session\n }\n }\n\n totalCount\n pageInfo {\n startCursor\n endCursor\n hasNextPage\n hasPreviousPage\n }\n }\n }\n }\n }\n": types.AppSessionsListDocument, - "\n query CurrentUserGreeting {\n viewerSession {\n __typename\n\n ... on BrowserSession {\n id\n\n user {\n ...UnverifiedEmailAlert_user\n ...UserGreeting_user\n }\n }\n }\n\n siteConfig {\n ...UserGreeting_siteConfig\n }\n }\n": types.CurrentUserGreetingDocument, + "\n query CurrentUserGreeting {\n viewerSession {\n __typename\n\n ... on BrowserSession {\n id\n\n user {\n ...UserGreeting_user\n }\n }\n }\n\n siteConfig {\n ...UserGreeting_siteConfig\n }\n }\n": types.CurrentUserGreetingDocument, "\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, @@ -124,11 +121,7 @@ export function graphql(source: "\n fragment OAuth2Session_detail on Oauth2Sess /** * 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 UnverifiedEmailAlert_user on User {\n unverifiedEmails: emails(first: 0, state: PENDING) {\n totalCount\n }\n }\n"): typeof import('./graphql').UnverifiedEmailAlert_UserFragmentDoc; -/** - * 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_email on UserEmail {\n id\n email\n confirmedAt\n }\n"): typeof import('./graphql').UserEmail_EmailFragmentDoc; +export function graphql(source: "\n fragment UserEmail_email on UserEmail {\n id\n email\n }\n"): typeof import('./graphql').UserEmail_EmailFragmentDoc; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -137,10 +130,6 @@ export function graphql(source: "\n fragment UserEmail_siteConfig on SiteConfig * 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 RemoveEmail($id: ID!) {\n removeEmail(input: { userEmailId: $id }) {\n status\n\n user {\n id\n }\n }\n }\n"): typeof import('./graphql').RemoveEmailDocument; -/** - * 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 SetPrimaryEmail($id: ID!) {\n setPrimaryEmail(input: { userEmailId: $id }) {\n status\n user {\n id\n primaryEmail {\n id\n }\n }\n }\n }\n"): typeof import('./graphql').SetPrimaryEmailDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -160,15 +149,11 @@ export function graphql(source: "\n mutation AddEmail($userId: ID!, $email: Str /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n query UserEmailList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n\n emails(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n id\n ...UserEmail_email\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n"): typeof import('./graphql').UserEmailListDocument; -/** - * 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 UserEmailList_user on User {\n id\n primaryEmail {\n id\n }\n }\n"): typeof import('./graphql').UserEmailList_UserFragmentDoc; +export function graphql(source: "\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"): typeof import('./graphql').UserEmailListDocument; /** * 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 UserEmailList_siteConfig on SiteConfig {\n ...UserEmail_siteConfig\n }\n"): typeof import('./graphql').UserEmailList_SiteConfigFragmentDoc; +export function graphql(source: "\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n }\n"): typeof import('./graphql').UserEmailList_SiteConfigFragmentDoc; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -180,15 +165,15 @@ export function graphql(source: "\n fragment UserEmail_verifyEmail on UserEmail /** * 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 user {\n id\n primaryEmail {\n id\n }\n }\n\n email {\n id\n ...UserEmail_email\n }\n }\n }\n"): typeof import('./graphql').DoVerifyEmailDocument; +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 user {\n id\n primaryEmail {\n id\n }\n }\n\n email {\n id\n ...UserEmail_email\n }\n }\n }\n"): typeof import('./graphql').ResendVerificationEmailDocument; +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 primaryEmail {\n id\n ...UserEmail_email\n }\n\n ...UserEmailList_user\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 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; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -208,7 +193,7 @@ export function graphql(source: "\n query AppSessionsList(\n $before: String /** * 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 CurrentUserGreeting {\n viewerSession {\n __typename\n\n ... on BrowserSession {\n id\n\n user {\n ...UnverifiedEmailAlert_user\n ...UserGreeting_user\n }\n }\n }\n\n siteConfig {\n ...UserGreeting_siteConfig\n }\n }\n"): typeof import('./graphql').CurrentUserGreetingDocument; +export function graphql(source: "\n query CurrentUserGreeting {\n viewerSession {\n __typename\n\n ... on BrowserSession {\n id\n\n user {\n ...UserGreeting_user\n }\n }\n }\n\n siteConfig {\n ...UserGreeting_siteConfig\n }\n }\n"): typeof import('./graphql').CurrentUserGreetingDocument; /** * 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 3980ea976..7a67fafc6 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -1551,9 +1551,7 @@ export type CompatSession_DetailFragment = { __typename?: 'CompatSession', id: s export type OAuth2Session_DetailFragment = { __typename?: 'Oauth2Session', id: string, scope: string, createdAt: string, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, client: { __typename?: 'Oauth2Client', id: string, clientId: string, clientName?: string | null, clientUri?: string | null, logoUri?: string | null } } & { ' $fragmentName'?: 'OAuth2Session_DetailFragment' }; -export type UnverifiedEmailAlert_UserFragment = { __typename?: 'User', unverifiedEmails: { __typename?: 'UserEmailConnection', totalCount: number } } & { ' $fragmentName'?: 'UnverifiedEmailAlert_UserFragment' }; - -export type UserEmail_EmailFragment = { __typename?: 'UserEmail', id: string, email: string, confirmedAt?: string | null } & { ' $fragmentName'?: 'UserEmail_EmailFragment' }; +export type UserEmail_EmailFragment = { __typename?: 'UserEmail', id: string, email: string } & { ' $fragmentName'?: 'UserEmail_EmailFragment' }; export type UserEmail_SiteConfigFragment = { __typename?: 'SiteConfig', emailChangeAllowed: boolean } & { ' $fragmentName'?: 'UserEmail_SiteConfigFragment' }; @@ -1564,13 +1562,6 @@ export type RemoveEmailMutationVariables = Exact<{ export type RemoveEmailMutation = { __typename?: 'Mutation', removeEmail: { __typename?: 'RemoveEmailPayload', status: RemoveEmailStatus, user?: { __typename?: 'User', id: string } | null } }; -export type SetPrimaryEmailMutationVariables = Exact<{ - id: Scalars['ID']['input']; -}>; - - -export type SetPrimaryEmailMutation = { __typename?: 'Mutation', setPrimaryEmail: { __typename?: 'SetPrimaryEmailPayload', status: SetPrimaryEmailStatus, user?: { __typename?: 'User', id: string, primaryEmail?: { __typename?: 'UserEmail', id: string } | null } | null } }; - export type UserGreeting_UserFragment = { __typename?: 'User', id: string, matrix: { __typename?: 'MatrixUser', mxid: string, displayName?: string | null } } & { ' $fragmentName'?: 'UserGreeting_UserFragment' }; export type UserGreeting_SiteConfigFragment = { __typename?: 'SiteConfig', displayNameChangeAllowed: boolean } & { ' $fragmentName'?: 'UserGreeting_SiteConfigFragment' }; @@ -1595,7 +1586,6 @@ export type AddEmailMutation = { __typename?: 'Mutation', addEmail: { __typename ) | null } }; export type UserEmailListQueryVariables = Exact<{ - userId: Scalars['ID']['input']; first?: InputMaybe; after?: InputMaybe; last?: InputMaybe; @@ -1603,17 +1593,12 @@ export type UserEmailListQueryVariables = Exact<{ }>; -export type UserEmailListQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, emails: { __typename?: 'UserEmailConnection', totalCount: number, edges: Array<{ __typename?: 'UserEmailEdge', cursor: string, node: ( - { __typename?: 'UserEmail', id: string } +export type UserEmailListQuery = { __typename?: 'Query', viewer: { __typename: 'Anonymous' } | { __typename: 'User', emails: { __typename?: 'UserEmailConnection', totalCount: number, edges: Array<{ __typename?: 'UserEmailEdge', cursor: string, node: ( + { __typename?: 'UserEmail' } & { ' $fragmentRefs'?: { 'UserEmail_EmailFragment': UserEmail_EmailFragment } } - ) }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } | null }; + ) }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } }; -export type UserEmailList_UserFragment = { __typename?: 'User', id: string, primaryEmail?: { __typename?: 'UserEmail', id: string } | null } & { ' $fragmentName'?: 'UserEmailList_UserFragment' }; - -export type UserEmailList_SiteConfigFragment = ( - { __typename?: 'SiteConfig' } - & { ' $fragmentRefs'?: { 'UserEmail_SiteConfigFragment': UserEmail_SiteConfigFragment } } -) & { ' $fragmentName'?: 'UserEmailList_SiteConfigFragment' }; +export type UserEmailList_SiteConfigFragment = { __typename?: 'SiteConfig', emailChangeAllowed: boolean } & { ' $fragmentName'?: 'UserEmailList_SiteConfigFragment' }; export type BrowserSessionsOverview_UserFragment = { __typename?: 'User', id: string, browserSessions: { __typename?: 'BrowserSessionConnection', totalCount: number } } & { ' $fragmentName'?: 'BrowserSessionsOverview_UserFragment' }; @@ -1625,31 +1610,19 @@ export type DoVerifyEmailMutationVariables = Exact<{ }>; -export type DoVerifyEmailMutation = { __typename?: 'Mutation', verifyEmail: { __typename?: 'VerifyEmailPayload', status: VerifyEmailStatus, user?: { __typename?: 'User', id: string, primaryEmail?: { __typename?: 'UserEmail', id: string } | null } | null, email?: ( - { __typename?: 'UserEmail', id: string } - & { ' $fragmentRefs'?: { 'UserEmail_EmailFragment': UserEmail_EmailFragment } } - ) | null } }; +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, user: { __typename?: 'User', id: string, primaryEmail?: { __typename?: 'UserEmail', id: string } | null }, email: ( - { __typename?: 'UserEmail', id: string } - & { ' $fragmentRefs'?: { 'UserEmail_EmailFragment': UserEmail_EmailFragment } } - ) } }; +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, primaryEmail?: ( - { __typename?: 'UserEmail', id: string } - & { ' $fragmentRefs'?: { 'UserEmail_EmailFragment': UserEmail_EmailFragment } } - ) | null } - & { ' $fragmentRefs'?: { 'UserEmailList_UserFragment': UserEmailList_UserFragment } } - ), siteConfig: ( +export type UserProfileQuery = { __typename?: 'Query', viewer: { __typename: 'Anonymous' } | { __typename: 'User', id: string, 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 } } ) }; @@ -1714,7 +1687,7 @@ export type CurrentUserGreetingQueryVariables = Exact<{ [key: string]: never; }> export type CurrentUserGreetingQuery = { __typename?: 'Query', viewerSession: { __typename: 'Anonymous' } | { __typename: 'BrowserSession', id: string, user: ( { __typename?: 'User' } - & { ' $fragmentRefs'?: { 'UnverifiedEmailAlert_UserFragment': UnverifiedEmailAlert_UserFragment;'UserGreeting_UserFragment': UserGreeting_UserFragment } } + & { ' $fragmentRefs'?: { 'UserGreeting_UserFragment': UserGreeting_UserFragment } } ) } | { __typename: 'Oauth2Session' }, siteConfig: ( { __typename?: 'SiteConfig' } & { ' $fragmentRefs'?: { 'UserGreeting_SiteConfigFragment': UserGreeting_SiteConfigFragment } } @@ -1972,20 +1945,17 @@ export const OAuth2Session_DetailFragmentDoc = new TypedDocumentString(` } } `, {"fragmentName":"OAuth2Session_detail"}) as unknown as TypedDocumentString; -export const UnverifiedEmailAlert_UserFragmentDoc = new TypedDocumentString(` - fragment UnverifiedEmailAlert_user on User { - unverifiedEmails: emails(first: 0, state: PENDING) { - totalCount - } -} - `, {"fragmentName":"UnverifiedEmailAlert_user"}) as unknown as TypedDocumentString; export const UserEmail_EmailFragmentDoc = new TypedDocumentString(` fragment UserEmail_email on UserEmail { id email - confirmedAt } `, {"fragmentName":"UserEmail_email"}) as unknown as TypedDocumentString; +export const UserEmail_SiteConfigFragmentDoc = new TypedDocumentString(` + fragment UserEmail_siteConfig on SiteConfig { + emailChangeAllowed +} + `, {"fragmentName":"UserEmail_siteConfig"}) as unknown as TypedDocumentString; export const UserGreeting_UserFragmentDoc = new TypedDocumentString(` fragment UserGreeting_user on User { id @@ -2000,26 +1970,11 @@ export const UserGreeting_SiteConfigFragmentDoc = new TypedDocumentString(` displayNameChangeAllowed } `, {"fragmentName":"UserGreeting_siteConfig"}) as unknown as TypedDocumentString; -export const UserEmailList_UserFragmentDoc = new TypedDocumentString(` - fragment UserEmailList_user on User { - id - primaryEmail { - id - } -} - `, {"fragmentName":"UserEmailList_user"}) as unknown as TypedDocumentString; -export const UserEmail_SiteConfigFragmentDoc = new TypedDocumentString(` - fragment UserEmail_siteConfig on SiteConfig { - emailChangeAllowed -} - `, {"fragmentName":"UserEmail_siteConfig"}) as unknown as TypedDocumentString; export const UserEmailList_SiteConfigFragmentDoc = new TypedDocumentString(` fragment UserEmailList_siteConfig on SiteConfig { - ...UserEmail_siteConfig -} - fragment UserEmail_siteConfig on SiteConfig { emailChangeAllowed -}`, {"fragmentName":"UserEmailList_siteConfig"}) as unknown as TypedDocumentString; +} + `, {"fragmentName":"UserEmailList_siteConfig"}) as unknown as TypedDocumentString; export const BrowserSessionsOverview_UserFragmentDoc = new TypedDocumentString(` fragment BrowserSessionsOverview_user on User { id @@ -2125,19 +2080,6 @@ export const RemoveEmailDocument = new TypedDocumentString(` } } `) as unknown as TypedDocumentString; -export const SetPrimaryEmailDocument = new TypedDocumentString(` - mutation SetPrimaryEmail($id: ID!) { - setPrimaryEmail(input: {userEmailId: $id}) { - status - user { - id - primaryEmail { - id - } - } - } -} - `) as unknown as TypedDocumentString; export const SetDisplayNameDocument = new TypedDocumentString(` mutation SetDisplayName($userId: ID!, $displayName: String) { setDisplayName(input: {userId: $userId, displayName: $displayName}) { @@ -2159,26 +2101,26 @@ export const AddEmailDocument = new TypedDocumentString(` fragment UserEmail_email on UserEmail { id email - confirmedAt }`) as unknown as TypedDocumentString; export const UserEmailListDocument = new TypedDocumentString(` - query UserEmailList($userId: ID!, $first: Int, $after: String, $last: Int, $before: String) { - user(id: $userId) { - id - emails(first: $first, after: $after, last: $last, before: $before) { - edges { - cursor - node { - id - ...UserEmail_email + query UserEmailList($first: Int, $after: String, $last: Int, $before: String) { + viewer { + __typename + ... on User { + emails(first: $first, after: $after, last: $last, before: $before) { + edges { + cursor + node { + ...UserEmail_email + } + } + totalCount + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor } - } - totalCount - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor } } } @@ -2186,61 +2128,30 @@ export const UserEmailListDocument = new TypedDocumentString(` fragment UserEmail_email on UserEmail { id email - confirmedAt }`) as unknown as TypedDocumentString; export const DoVerifyEmailDocument = new TypedDocumentString(` mutation DoVerifyEmail($id: ID!, $code: String!) { verifyEmail(input: {userEmailId: $id, code: $code}) { status - user { - id - primaryEmail { - id - } - } - email { - id - ...UserEmail_email - } } } - fragment UserEmail_email on UserEmail { - id - email - confirmedAt -}`) as unknown as TypedDocumentString; + `) as unknown as TypedDocumentString; export const ResendVerificationEmailDocument = new TypedDocumentString(` mutation ResendVerificationEmail($id: ID!) { sendVerificationEmail(input: {userEmailId: $id}) { status - user { - id - primaryEmail { - id - } - } - email { - id - ...UserEmail_email - } } } - fragment UserEmail_email on UserEmail { - id - email - confirmedAt -}`) as unknown as TypedDocumentString; + `) as unknown as TypedDocumentString; export const UserProfileDocument = new TypedDocumentString(` query UserProfile { viewer { __typename ... on User { id - primaryEmail { - id - ...UserEmail_email + emails(first: 0) { + totalCount } - ...UserEmailList_user } } siteConfig { @@ -2254,22 +2165,11 @@ export const UserProfileDocument = new TypedDocumentString(` fragment PasswordChange_siteConfig on SiteConfig { passwordChangeAllowed } -fragment UserEmail_email on UserEmail { - id - email - confirmedAt -} fragment UserEmail_siteConfig on SiteConfig { emailChangeAllowed } -fragment UserEmailList_user on User { - id - primaryEmail { - id - } -} fragment UserEmailList_siteConfig on SiteConfig { - ...UserEmail_siteConfig + emailChangeAllowed }`) as unknown as TypedDocumentString; export const SessionDetailDocument = new TypedDocumentString(` query SessionDetail($id: ID!) { @@ -2486,7 +2386,6 @@ export const CurrentUserGreetingDocument = new TypedDocumentString(` ... on BrowserSession { id user { - ...UnverifiedEmailAlert_user ...UserGreeting_user } } @@ -2495,12 +2394,7 @@ export const CurrentUserGreetingDocument = new TypedDocumentString(` ...UserGreeting_siteConfig } } - fragment UnverifiedEmailAlert_user on User { - unverifiedEmails: emails(first: 0, state: PENDING) { - totalCount - } -} -fragment UserGreeting_user on User { + fragment UserGreeting_user on User { id matrix { mxid @@ -2736,28 +2630,6 @@ export const mockRemoveEmailMutation = (resolver: GraphQLResponseResolver { - * const { id } = variables; - * return HttpResponse.json({ - * data: { setPrimaryEmail } - * }) - * }, - * requestOptions - * ) - */ -export const mockSetPrimaryEmailMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => - graphql.mutation( - 'SetPrimaryEmail', - 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)) @@ -2809,9 +2681,9 @@ export const mockAddEmailMutation = (resolver: GraphQLResponseResolver { - * const { userId, first, after, last, before } = variables; + * const { first, after, last, before } = variables; * return HttpResponse.json({ - * data: { user } + * data: { viewer } * }) * }, * requestOptions diff --git a/frontend/src/routes/_account.index.lazy.tsx b/frontend/src/routes/_account.index.lazy.tsx index fe63e0906..81792e216 100644 --- a/frontend/src/routes/_account.index.lazy.tsx +++ b/frontend/src/routes/_account.index.lazy.tsx @@ -1,4 +1,4 @@ -// 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 @@ -10,15 +10,12 @@ import { notFound, useNavigate, } from "@tanstack/react-router"; -import { Alert, Separator, Text } from "@vector-im/compound-web"; -import { Suspense } from "react"; +import { Separator, Text } from "@vector-im/compound-web"; import { useTranslation } from "react-i18next"; import AccountManagementPasswordPreview from "../components/AccountManagementPasswordPreview"; import { ButtonLink } from "../components/ButtonLink"; import * as Collapsible from "../components/Collapsible"; -import LoadingSpinner from "../components/LoadingSpinner"; -import UserEmail from "../components/UserEmail"; import AddEmailForm from "../components/UserProfile/AddEmailForm"; import UserEmailList from "../components/UserProfile/UserEmailList"; @@ -43,46 +40,38 @@ function Index(): React.ReactElement { return (
- - {viewer.primaryEmail ? ( - - ) : ( - - )} + {/* Only display this section if the user can add email addresses to their + account *or* if they have any existing email addresses */} + {(siteConfig.emailChangeAllowed || viewer.emails.totalCount > 0) && ( + <> + + - }> - - + {siteConfig.emailChangeAllowed && ( + + )} + - {siteConfig.emailChangeAllowed && ( - - )} - + + + )} {siteConfig.passwordLoginEnabled && ( <> - + + )} - - {t("frontend.reset_cross_signing.description")} diff --git a/frontend/src/routes/_account.index.tsx b/frontend/src/routes/_account.index.tsx index 2addf320b..6422b749b 100644 --- a/frontend/src/routes/_account.index.tsx +++ b/frontend/src/routes/_account.index.tsx @@ -1,4 +1,4 @@ -// 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 @@ -8,6 +8,7 @@ import { queryOptions } from "@tanstack/react-query"; import { createFileRoute, redirect } from "@tanstack/react-router"; import { zodSearchValidator } from "@tanstack/router-zod-adapter"; import * as z from "zod"; +import { query as userEmailListQuery } from "../components/UserProfile/UserEmailList"; import { graphql } from "../gql"; import { graphqlRequest } from "../graphql"; @@ -17,12 +18,10 @@ const QUERY = graphql(/* GraphQL */ ` __typename ... on User { id - primaryEmail { - id - ...UserEmail_email - } - ...UserEmailList_user + emails(first: 0) { + totalCount + } } } @@ -105,5 +104,9 @@ export const Route = createFileRoute("/_account/")({ } }, - loader: ({ context }) => context.queryClient.ensureQueryData(query), + loader: ({ context }) => + Promise.all([ + context.queryClient.ensureQueryData(userEmailListQuery()), + context.queryClient.ensureQueryData(query), + ]), }); diff --git a/frontend/src/routes/_account.lazy.tsx b/frontend/src/routes/_account.lazy.tsx index d5390874d..3acf066fd 100644 --- a/frontend/src/routes/_account.lazy.tsx +++ b/frontend/src/routes/_account.lazy.tsx @@ -13,7 +13,6 @@ import Layout from "../components/Layout"; import NavBar from "../components/NavBar"; import NavItem from "../components/NavItem"; import EndSessionButton from "../components/Session/EndSessionButton"; -import UnverifiedEmailAlert from "../components/UnverifiedEmailAlert"; import UserGreeting from "../components/UserGreeting"; import { useSuspenseQuery } from "@tanstack/react-query"; @@ -45,8 +44,6 @@ function Account(): React.ReactElement {
- - {t("frontend.nav.settings")} {t("frontend.nav.devices")} diff --git a/frontend/src/routes/_account.tsx b/frontend/src/routes/_account.tsx index 11121bef9..e61b98aaa 100644 --- a/frontend/src/routes/_account.tsx +++ b/frontend/src/routes/_account.tsx @@ -1,4 +1,4 @@ -// 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 @@ -18,7 +18,6 @@ const QUERY = graphql(/* GraphQL */ ` id user { - ...UnverifiedEmailAlert_user ...UserGreeting_user } } diff --git a/frontend/tests/mocks/handlers.ts b/frontend/tests/mocks/handlers.ts index 35ba2407d..a7b110c60 100644 --- a/frontend/tests/mocks/handlers.ts +++ b/frontend/tests/mocks/handlers.ts @@ -1,7 +1,11 @@ +// Copyright 2024, 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + import { HttpResponse } from "msw"; import { CONFIG_FRAGMENT as PASSWORD_CHANGE_CONFIG_FRAGMENT } from "../../src/components/AccountManagementPasswordPreview/AccountManagementPasswordPreview"; import { FRAGMENT as FOOTER_FRAGMENT } from "../../src/components/Footer/Footer"; -import { UNVERIFIED_EMAILS_FRAGMENT } from "../../src/components/UnverifiedEmailAlert/UnverifiedEmailAlert"; import { CONFIG_FRAGMENT as USER_EMAIL_CONFIG_FRAGMENT, FRAGMENT as USER_EMAIL_FRAGMENT, @@ -71,15 +75,6 @@ export const handlers = [ }, USER_GREETING_FRAGMENT, ), - - makeFragmentData( - { - unverifiedEmails: { - totalCount: 0, - }, - }, - UNVERIFIED_EMAILS_FRAGMENT, - ), ), }, @@ -99,16 +94,8 @@ export const handlers = [ viewer: { __typename: "User", id: "user-id", - primaryEmail: { - id: "primary-email-id", - ...makeFragmentData( - { - id: "primary-email-id", - email: "alice@example.com", - confirmedAt: new Date().toISOString(), - }, - USER_EMAIL_FRAGMENT, - ), + emails: { + totalCount: 1, }, }, @@ -124,12 +111,9 @@ export const handlers = [ USER_EMAIL_CONFIG_FRAGMENT, ), makeFragmentData( - makeFragmentData( - { - emailChangeAllowed: true, - }, - USER_EMAIL_CONFIG_FRAGMENT, - ), + { + emailChangeAllowed: true, + }, USER_EMAIL_LIST_CONFIG_FRAGMENT, ), makeFragmentData( @@ -146,11 +130,24 @@ export const handlers = [ mockUserEmailListQuery(() => HttpResponse.json({ data: { - user: { - id: "user-id", + viewer: { + __typename: "User", emails: { - edges: [], - totalCount: 0, + edges: [ + { + cursor: "primary-email-id", + node: { + ...makeFragmentData( + { + id: "primary-email-id", + email: "alice@example.com", + }, + USER_EMAIL_FRAGMENT, + ), + }, + }, + ], + totalCount: 1, pageInfo: { hasNextPage: false, hasPreviousPage: false, diff --git a/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap b/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap index 7bbdb9738..48fa1e3be 100644 --- a/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap +++ b/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap @@ -2,18 +2,18 @@ exports[`Account home page > display name edit box > displays an error if the display name is invalid 1`] = ` This is what others will see wherever you’re signed in. @@ -236,13 +236,13 @@ exports[`Account home page > display name edit box > lets edit the display name > display name edit box > lets edit the display name Cancel
-
@@ -548,7 +520,7 @@ exports[`Account home page > renders the page 1`] = ` > @@ -556,9 +528,9 @@ exports[`Account home page > renders the page 1`] = ` class="_controls_1h4nb_17" > renders the page 1`] = `
Add an alternative email you can use to access this account. @@ -582,7 +554,7 @@ exports[`Account home page > renders the page 1`] = ` role="separator" />
@@ -594,14 +566,14 @@ exports[`Account home page > renders the page 1`] = ` >

Account password

- ); -}; - -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, }, From dbb53160b66b51b5a341fd6cd8ef784fa088467e Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 13 Jan 2025 16:48:13 +0100 Subject: [PATCH 11/26] Data model and storage layer for storing user registrations --- crates/data-model/src/lib.rs | 2 +- crates/data-model/src/users.rs | 23 + ...79bfc50e22cb12ddf7495c7b0fedca61f9421.json | 17 + ...d64d52804848df378dc74f8f54ec4404e094e.json | 15 + ...baffb050b2f475ae106155c2e2f210a81191a.json | 15 + ...00c6890d2d63f54f1306e1bb95ca6ca123777.json | 88 ++ ...410b4b7b4af441f0a138c5421d1111cb9f79.json} | 14 +- ...19359913b8a934ca8a642b7bb43c9a7a58a6d.json | 15 + ...0038e9d00492b1e282237c0ec0e03bc36a9c0.json | 19 + ...a8b231f9e7c211ab83487536008e48316c269.json | 15 + ...1337d69aebad12be6fbfbdde91e34083ba4ed.json | 16 + .../20250113102144_user_registrations.sql | 49 ++ crates/storage-pg/src/repository.rs | 9 +- crates/storage-pg/src/user/email.rs | 57 ++ crates/storage-pg/src/user/mod.rs | 5 +- crates/storage-pg/src/user/registration.rs | 819 ++++++++++++++++++ crates/storage/src/repository.rs | 26 +- crates/storage/src/user/email.rs | 30 + crates/storage/src/user/mod.rs | 2 + crates/storage/src/user/registration.rs | 198 +++++ 20 files changed, 1423 insertions(+), 11 deletions(-) create mode 100644 crates/storage-pg/.sqlx/query-0e1bce56e15751d82a622d532b279bfc50e22cb12ddf7495c7b0fedca61f9421.json create mode 100644 crates/storage-pg/.sqlx/query-188a4aeef5a8b4bf3230c7176ded64d52804848df378dc74f8f54ec4404e094e.json create mode 100644 crates/storage-pg/.sqlx/query-4968c60adef69c7215a7efe2021baffb050b2f475ae106155c2e2f210a81191a.json create mode 100644 crates/storage-pg/.sqlx/query-6772b17585f26365e70ec3e342100c6890d2d63f54f1306e1bb95ca6ca123777.json rename crates/storage-pg/.sqlx/{query-7fd19dac2c15091e7f8bd85531d2b99d8a42cc89fe7bb6e9411a886f68e38628.json => query-7e367e416d18fcf9b227bf053421410b4b7b4af441f0a138c5421d1111cb9f79.json} (66%) create mode 100644 crates/storage-pg/.sqlx/query-83d1b0720dfde3209d77f1142aa19359913b8a934ca8a642b7bb43c9a7a58a6d.json create mode 100644 crates/storage-pg/.sqlx/query-8d240d72d651f59d53bed7380710038e9d00492b1e282237c0ec0e03bc36a9c0.json create mode 100644 crates/storage-pg/.sqlx/query-8f5ce493e8b8473ba03d5263915a8b231f9e7c211ab83487536008e48316c269.json create mode 100644 crates/storage-pg/.sqlx/query-b60d34f4d250c12f75dba10491c1337d69aebad12be6fbfbdde91e34083ba4ed.json create mode 100644 crates/storage-pg/migrations/20250113102144_user_registrations.sql create mode 100644 crates/storage-pg/src/user/registration.rs create mode 100644 crates/storage/src/user/registration.rs diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index 737d58af0..19a81f098 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -48,6 +48,6 @@ pub use self::{ users::{ Authentication, AuthenticationMethod, BrowserSession, Password, User, UserEmail, UserEmailAuthentication, UserEmailAuthenticationCode, UserRecoverySession, - UserRecoveryTicket, + UserRecoveryTicket, UserRegistration, UserRegistrationPassword, }, }; diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs index 88afae0b8..c020fa720 100644 --- a/crates/data-model/src/users.rs +++ b/crates/data-model/src/users.rs @@ -10,6 +10,7 @@ use chrono::{DateTime, Utc}; use rand::Rng; use serde::Serialize; use ulid::Ulid; +use url::Url; use crate::UserAgent; @@ -112,6 +113,7 @@ impl UserRecoveryTicket { pub struct UserEmailAuthentication { pub id: Ulid, pub user_session_id: Option, + pub user_registration_id: Option, pub email: String, pub created_at: DateTime, pub completed_at: Option>, @@ -192,3 +194,24 @@ impl UserEmail { ] } } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct UserRegistrationPassword { + pub hashed_password: String, + pub version: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct UserRegistration { + pub id: Ulid, + pub username: String, + pub display_name: Option, + pub terms_url: Option, + pub email_authentication_id: Option, + pub password: Option, + pub post_auth_action: Option, + pub ip_address: Option, + pub user_agent: Option, + pub created_at: DateTime, + pub completed_at: Option>, +} diff --git a/crates/storage-pg/.sqlx/query-0e1bce56e15751d82a622d532b279bfc50e22cb12ddf7495c7b0fedca61f9421.json b/crates/storage-pg/.sqlx/query-0e1bce56e15751d82a622d532b279bfc50e22cb12ddf7495c7b0fedca61f9421.json new file mode 100644 index 000000000..afd0835bb --- /dev/null +++ b/crates/storage-pg/.sqlx/query-0e1bce56e15751d82a622d532b279bfc50e22cb12ddf7495c7b0fedca61f9421.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_email_authentications\n ( user_email_authentication_id\n , user_registration_id\n , email\n , created_at\n )\n VALUES ($1, $2, $3, $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "0e1bce56e15751d82a622d532b279bfc50e22cb12ddf7495c7b0fedca61f9421" +} diff --git a/crates/storage-pg/.sqlx/query-188a4aeef5a8b4bf3230c7176ded64d52804848df378dc74f8f54ec4404e094e.json b/crates/storage-pg/.sqlx/query-188a4aeef5a8b4bf3230c7176ded64d52804848df378dc74f8f54ec4404e094e.json new file mode 100644 index 000000000..e6c0970c2 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-188a4aeef5a8b4bf3230c7176ded64d52804848df378dc74f8f54ec4404e094e.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_registrations\n SET terms_url = $2\n WHERE user_registration_id = $1 AND completed_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "188a4aeef5a8b4bf3230c7176ded64d52804848df378dc74f8f54ec4404e094e" +} diff --git a/crates/storage-pg/.sqlx/query-4968c60adef69c7215a7efe2021baffb050b2f475ae106155c2e2f210a81191a.json b/crates/storage-pg/.sqlx/query-4968c60adef69c7215a7efe2021baffb050b2f475ae106155c2e2f210a81191a.json new file mode 100644 index 000000000..ae85c032d --- /dev/null +++ b/crates/storage-pg/.sqlx/query-4968c60adef69c7215a7efe2021baffb050b2f475ae106155c2e2f210a81191a.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_registrations\n SET email_authentication_id = $2\n WHERE user_registration_id = $1 AND completed_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "4968c60adef69c7215a7efe2021baffb050b2f475ae106155c2e2f210a81191a" +} diff --git a/crates/storage-pg/.sqlx/query-6772b17585f26365e70ec3e342100c6890d2d63f54f1306e1bb95ca6ca123777.json b/crates/storage-pg/.sqlx/query-6772b17585f26365e70ec3e342100c6890d2d63f54f1306e1bb95ca6ca123777.json new file mode 100644 index 000000000..6ee03e2d7 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-6772b17585f26365e70ec3e342100c6890d2d63f54f1306e1bb95ca6ca123777.json @@ -0,0 +1,88 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_registration_id\n , ip_address as \"ip_address: IpAddr\"\n , user_agent\n , post_auth_action\n , username\n , display_name\n , terms_url\n , email_authentication_id\n , hashed_password\n , hashed_password_version\n , created_at\n , completed_at\n FROM user_registrations\n WHERE user_registration_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_registration_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "ip_address: IpAddr", + "type_info": "Inet" + }, + { + "ordinal": 2, + "name": "user_agent", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "post_auth_action", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "terms_url", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "email_authentication_id", + "type_info": "Uuid" + }, + { + "ordinal": 8, + "name": "hashed_password", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "hashed_password_version", + "type_info": "Int4" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "completed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true, + true, + true, + false, + true, + true, + true, + true, + true, + false, + true + ] + }, + "hash": "6772b17585f26365e70ec3e342100c6890d2d63f54f1306e1bb95ca6ca123777" +} diff --git a/crates/storage-pg/.sqlx/query-7fd19dac2c15091e7f8bd85531d2b99d8a42cc89fe7bb6e9411a886f68e38628.json b/crates/storage-pg/.sqlx/query-7e367e416d18fcf9b227bf053421410b4b7b4af441f0a138c5421d1111cb9f79.json similarity index 66% rename from crates/storage-pg/.sqlx/query-7fd19dac2c15091e7f8bd85531d2b99d8a42cc89fe7bb6e9411a886f68e38628.json rename to crates/storage-pg/.sqlx/query-7e367e416d18fcf9b227bf053421410b4b7b4af441f0a138c5421d1111cb9f79.json index f85b4d689..a6a02b326 100644 --- a/crates/storage-pg/.sqlx/query-7fd19dac2c15091e7f8bd85531d2b99d8a42cc89fe7bb6e9411a886f68e38628.json +++ b/crates/storage-pg/.sqlx/query-7e367e416d18fcf9b227bf053421410b4b7b4af441f0a138c5421d1111cb9f79.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_email_authentication_id\n , user_session_id\n , email\n , created_at\n , completed_at\n FROM user_email_authentications\n WHERE user_email_authentication_id = $1\n ", + "query": "\n SELECT user_email_authentication_id\n , user_session_id\n , user_registration_id\n , email\n , created_at\n , completed_at\n FROM user_email_authentications\n WHERE user_email_authentication_id = $1\n ", "describe": { "columns": [ { @@ -15,16 +15,21 @@ }, { "ordinal": 2, + "name": "user_registration_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, "name": "email", "type_info": "Text" }, { - "ordinal": 3, + "ordinal": 4, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 4, + "ordinal": 5, "name": "completed_at", "type_info": "Timestamptz" } @@ -37,10 +42,11 @@ "nullable": [ false, true, + true, false, false, true ] }, - "hash": "7fd19dac2c15091e7f8bd85531d2b99d8a42cc89fe7bb6e9411a886f68e38628" + "hash": "7e367e416d18fcf9b227bf053421410b4b7b4af441f0a138c5421d1111cb9f79" } diff --git a/crates/storage-pg/.sqlx/query-83d1b0720dfde3209d77f1142aa19359913b8a934ca8a642b7bb43c9a7a58a6d.json b/crates/storage-pg/.sqlx/query-83d1b0720dfde3209d77f1142aa19359913b8a934ca8a642b7bb43c9a7a58a6d.json new file mode 100644 index 000000000..a5899aa2f --- /dev/null +++ b/crates/storage-pg/.sqlx/query-83d1b0720dfde3209d77f1142aa19359913b8a934ca8a642b7bb43c9a7a58a6d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_registrations\n SET completed_at = $2\n WHERE user_registration_id = $1 AND completed_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "83d1b0720dfde3209d77f1142aa19359913b8a934ca8a642b7bb43c9a7a58a6d" +} diff --git a/crates/storage-pg/.sqlx/query-8d240d72d651f59d53bed7380710038e9d00492b1e282237c0ec0e03bc36a9c0.json b/crates/storage-pg/.sqlx/query-8d240d72d651f59d53bed7380710038e9d00492b1e282237c0ec0e03bc36a9c0.json new file mode 100644 index 000000000..00f736abc --- /dev/null +++ b/crates/storage-pg/.sqlx/query-8d240d72d651f59d53bed7380710038e9d00492b1e282237c0ec0e03bc36a9c0.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_registrations\n ( user_registration_id\n , ip_address\n , user_agent\n , post_auth_action\n , username\n , created_at\n )\n VALUES ($1, $2, $3, $4, $5, $6)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Inet", + "Text", + "Jsonb", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "8d240d72d651f59d53bed7380710038e9d00492b1e282237c0ec0e03bc36a9c0" +} diff --git a/crates/storage-pg/.sqlx/query-8f5ce493e8b8473ba03d5263915a8b231f9e7c211ab83487536008e48316c269.json b/crates/storage-pg/.sqlx/query-8f5ce493e8b8473ba03d5263915a8b231f9e7c211ab83487536008e48316c269.json new file mode 100644 index 000000000..969748044 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-8f5ce493e8b8473ba03d5263915a8b231f9e7c211ab83487536008e48316c269.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_registrations\n SET display_name = $2\n WHERE user_registration_id = $1 AND completed_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "8f5ce493e8b8473ba03d5263915a8b231f9e7c211ab83487536008e48316c269" +} diff --git a/crates/storage-pg/.sqlx/query-b60d34f4d250c12f75dba10491c1337d69aebad12be6fbfbdde91e34083ba4ed.json b/crates/storage-pg/.sqlx/query-b60d34f4d250c12f75dba10491c1337d69aebad12be6fbfbdde91e34083ba4ed.json new file mode 100644 index 000000000..5b4d6fb5f --- /dev/null +++ b/crates/storage-pg/.sqlx/query-b60d34f4d250c12f75dba10491c1337d69aebad12be6fbfbdde91e34083ba4ed.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_registrations\n SET hashed_password = $2, hashed_password_version = $3\n WHERE user_registration_id = $1 AND completed_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "b60d34f4d250c12f75dba10491c1337d69aebad12be6fbfbdde91e34083ba4ed" +} diff --git a/crates/storage-pg/migrations/20250113102144_user_registrations.sql b/crates/storage-pg/migrations/20250113102144_user_registrations.sql new file mode 100644 index 000000000..6b4590f43 --- /dev/null +++ b/crates/storage-pg/migrations/20250113102144_user_registrations.sql @@ -0,0 +1,49 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +-- Add a table for storing user registrations +CREATE TABLE "user_registrations" ( + "user_registration_id" UUID PRIMARY KEY, + + -- The IP address of the user agent, if any + "ip_address" INET, + + -- The user agent string of the user agent, if any + "user_agent" TEXT, + + -- The post auth action to execute after the registration, if any + "post_auth_action" JSONB, + + -- The username the user asked for + "username" TEXT NOT NULL, + + -- The display name the user asked for + "display_name" TEXT, + + -- The URL to the terms of service at the time of registration + "terms_url" TEXT, + + -- The ID of the email authentication session + "email_authentication_id" UUID + REFERENCES "user_email_authentications" ("user_email_authentication_id") + ON DELETE SET NULL, + + -- The hashed password of the user + "hashed_password" TEXT, + -- The scheme version used to hash the password + "hashed_password_version" INTEGER, + + -- When the object was created + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + + -- When the registration was completed + "completed_at" TIMESTAMP WITH TIME ZONE +); + +-- Allow using user email authentications for user registrations +ALTER TABLE "user_email_authentications" + ADD COLUMN "user_registration_id" UUID + REFERENCES "user_registrations" ("user_registration_id") + ON DELETE CASCADE; diff --git a/crates/storage-pg/src/repository.rs b/crates/storage-pg/src/repository.rs index 923221742..fde0f13e3 100644 --- a/crates/storage-pg/src/repository.rs +++ b/crates/storage-pg/src/repository.rs @@ -49,7 +49,8 @@ use crate::{ }, user::{ PgBrowserSessionRepository, PgUserEmailRepository, PgUserPasswordRepository, - PgUserRecoveryRepository, PgUserRepository, PgUserTermsRepository, + PgUserRecoveryRepository, PgUserRegistrationRepository, PgUserRepository, + PgUserTermsRepository, }, DatabaseError, }; @@ -191,6 +192,12 @@ where Box::new(PgUserTermsRepository::new(self.conn.as_mut())) } + fn user_registration<'c>( + &'c mut self, + ) -> Box + 'c> { + Box::new(PgUserRegistrationRepository::new(self.conn.as_mut())) + } + fn browser_session<'c>( &'c mut self, ) -> Box + 'c> { diff --git a/crates/storage-pg/src/user/email.rs b/crates/storage-pg/src/user/email.rs index cd09e8668..091714f4a 100644 --- a/crates/storage-pg/src/user/email.rs +++ b/crates/storage-pg/src/user/email.rs @@ -8,6 +8,7 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use mas_data_model::{ BrowserSession, User, UserEmail, UserEmailAuthentication, UserEmailAuthenticationCode, + UserRegistration, }; use mas_storage::{ user::{UserEmailFilter, UserEmailRepository}, @@ -66,6 +67,7 @@ impl From for UserEmail { struct UserEmailAuthenticationLookup { user_email_authentication_id: Uuid, user_session_id: Option, + user_registration_id: Option, email: String, created_at: DateTime, completed_at: Option>, @@ -76,6 +78,7 @@ impl From for UserEmailAuthentication { UserEmailAuthentication { id: value.user_email_authentication_id.into(), user_session_id: value.user_session_id.map(Ulid::from), + user_registration_id: value.user_registration_id.map(Ulid::from), email: value.email, created_at: value.created_at, completed_at: value.completed_at, @@ -427,6 +430,59 @@ impl UserEmailRepository for PgUserEmailRepository<'_> { Ok(UserEmailAuthentication { id, user_session_id: Some(session.id), + user_registration_id: None, + email, + created_at, + completed_at: None, + }) + } + + #[tracing::instrument( + name = "db.user_email.add_authentication_for_registration", + skip_all, + fields( + db.query.text, + %user_registration.id, + user_email_authentication.id, + user_email_authentication.email = email, + ), + err, + )] + async fn add_authentication_for_registration( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + email: String, + user_registration: &UserRegistration, + ) -> Result { + let created_at = clock.now(); + let id = Ulid::from_datetime_with_source(created_at.into(), rng); + tracing::Span::current() + .record("user_email_authentication.id", tracing::field::display(id)); + + sqlx::query!( + r#" + INSERT INTO user_email_authentications + ( user_email_authentication_id + , user_registration_id + , email + , created_at + ) + VALUES ($1, $2, $3, $4) + "#, + Uuid::from(id), + Uuid::from(user_registration.id), + &email, + created_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + Ok(UserEmailAuthentication { + id, + user_session_id: None, + user_registration_id: Some(user_registration.id), email, created_at, completed_at: None, @@ -509,6 +565,7 @@ impl UserEmailRepository for PgUserEmailRepository<'_> { r#" SELECT user_email_authentication_id , user_session_id + , user_registration_id , email , created_at , completed_at diff --git a/crates/storage-pg/src/user/mod.rs b/crates/storage-pg/src/user/mod.rs index ae7b999d1..ff0119c2a 100644 --- a/crates/storage-pg/src/user/mod.rs +++ b/crates/storage-pg/src/user/mod.rs @@ -31,6 +31,7 @@ use crate::{ mod email; mod password; mod recovery; +mod registration; mod session; mod terms; @@ -39,8 +40,8 @@ mod tests; pub use self::{ email::PgUserEmailRepository, password::PgUserPasswordRepository, - recovery::PgUserRecoveryRepository, session::PgBrowserSessionRepository, - terms::PgUserTermsRepository, + recovery::PgUserRecoveryRepository, registration::PgUserRegistrationRepository, + session::PgBrowserSessionRepository, terms::PgUserTermsRepository, }; /// An implementation of [`UserRepository`] for a PostgreSQL connection diff --git a/crates/storage-pg/src/user/registration.rs b/crates/storage-pg/src/user/registration.rs new file mode 100644 index 000000000..5087d1d9e --- /dev/null +++ b/crates/storage-pg/src/user/registration.rs @@ -0,0 +1,819 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use std::net::IpAddr; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use mas_data_model::{ + UserAgent, UserEmailAuthentication, UserRegistration, UserRegistrationPassword, +}; +use mas_storage::{user::UserRegistrationRepository, Clock}; +use rand::RngCore; +use sqlx::PgConnection; +use ulid::Ulid; +use url::Url; +use uuid::Uuid; + +use crate::{DatabaseError, DatabaseInconsistencyError, ExecuteExt as _}; + +/// An implementation of [`UserRegistrationRepository`] for a PostgreSQL +/// connection +pub struct PgUserRegistrationRepository<'c> { + conn: &'c mut PgConnection, +} + +impl<'c> PgUserRegistrationRepository<'c> { + /// Create a new [`PgUserRegistrationRepository`] from an active PostgreSQL + /// connection + pub fn new(conn: &'c mut PgConnection) -> Self { + Self { conn } + } +} + +struct UserRegistrationLookup { + user_registration_id: Uuid, + ip_address: Option, + user_agent: Option, + post_auth_action: Option, + username: String, + display_name: Option, + terms_url: Option, + email_authentication_id: Option, + hashed_password: Option, + hashed_password_version: Option, + created_at: DateTime, + completed_at: Option>, +} + +impl TryFrom for UserRegistration { + type Error = DatabaseInconsistencyError; + + fn try_from(value: UserRegistrationLookup) -> Result { + let id = Ulid::from(value.user_registration_id); + let user_agent = value.user_agent.map(UserAgent::parse); + + let password = match (value.hashed_password, value.hashed_password_version) { + (Some(hashed_password), Some(version)) => { + let version = version.try_into().map_err(|e| { + DatabaseInconsistencyError::on("user_registrations") + .column("hashed_password_version") + .row(id) + .source(e) + })?; + + Some(UserRegistrationPassword { + hashed_password, + version, + }) + } + (None, None) => None, + _ => { + return Err(DatabaseInconsistencyError::on("user_registrations") + .column("hashed_password") + .row(id)); + } + }; + + let terms_url = value + .terms_url + .map(|u| u.parse()) + .transpose() + .map_err(|e| { + DatabaseInconsistencyError::on("user_registrations") + .column("terms_url") + .row(id) + .source(e) + })?; + + Ok(UserRegistration { + id, + ip_address: value.ip_address, + user_agent, + post_auth_action: value.post_auth_action, + username: value.username, + display_name: value.display_name, + terms_url, + email_authentication_id: value.email_authentication_id.map(Ulid::from), + password, + created_at: value.created_at, + completed_at: value.completed_at, + }) + } +} + +#[async_trait] +impl UserRegistrationRepository for PgUserRegistrationRepository<'_> { + type Error = DatabaseError; + + #[tracing::instrument( + name = "db.user_registration.lookup", + skip_all, + fields( + db.query.text, + user_registration.id = %id, + ), + err, + )] + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error> { + let res = sqlx::query_as!( + UserRegistrationLookup, + r#" + SELECT user_registration_id + , ip_address as "ip_address: IpAddr" + , user_agent + , post_auth_action + , username + , display_name + , terms_url + , email_authentication_id + , hashed_password + , hashed_password_version + , created_at + , completed_at + FROM user_registrations + WHERE user_registration_id = $1 + "#, + Uuid::from(id), + ) + .traced() + .fetch_optional(&mut *self.conn) + .await?; + + let Some(res) = res else { return Ok(None) }; + + Ok(Some(res.try_into()?)) + } + + #[tracing::instrument( + name = "db.user_registration.add", + skip_all, + fields( + db.query.text, + user_registration.id, + ), + err, + )] + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + username: String, + ip_address: Option, + user_agent: Option, + post_auth_action: Option, + ) -> Result { + let created_at = clock.now(); + let id = Ulid::from_datetime_with_source(created_at.into(), rng); + tracing::Span::current().record("user_registration.id", tracing::field::display(id)); + + sqlx::query!( + r#" + INSERT INTO user_registrations + ( user_registration_id + , ip_address + , user_agent + , post_auth_action + , username + , created_at + ) + VALUES ($1, $2, $3, $4, $5, $6) + "#, + Uuid::from(id), + ip_address as Option, + user_agent.as_deref(), + post_auth_action, + username, + created_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + Ok(UserRegistration { + id, + ip_address, + user_agent, + post_auth_action, + created_at, + completed_at: None, + username, + display_name: None, + terms_url: None, + email_authentication_id: None, + password: None, + }) + } + + #[tracing::instrument( + name = "db.user_registration.set_display_name", + skip_all, + fields( + db.query.text, + user_registration.id = %user_registration.id, + user_registration.display_name = display_name, + ), + err, + )] + async fn set_display_name( + &mut self, + mut user_registration: UserRegistration, + display_name: String, + ) -> Result { + let res = sqlx::query!( + r#" + UPDATE user_registrations + SET display_name = $2 + WHERE user_registration_id = $1 AND completed_at IS NULL + "#, + Uuid::from(user_registration.id), + display_name, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + user_registration.display_name = Some(display_name); + + Ok(user_registration) + } + + #[tracing::instrument( + name = "db.user_registration.set_terms_url", + skip_all, + fields( + db.query.text, + user_registration.id = %user_registration.id, + user_registration.terms_url = %terms_url, + ), + err, + )] + async fn set_terms_url( + &mut self, + mut user_registration: UserRegistration, + terms_url: Url, + ) -> Result { + let res = sqlx::query!( + r#" + UPDATE user_registrations + SET terms_url = $2 + WHERE user_registration_id = $1 AND completed_at IS NULL + "#, + Uuid::from(user_registration.id), + terms_url.as_str(), + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + user_registration.terms_url = Some(terms_url); + + Ok(user_registration) + } + + #[tracing::instrument( + name = "db.user_registration.set_email_authentication", + skip_all, + fields( + db.query.text, + %user_registration.id, + %user_email_authentication.id, + %user_email_authentication.email, + ), + err, + )] + async fn set_email_authentication( + &mut self, + mut user_registration: UserRegistration, + user_email_authentication: &UserEmailAuthentication, + ) -> Result { + let res = sqlx::query!( + r#" + UPDATE user_registrations + SET email_authentication_id = $2 + WHERE user_registration_id = $1 AND completed_at IS NULL + "#, + Uuid::from(user_registration.id), + Uuid::from(user_email_authentication.id), + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + user_registration.email_authentication_id = Some(user_email_authentication.id); + + Ok(user_registration) + } + + #[tracing::instrument( + name = "db.user_registration.set_password", + skip_all, + fields( + db.query.text, + user_registration.id = %user_registration.id, + user_registration.hashed_password = hashed_password, + user_registration.hashed_password_version = version, + ), + err, + )] + async fn set_password( + &mut self, + mut user_registration: UserRegistration, + hashed_password: String, + version: u16, + ) -> Result { + let res = sqlx::query!( + r#" + UPDATE user_registrations + SET hashed_password = $2, hashed_password_version = $3 + WHERE user_registration_id = $1 AND completed_at IS NULL + "#, + Uuid::from(user_registration.id), + hashed_password, + i32::from(version), + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + user_registration.password = Some(UserRegistrationPassword { + hashed_password, + version, + }); + + Ok(user_registration) + } + + #[tracing::instrument( + name = "db.user_registration.complete", + skip_all, + fields( + db.query.text, + user_registration.id = %user_registration.id, + ), + err, + )] + async fn complete( + &mut self, + clock: &dyn Clock, + mut user_registration: UserRegistration, + ) -> Result { + let completed_at = clock.now(); + let res = sqlx::query!( + r#" + UPDATE user_registrations + SET completed_at = $2 + WHERE user_registration_id = $1 AND completed_at IS NULL + "#, + Uuid::from(user_registration.id), + completed_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + user_registration.completed_at = Some(completed_at); + + Ok(user_registration) + } +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr}; + + use mas_data_model::{UserAgent, UserRegistrationPassword}; + use mas_storage::{clock::MockClock, Clock}; + use rand::SeedableRng; + use rand_chacha::ChaChaRng; + use sqlx::PgPool; + + use crate::PgRepository; + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_create_lookup_complete(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + let registration = repo + .user_registration() + .add(&mut rng, &clock, "alice".to_owned(), None, None, None) + .await + .unwrap(); + + assert_eq!(registration.created_at, clock.now()); + assert_eq!(registration.completed_at, None); + assert_eq!(registration.username, "alice"); + assert_eq!(registration.display_name, None); + assert_eq!(registration.terms_url, None); + assert_eq!(registration.email_authentication_id, None); + assert_eq!(registration.password, None); + assert_eq!(registration.user_agent, None); + assert_eq!(registration.ip_address, None); + assert_eq!(registration.post_auth_action, None); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!(lookup.id, registration.id); + assert_eq!(lookup.created_at, registration.created_at); + assert_eq!(lookup.completed_at, registration.completed_at); + assert_eq!(lookup.username, registration.username); + assert_eq!(lookup.display_name, registration.display_name); + assert_eq!(lookup.terms_url, registration.terms_url); + assert_eq!( + lookup.email_authentication_id, + registration.email_authentication_id + ); + assert_eq!(lookup.password, registration.password); + assert_eq!(lookup.user_agent, registration.user_agent); + assert_eq!(lookup.ip_address, registration.ip_address); + assert_eq!(lookup.post_auth_action, registration.post_auth_action); + + // Mark the registration as completed + let registration = repo + .user_registration() + .complete(&clock, registration) + .await + .unwrap(); + assert_eq!(registration.completed_at, Some(clock.now())); + + // Lookup the registration again + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + assert_eq!(lookup.completed_at, registration.completed_at); + + // Do it again, it should fail + let res = repo + .user_registration() + .complete(&clock, registration) + .await; + assert!(res.is_err()); + } + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_create_useragent_ipaddress(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + let registration = repo + .user_registration() + .add( + &mut rng, + &clock, + "alice".to_owned(), + Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))), + Some(UserAgent::parse("Mozilla/5.0".to_owned())), + Some(serde_json::json!({"action": "continue_compat_sso_login", "id": "01FSHN9AG0MKGTBNZ16RDR3PVY"})), + ) + .await + .unwrap(); + + assert_eq!( + registration.user_agent, + Some(UserAgent::parse("Mozilla/5.0".to_owned())) + ); + assert_eq!( + registration.ip_address, + Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))) + ); + assert_eq!( + registration.post_auth_action, + Some( + serde_json::json!({"action": "continue_compat_sso_login", "id": "01FSHN9AG0MKGTBNZ16RDR3PVY"}) + ) + ); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!(lookup.user_agent, registration.user_agent); + assert_eq!(lookup.ip_address, registration.ip_address); + assert_eq!(lookup.post_auth_action, registration.post_auth_action); + } + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_set_display_name(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + let registration = repo + .user_registration() + .add(&mut rng, &clock, "alice".to_owned(), None, None, None) + .await + .unwrap(); + + assert_eq!(registration.display_name, None); + + let registration = repo + .user_registration() + .set_display_name(registration, "Alice".to_owned()) + .await + .unwrap(); + + assert_eq!(registration.display_name, Some("Alice".to_owned())); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!(lookup.display_name, registration.display_name); + + // Setting it again should work + let registration = repo + .user_registration() + .set_display_name(registration, "Bob".to_owned()) + .await + .unwrap(); + + assert_eq!(registration.display_name, Some("Bob".to_owned())); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!(lookup.display_name, registration.display_name); + + // Can't set it once completed + let registration = repo + .user_registration() + .complete(&clock, registration) + .await + .unwrap(); + + let res = repo + .user_registration() + .set_display_name(registration, "Charlie".to_owned()) + .await; + assert!(res.is_err()); + } + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_set_terms_url(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + let registration = repo + .user_registration() + .add(&mut rng, &clock, "alice".to_owned(), None, None, None) + .await + .unwrap(); + + assert_eq!(registration.terms_url, None); + + let registration = repo + .user_registration() + .set_terms_url(registration, "https://example.com/terms".parse().unwrap()) + .await + .unwrap(); + + assert_eq!( + registration.terms_url, + Some("https://example.com/terms".parse().unwrap()) + ); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!(lookup.terms_url, registration.terms_url); + + // Setting it again should work + let registration = repo + .user_registration() + .set_terms_url(registration, "https://example.com/terms2".parse().unwrap()) + .await + .unwrap(); + + assert_eq!( + registration.terms_url, + Some("https://example.com/terms2".parse().unwrap()) + ); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!(lookup.terms_url, registration.terms_url); + + // Can't set it once completed + let registration = repo + .user_registration() + .complete(&clock, registration) + .await + .unwrap(); + + let res = repo + .user_registration() + .set_terms_url(registration, "https://example.com/terms3".parse().unwrap()) + .await; + assert!(res.is_err()); + } + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_set_email_authentication(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + let registration = repo + .user_registration() + .add(&mut rng, &clock, "alice".to_owned(), None, None, None) + .await + .unwrap(); + + assert_eq!(registration.email_authentication_id, None); + + let authentication = repo + .user_email() + .add_authentication_for_registration( + &mut rng, + &clock, + "alice@example.com".to_owned(), + ®istration, + ) + .await + .unwrap(); + + let registration = repo + .user_registration() + .set_email_authentication(registration, &authentication) + .await + .unwrap(); + + assert_eq!( + registration.email_authentication_id, + Some(authentication.id) + ); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!( + lookup.email_authentication_id, + registration.email_authentication_id + ); + + // Setting it again should work + let registration = repo + .user_registration() + .set_email_authentication(registration, &authentication) + .await + .unwrap(); + + assert_eq!( + registration.email_authentication_id, + Some(authentication.id) + ); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!( + lookup.email_authentication_id, + registration.email_authentication_id + ); + + // Can't set it once completed + let registration = repo + .user_registration() + .complete(&clock, registration) + .await + .unwrap(); + + let res = repo + .user_registration() + .set_email_authentication(registration, &authentication) + .await; + assert!(res.is_err()); + } + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_set_password(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + let registration = repo + .user_registration() + .add(&mut rng, &clock, "alice".to_owned(), None, None, None) + .await + .unwrap(); + + assert_eq!(registration.password, None); + + let registration = repo + .user_registration() + .set_password(registration, "fakehashedpassword".to_owned(), 1) + .await + .unwrap(); + + assert_eq!( + registration.password, + Some(UserRegistrationPassword { + hashed_password: "fakehashedpassword".to_owned(), + version: 1, + }) + ); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!(lookup.password, registration.password); + + // Setting it again should work + let registration = repo + .user_registration() + .set_password(registration, "fakehashedpassword2".to_owned(), 2) + .await + .unwrap(); + + assert_eq!( + registration.password, + Some(UserRegistrationPassword { + hashed_password: "fakehashedpassword2".to_owned(), + version: 2, + }) + ); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!(lookup.password, registration.password); + + // Can't set it once completed + let registration = repo + .user_registration() + .complete(&clock, registration) + .await + .unwrap(); + + let res = repo + .user_registration() + .set_password(registration, "fakehashedpassword3".to_owned(), 3) + .await; + assert!(res.is_err()); + } +} diff --git a/crates/storage/src/repository.rs b/crates/storage/src/repository.rs index ab70a287a..4ee86093d 100644 --- a/crates/storage/src/repository.rs +++ b/crates/storage/src/repository.rs @@ -24,7 +24,7 @@ use crate::{ }, user::{ BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, - UserRecoveryRepository, UserRepository, UserTermsRepository, + UserRecoveryRepository, UserRegistrationRepository, UserRepository, UserTermsRepository, }, }; @@ -129,6 +129,11 @@ pub trait RepositoryAccess: Send { fn user_recovery<'c>(&'c mut self) -> Box + 'c>; + /// Get an [`UserRegistrationRepository`] + fn user_registration<'c>( + &'c mut self, + ) -> Box + 'c>; + /// Get an [`UserTermsRepository`] fn user_terms<'c>(&'c mut self) -> Box + 'c>; @@ -224,8 +229,8 @@ mod impls { UpstreamOAuthSessionRepository, }, user::{ - BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository, - UserTermsRepository, + BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, + UserRegistrationRepository, UserRepository, UserTermsRepository, }, MapErr, Repository, RepositoryTransaction, }; @@ -316,6 +321,15 @@ mod impls { Box::new(MapErr::new(self.inner.user_recovery(), &mut self.mapper)) } + fn user_registration<'c>( + &'c mut self, + ) -> Box + 'c> { + Box::new(MapErr::new( + self.inner.user_registration(), + &mut self.mapper, + )) + } + fn user_terms<'c>(&'c mut self) -> Box + 'c> { Box::new(MapErr::new(self.inner.user_terms(), &mut self.mapper)) } @@ -468,6 +482,12 @@ mod impls { (**self).user_recovery() } + fn user_registration<'c>( + &'c mut self, + ) -> Box + 'c> { + (**self).user_registration() + } + fn user_terms<'c>(&'c mut self) -> Box + 'c> { (**self).user_terms() } diff --git a/crates/storage/src/user/email.rs b/crates/storage/src/user/email.rs index 903b7cd4b..695876a92 100644 --- a/crates/storage/src/user/email.rs +++ b/crates/storage/src/user/email.rs @@ -7,6 +7,7 @@ use async_trait::async_trait; use mas_data_model::{ BrowserSession, User, UserEmail, UserEmailAuthentication, UserEmailAuthenticationCode, + UserRegistration, }; use rand_core::RngCore; use ulid::Ulid; @@ -184,6 +185,27 @@ pub trait UserEmailRepository: Send + Sync { session: &BrowserSession, ) -> Result; + /// Add a new [`UserEmailAuthentication`] for a [`UserRegistration`] + /// + /// # Parameters + /// + /// * `rng`: The random number generator to use + /// * `clock`: The clock to use + /// * `email`: The email address to add + /// * `registration`: The [`UserRegistration`] for which to add the + /// [`UserEmailAuthentication`] + /// + /// # Errors + /// + /// Returns an error if the underlying repository fails + async fn add_authentication_for_registration( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + email: String, + registration: &UserRegistration, + ) -> Result; + /// Add a new [`UserEmailAuthenticationCode`] for a /// [`UserEmailAuthentication`] /// @@ -289,6 +311,14 @@ repository_impl!(UserEmailRepository: session: &BrowserSession, ) -> Result; + async fn add_authentication_for_registration( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + email: String, + registration: &UserRegistration, + ) -> Result; + async fn add_authentication_code( &mut self, rng: &mut (dyn RngCore + Send), diff --git a/crates/storage/src/user/mod.rs b/crates/storage/src/user/mod.rs index fcd1381d3..d8d288eb8 100644 --- a/crates/storage/src/user/mod.rs +++ b/crates/storage/src/user/mod.rs @@ -16,6 +16,7 @@ use crate::{repository_impl, Clock, Page, Pagination}; mod email; mod password; mod recovery; +mod registration; mod session; mod terms; @@ -23,6 +24,7 @@ pub use self::{ email::{UserEmailFilter, UserEmailRepository}, password::UserPasswordRepository, recovery::UserRecoveryRepository, + registration::UserRegistrationRepository, session::{BrowserSessionFilter, BrowserSessionRepository}, terms::UserTermsRepository, }; diff --git a/crates/storage/src/user/registration.rs b/crates/storage/src/user/registration.rs new file mode 100644 index 000000000..f193fa220 --- /dev/null +++ b/crates/storage/src/user/registration.rs @@ -0,0 +1,198 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use std::net::IpAddr; + +use async_trait::async_trait; +use mas_data_model::{UserAgent, UserEmailAuthentication, UserRegistration}; +use rand_core::RngCore; +use ulid::Ulid; +use url::Url; + +use crate::{repository_impl, Clock}; + +/// A [`UserRegistrationRepository`] helps interacting with [`UserRegistration`] +/// saved in the storage backend +#[async_trait] +pub trait UserRegistrationRepository: Send + Sync { + /// The error type returned by the repository + type Error; + + /// Lookup a [`UserRegistration`] by its ID + /// + /// Returns `None` if no [`UserRegistration`] was found + /// + /// # Parameters + /// + /// * `id`: The ID of the [`UserRegistration`] to lookup + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; + + /// Create a new [`UserRegistration`] session + /// + /// Returns the newly created [`UserRegistration`] + /// + /// # Parameters + /// + /// * `rng`: The random number generator to use + /// * `clock`: The clock used to generate timestamps + /// * `username`: The username of the user + /// * `ip_address`: The IP address of the user agent, if any + /// * `user_agent`: The user agent of the user agent, if any + /// * `post_auth_action`: The post auth action to execute after the + /// registration, if any + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + username: String, + ip_address: Option, + user_agent: Option, + post_auth_action: Option, + ) -> Result; + + /// Set the display name of a [`UserRegistration`] + /// + /// Returns the updated [`UserRegistration`] + /// + /// # Parameters + /// + /// * `user_registration`: The [`UserRegistration`] to update + /// * `display_name`: The display name to set + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails or if the + /// registration is already completed + async fn set_display_name( + &mut self, + user_registration: UserRegistration, + display_name: String, + ) -> Result; + + /// Set the terms URL of a [`UserRegistration`] + /// + /// Returns the updated [`UserRegistration`] + /// + /// # Parameters + /// + /// * `user_registration`: The [`UserRegistration`] to update + /// * `terms_url`: The terms URL to set + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails or if the + /// registration is already completed + async fn set_terms_url( + &mut self, + user_registration: UserRegistration, + terms_url: Url, + ) -> Result; + + /// Set the email authentication code of a [`UserRegistration`] + /// + /// Returns the updated [`UserRegistration`] + /// + /// # Parameters + /// + /// * `user_registration`: The [`UserRegistration`] to update + /// * `email_authentication`: The [`UserEmailAuthentication`] to set + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails or if the + /// registration is already completed + async fn set_email_authentication( + &mut self, + user_registration: UserRegistration, + email_authentication: &UserEmailAuthentication, + ) -> Result; + + /// Set the password of a [`UserRegistration`] + /// + /// Returns the updated [`UserRegistration`] + /// + /// # Parameters + /// + /// * `user_registration`: The [`UserRegistration`] to update + /// * `hashed_password`: The hashed password to set + /// * `version`: The version of the hashing scheme + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails or if the + /// registration is already completed + async fn set_password( + &mut self, + user_registration: UserRegistration, + hashed_password: String, + version: u16, + ) -> Result; + + /// Complete a [`UserRegistration`] + /// + /// Returns the updated [`UserRegistration`] + /// + /// # Parameters + /// + /// * `clock`: The clock used to generate timestamps + /// * `user_registration`: The [`UserRegistration`] to complete + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails or if the + /// registration is already completed + async fn complete( + &mut self, + clock: &dyn Clock, + user_registration: UserRegistration, + ) -> Result; +} + +repository_impl!(UserRegistrationRepository: + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + username: String, + ip_address: Option, + user_agent: Option, + post_auth_action: Option, + ) -> Result; + async fn set_display_name( + &mut self, + user_registration: UserRegistration, + display_name: String, + ) -> Result; + async fn set_terms_url( + &mut self, + user_registration: UserRegistration, + terms_url: Url, + ) -> Result; + async fn set_email_authentication( + &mut self, + user_registration: UserRegistration, + email_authentication: &UserEmailAuthentication, + ) -> Result; + async fn set_password( + &mut self, + user_registration: UserRegistration, + hashed_password: String, + version: u16, + ) -> Result; + async fn complete( + &mut self, + clock: &dyn Clock, + user_registration: UserRegistration, + ) -> Result; +); From 3da27afc91e21b6adba7e3791ea52b5030a50d4a Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 14 Jan 2025 10:37:18 +0100 Subject: [PATCH 12/26] Move the registration-related views into a sub-module --- crates/handlers/src/lib.rs | 6 ++-- crates/handlers/src/views/mod.rs | 1 - .../views/{register.rs => register/mod.rs} | 2 ++ .../password.rs} | 6 ++-- crates/templates/src/lib.rs | 4 +-- .../{register.html => register/index.html} | 0 .../password.html} | 0 translations/en.json | 35 +++++++------------ 8 files changed, 22 insertions(+), 32 deletions(-) rename crates/handlers/src/views/{register.rs => register/mod.rs} (99%) rename crates/handlers/src/views/{password_register.rs => register/password.rs} (99%) rename templates/pages/{register.html => register/index.html} (100%) rename templates/pages/{password_register.html => register/password.html} (100%) diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 882684751..d7b39ff24 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -24,7 +24,6 @@ use axum::{ routing::{get, post}, Extension, Router, }; -use graphql::ExtraRouterParameters; use headers::HeaderName; use hyper::{ header::{ @@ -42,11 +41,12 @@ use mas_router::{Route, UrlBuilder}; use mas_storage::{BoxClock, BoxRepository, BoxRng}; use mas_templates::{ErrorContext, NotFoundContext, TemplateContext, Templates}; use opentelemetry::metrics::Meter; -use passwords::PasswordManager; use sqlx::PgPool; use tower::util::AndThenLayer; use tower_http::cors::{Any, CorsLayer}; +use self::{graphql::ExtraRouterParameters, passwords::PasswordManager}; + mod admin; mod compat; mod graphql; @@ -376,7 +376,7 @@ where ) .route( mas_router::PasswordRegister::route(), - get(self::views::password_register::get).post(self::views::password_register::post), + get(self::views::register::password::get).post(self::views::register::password::post), ) .route( mas_router::AccountVerifyEmail::route(), diff --git a/crates/handlers/src/views/mod.rs b/crates/handlers/src/views/mod.rs index 5a3928444..1ce31f3cf 100644 --- a/crates/handlers/src/views/mod.rs +++ b/crates/handlers/src/views/mod.rs @@ -9,7 +9,6 @@ pub mod app; pub mod index; pub mod login; pub mod logout; -pub mod password_register; pub mod reauth; pub mod recovery; pub mod register; diff --git a/crates/handlers/src/views/register.rs b/crates/handlers/src/views/register/mod.rs similarity index 99% rename from crates/handlers/src/views/register.rs rename to crates/handlers/src/views/register/mod.rs index 63d6ef1b6..a532f4355 100644 --- a/crates/handlers/src/views/register.rs +++ b/crates/handlers/src/views/register/mod.rs @@ -17,6 +17,8 @@ use mas_templates::{RegisterContext, TemplateContext, Templates}; use super::shared::OptionalPostAuthAction; use crate::{BoundActivityTracker, PreferredLanguage}; +pub(crate) mod password; + #[tracing::instrument(name = "handlers.views.register.get", skip_all, err)] pub(crate) async fn get( mut rng: BoxRng, diff --git a/crates/handlers/src/views/password_register.rs b/crates/handlers/src/views/register/password.rs similarity index 99% rename from crates/handlers/src/views/password_register.rs rename to crates/handlers/src/views/register/password.rs index c53273e51..5f30a9385 100644 --- a/crates/handlers/src/views/password_register.rs +++ b/crates/handlers/src/views/register/password.rs @@ -35,10 +35,10 @@ use mas_templates::{ use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; -use super::shared::OptionalPostAuthAction; use crate::{ - captcha::Form as CaptchaForm, passwords::PasswordManager, BoundActivityTracker, Limiter, - PreferredLanguage, RequesterFingerprint, SiteConfig, + captcha::Form as CaptchaForm, passwords::PasswordManager, + views::shared::OptionalPostAuthAction, BoundActivityTracker, Limiter, PreferredLanguage, + RequesterFingerprint, SiteConfig, }; #[derive(Debug, Deserialize, Serialize)] diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 3ed01afc2..0f51d4826 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -326,10 +326,10 @@ register_templates! { pub fn render_login(WithLanguage>) { "pages/login.html" } /// Render the registration page - pub fn render_register(WithLanguage>) { "pages/register.html" } + pub fn render_register(WithLanguage>) { "pages/register/index.html" } /// Render the password registration page - pub fn render_password_register(WithLanguage>>) { "pages/password_register.html" } + pub fn render_password_register(WithLanguage>>) { "pages/register/password.html" } /// Render the client consent page pub fn render_consent(WithLanguage>>) { "pages/consent.html" } diff --git a/templates/pages/register.html b/templates/pages/register/index.html similarity index 100% rename from templates/pages/register.html rename to templates/pages/register/index.html diff --git a/templates/pages/password_register.html b/templates/pages/register/password.html similarity index 100% rename from templates/pages/password_register.html rename to templates/pages/register/password.html diff --git a/translations/en.json b/translations/en.json index b4e957f80..4835bf4b7 100644 --- a/translations/en.json +++ b/translations/en.json @@ -10,7 +10,7 @@ }, "continue": "Continue", "@continue": { - "context": "form_post.html:25:28-48, pages/account/emails/add.html:37:26-46, pages/account/emails/verify.html:52:26-46, pages/consent.html:57:28-48, pages/device_consent.html:123:13-33, pages/device_link.html:40:26-46, pages/login.html:62:30-50, pages/password_register.html:74:26-46, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/sso.html:37:28-48" + "context": "form_post.html:25:28-48, pages/account/emails/verify.html:52:26-46, pages/consent.html:57:28-48, pages/device_consent.html:123:13-33, pages/device_link.html:40:26-46, pages/login.html:62:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/sso.html:37:28-48" }, "create_account": "Create Account", "@create_account": { @@ -75,7 +75,7 @@ }, "email_address": "Email address", "@email_address": { - "context": "pages/account/emails/add.html:33:33-58, pages/password_register.html:38:33-58, pages/recovery/start.html:34:33-58, pages/upstream_oauth2/do_register.html:114:37-62" + "context": "pages/recovery/start.html:34:33-58, pages/register/password.html:38:33-58, pages/upstream_oauth2/do_register.html:114:37-62" }, "loading": "Loading…", "@loading": { @@ -87,15 +87,15 @@ }, "password": "Password", "@password": { - "context": "pages/login.html:50:37-57, pages/password_register.html:42:33-53, pages/reauth.html:28:35-55" + "context": "pages/login.html:50:37-57, pages/reauth.html:28:35-55, pages/register/password.html:42:33-53" }, "password_confirm": "Confirm password", "@password_confirm": { - "context": "pages/password_register.html:46:33-61" + "context": "pages/register/password.html:46:33-61" }, "username": "Username", "@username": { - "context": "pages/login.html:45:35-55, pages/password_register.html:34:33-53, pages/register.html:30:35-55, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:106:39-59" + "context": "pages/login.html:45:35-55, pages/register/index.html:30:35-55, pages/register/password.html:34:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:106:39-59" } }, "error": { @@ -106,17 +106,6 @@ } }, "mas": { - "add_email": { - "description": "Enter an email address to recover your account in case you lose access to it.", - "@description": { - "context": "pages/account/emails/add.html:19:25-55" - }, - "heading": "Add an email address", - "@heading": { - "context": "pages/account/emails/add.html:18:27-53", - "description": "Heading for the page to add an email address" - } - }, "back_to_homepage": "Go back to the homepage", "@back_to_homepage": { "context": "pages/404.html:16:29-54" @@ -338,7 +327,7 @@ }, "continue_with_provider": "Continue with %(provider)s", "@continue_with_provider": { - "context": "pages/login.html:75:15-67, pages/register.html:49:15-67", + "context": "pages/login.html:75:15-67, pages/register/index.html:49:15-67", "description": "Button to log in with an upstream provider" }, "description": "Please sign in to continue:", @@ -524,26 +513,26 @@ "register": { "call_to_login": "Already have an account?", "@call_to_login": { - "context": "pages/password_register.html:77:33-64, pages/register.html:55:35-66", + "context": "pages/register/index.html:55:35-66, pages/register/password.html:77:33-64", "description": "Displayed on the registration page to suggest to log in instead" }, "continue_with_email": "Continue with email address", "@continue_with_email": { - "context": "pages/register.html:40:30-67" + "context": "pages/register/index.html:40:30-67" }, "create_account": { "description": "Choose a username to continue.", "@description": { - "context": "pages/register.html:24:29-73" + "context": "pages/register/index.html:24:29-73" }, "heading": "Create an account", "@heading": { - "context": "pages/password_register.html:18:27-67, pages/register.html:21:29-69" + "context": "pages/register/index.html:21:29-69, pages/register/password.html:18:27-67" } }, "terms_of_service": "I agree to the
Terms and Conditions", "@terms_of_service": { - "context": "pages/password_register.html:51:35-95, pages/upstream_oauth2/do_register.html:179:35-95" + "context": "pages/register/password.html:51:35-95, pages/upstream_oauth2/do_register.html:179:35-95" } }, "scope": { @@ -693,4 +682,4 @@ } } } -} \ No newline at end of file +} From a294b37e897512ffdf1e3aaed2dad2f68bbec84c Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 14 Jan 2025 11:10:07 +0100 Subject: [PATCH 13/26] Fix the post auth action being lost during the registration flow --- templates/pages/register/index.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/templates/pages/register/index.html b/templates/pages/register/index.html index 7604f9301..b89bc0fb0 100644 --- a/templates/pages/register/index.html +++ b/templates/pages/register/index.html @@ -11,7 +11,7 @@ {% from "components/idp_brand.html" import logo %} {% block content %} -
+
{{ icon.user_profile_solid() }} @@ -36,6 +36,10 @@

{{ _("mas.register.create_account.heading") }}

{% endif %}
+ {% for key, value in next["params"]|default({})|items %} + + {% endfor %} + {% if features.password_registration %} {{ button.button(text=_("mas.register.continue_with_email")) }} {% endif %} From 0bedaf37453cc175bfa56bed9c4f602332f28e26 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 14 Jan 2025 12:57:01 +0100 Subject: [PATCH 14/26] Make the password registration create a user_registration --- .../handlers/src/views/register/password.rs | 106 +++++++++++------- crates/tasks/src/email.rs | 23 +++- crates/templates/src/context.rs | 8 +- templates/emails/verification.html | 8 +- templates/emails/verification.txt | 8 +- translations/en.json | 14 +-- 6 files changed, 114 insertions(+), 53 deletions(-) diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs index 5f30a9385..a4fbc6763 100644 --- a/crates/handlers/src/views/register/password.rs +++ b/crates/handlers/src/views/register/password.rs @@ -24,8 +24,8 @@ use mas_matrix::BoxHomeserverConnection; use mas_policy::Policy; use mas_router::UrlBuilder; use mas_storage::{ - queue::{ProvisionUserJob, QueueJobRepositoryExt as _}, - user::{BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository}, + queue::{QueueJobRepositoryExt as _, SendEmailAuthenticationCodeJob}, + user::{UserEmailRepository, UserRepository}, BoxClock, BoxRepository, BoxRng, RepositoryAccess, }; use mas_templates::{ @@ -141,6 +141,8 @@ pub(crate) async fn post( Form(form): Form>, ) -> Result { let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); + + let ip_address = activity_tracker.ip(); if !site_config.password_registration_enabled { return Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()); } @@ -296,49 +298,64 @@ pub(crate) async fn post( return Ok((cookie_jar, Html(content)).into_response()); } - let user = repo.user().add(&mut rng, &clock, form.username).await?; - - if let Some(tos_uri) = &site_config.tos_uri { - repo.user_terms() - .accept_terms(&mut rng, &clock, &user, tos_uri.clone()) - .await?; - } - - let password = Zeroizing::new(form.password.into_bytes()); - let (version, hashed_password) = password_manager.hash(&mut rng, password).await?; - let user_password = repo - .user_password() - .add(&mut rng, &clock, &user, version, hashed_password, None) + let post_auth_action = query + .post_auth_action + .map(serde_json::to_value) + .transpose()?; + let registration = repo + .user_registration() + .add( + &mut rng, + &clock, + form.username, + ip_address, + user_agent, + post_auth_action, + ) .await?; - let user_email = repo + let registration = if let Some(tos_uri) = &site_config.tos_uri { + repo.user_registration() + .set_terms_url(registration, tos_uri.clone()) + .await? + } else { + registration + }; + + // Create a new user email authentication session + let user_email_authentication = repo .user_email() - .add(&mut rng, &clock, &user, form.email) + .add_authentication_for_registration(&mut rng, &clock, form.email, ®istration) .await?; - let next = mas_router::AccountVerifyEmail::new(user_email.id).and_maybe(query.post_auth_action); - - let session = repo - .browser_session() - .add(&mut rng, &clock, &user, user_agent) + // Schedule a job to verify the email + repo.queue_job() + .schedule_job( + &mut rng, + &clock, + SendEmailAuthenticationCodeJob::new(&user_email_authentication, locale.to_string()), + ) .await?; - repo.browser_session() - .authenticate_with_password(&mut rng, &clock, &session, &user_password) + let registration = repo + .user_registration() + .set_email_authentication(registration, &user_email_authentication) .await?; - repo.queue_job() - .schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user)) + // Hash the password + let password = Zeroizing::new(form.password.into_bytes()); + let (version, hashed_password) = password_manager.hash(&mut rng, password).await?; + + // Add the password to the registration + let registration = repo + .user_registration() + .set_password(registration, hashed_password, version) .await?; repo.save().await?; - activity_tracker - .record_browser_session(&clock, &session) - .await; - - let cookie_jar = cookie_jar.set_session(&session); - Ok((cookie_jar, url_builder.redirect(&next)).into_response()) + // TODO: redirect to the next step on the registration + Ok(format!("{}", registration.id).into_response()) } async fn render( @@ -451,16 +468,23 @@ mod tests { let request = cookies.with_cookies(request); let response = state.request(request).await; cookies.save_cookies(&response); - response.assert_status(StatusCode::SEE_OTHER); - - // Now if we get to the home page, we should see the user's username - let request = Request::get("/").empty(); - let request = cookies.with_cookies(request); - let response = state.request(request).await; - cookies.save_cookies(&response); response.assert_status(StatusCode::OK); - response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); - assert!(response.body().contains("john")); + + // The handler gives us the registration ID in the body for now + let id = response.body().parse().unwrap(); + // There should be a new registration in the database + let mut repo = state.repository().await.unwrap(); + let registration = repo.user_registration().lookup(id).await.unwrap().unwrap(); + assert_eq!(registration.username, "john".to_owned()); + assert!(registration.password.is_some()); + + let email_authentication = repo + .user_email() + .lookup_authentication(registration.email_authentication_id.unwrap()) + .await + .unwrap() + .unwrap(); + assert_eq!(email_authentication.email, "john@example.com"); } /// When the two password fields mismatch, it should give an error diff --git a/crates/tasks/src/email.rs b/crates/tasks/src/email.rs index fe2c2a51a..a0e7eaab6 100644 --- a/crates/tasks/src/email.rs +++ b/crates/tasks/src/email.rs @@ -78,6 +78,22 @@ impl RunnableJob for SendEmailAuthenticationCodeJob { None }; + // Load the registration, if any + let registration = + if let Some(registration_id) = user_email_authentication.user_registration_id { + Some( + repo.user_registration() + .lookup(registration_id) + .await + .map_err(JobError::retry)? + .ok_or(JobError::fail(anyhow::anyhow!( + "Failed to load user registration" + )))?, + ) + } else { + None + }; + // Generate a new 6-digit authentication code let range = Uniform::::from(0..1_000_000); let code = rng.sample(range); @@ -98,14 +114,17 @@ impl RunnableJob for SendEmailAuthenticationCodeJob { .email .parse() .map_err(JobError::fail)?; - let username = browser_session.as_ref().map(|s| s.user.username.clone()); + let username_from_session = browser_session.as_ref().map(|s| s.user.username.clone()); + let username_from_registration = registration.as_ref().map(|r| r.username.clone()); + let username = username_from_registration.or(username_from_session); let mailbox = Mailbox::new(username, address); info!("Sending email verification code to {}", mailbox); let language = self.language().parse().map_err(JobError::fail)?; - let context = EmailVerificationContext::new(code, browser_session).with_language(language); + let context = EmailVerificationContext::new(code, browser_session, registration) + .with_language(language); mailer .send_verification_email(mailbox, &context) .await diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index b8e407f92..1f0fa958f 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -23,7 +23,7 @@ use mas_data_model::{ DeviceCodeGrant, UpstreamOAuthLink, UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderTokenAuthMethod, User, UserAgent, UserEmail, UserEmailAuthenticationCode, - UserRecoverySession, + UserRecoverySession, UserRegistration, }; use mas_i18n::DataLocale; use mas_iana::jose::JsonWebSignatureAlg; @@ -878,7 +878,10 @@ impl TemplateContext for EmailRecoveryContext { /// Context used by the `emails/verification.{txt,html,subject}` templates #[derive(Serialize)] pub struct EmailVerificationContext { + #[serde(skip_serializing_if = "Option::is_none")] browser_session: Option, + #[serde(skip_serializing_if = "Option::is_none")] + user_registration: Option, authentication_code: UserEmailAuthenticationCode, } @@ -888,9 +891,11 @@ impl EmailVerificationContext { pub fn new( authentication_code: UserEmailAuthenticationCode, browser_session: Option, + user_registration: Option, ) -> Self { Self { browser_session, + user_registration, authentication_code, } } @@ -926,6 +931,7 @@ impl TemplateContext for EmailVerificationContext { Self { browser_session: Some(browser_session), + user_registration: None, authentication_code, } }) diff --git a/templates/emails/verification.html b/templates/emails/verification.html index f958e3382..58378dc27 100644 --- a/templates/emails/verification.html +++ b/templates/emails/verification.html @@ -8,6 +8,12 @@ {%- set _ = translator(lang) -%} -{{ _("mas.emails.greeting", username=browser_session.user.username | default("user")) }}
+{%- if browser_session is defined -%} + {%- set username = browser_session.user.username -%} +{%- elif user_registration is defined -%} + {%- set username = user_registration.username -%} +{%- endif -%} + +{{ _("mas.emails.greeting", username=(username|default("user"))) }}

{{ _("mas.emails.verify.body_html", code=authentication_code.code) }}
diff --git a/templates/emails/verification.txt b/templates/emails/verification.txt index f52a9ec5d..7afb408ff 100644 --- a/templates/emails/verification.txt +++ b/templates/emails/verification.txt @@ -8,6 +8,12 @@ Please see LICENSE in the repository root for full details. {%- set _ = translator(lang) -%} -{{ _("mas.emails.greeting", username=browser_session.user.username | default("user")) }} +{%- if browser_session is defined -%} + {%- set username = browser_session.user.username -%} +{%- elif user_registration is defined -%} + {%- set username = user_registration.username -%} +{%- endif -%} + +{{ _("mas.emails.greeting", username=(username|default("user"))) }} {{ _("mas.emails.verify.body_text", code=authentication_code.code) }} diff --git a/translations/en.json b/translations/en.json index 4835bf4b7..33711d4f4 100644 --- a/translations/en.json +++ b/translations/en.json @@ -219,7 +219,7 @@ "emails": { "greeting": "Hello %(username)s,", "@greeting": { - "context": "emails/verification.html:11:3-85, emails/verification.txt:11:3-85", + "context": "emails/verification.html:17:3-64, emails/verification.txt:17:3-64", "description": "Greeting at the top of emails sent to the user" }, "recovery": { @@ -251,12 +251,12 @@ "verify": { "body_html": "Your verification code to confirm this email address is: %(code)s", "@body_html": { - "context": "emails/verification.html:13:3-66", + "context": "emails/verification.html:19:3-66", "description": "The body of the email sent to verify an email address (HTML)" }, "body_text": "Your verification code to confirm this email address is: %(code)s", "@body_text": { - "context": "emails/verification.txt:13:3-66", + "context": "emails/verification.txt:19:3-66", "description": "The body of the email sent to verify an email address (text)" }, "subject": "Your email verification code is: %(code)s", @@ -327,7 +327,7 @@ }, "continue_with_provider": "Continue with %(provider)s", "@continue_with_provider": { - "context": "pages/login.html:75:15-67, pages/register/index.html:49:15-67", + "context": "pages/login.html:75:15-67, pages/register/index.html:53:15-67", "description": "Button to log in with an upstream provider" }, "description": "Please sign in to continue:", @@ -513,12 +513,12 @@ "register": { "call_to_login": "Already have an account?", "@call_to_login": { - "context": "pages/register/index.html:55:35-66, pages/register/password.html:77:33-64", + "context": "pages/register/index.html:59:35-66, pages/register/password.html:77:33-64", "description": "Displayed on the registration page to suggest to log in instead" }, "continue_with_email": "Continue with email address", "@continue_with_email": { - "context": "pages/register/index.html:40:30-67" + "context": "pages/register/index.html:44:30-67" }, "create_account": { "description": "Choose a username to continue.", @@ -682,4 +682,4 @@ } } } -} +} \ No newline at end of file From f8517a598200b68390419b4676d2413a34e0526b Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 14 Jan 2025 15:32:05 +0100 Subject: [PATCH 15/26] Implement email verification in the registration flow --- crates/handlers/src/lib.rs | 6 +- .../handlers/src/views/account/emails/mod.rs | 7 - .../src/views/account/emails/verify.rs | 135 ---------- crates/handlers/src/views/mod.rs | 1 - crates/handlers/src/views/register/mod.rs | 1 + .../handlers/src/views/register/password.rs | 20 +- .../views/{account => register/steps}/mod.rs | 5 +- .../src/views/register/steps/verify_email.rs | 253 ++++++++++++++++++ crates/router/src/endpoints.rs | 36 +-- crates/templates/src/context.rs | 34 +-- crates/templates/src/lib.rs | 26 +- .../steps/verify_email.html} | 4 +- translations/en.json | 8 +- 13 files changed, 320 insertions(+), 216 deletions(-) delete mode 100644 crates/handlers/src/views/account/emails/mod.rs delete mode 100644 crates/handlers/src/views/account/emails/verify.rs rename crates/handlers/src/views/{account => register/steps}/mod.rs (50%) create mode 100644 crates/handlers/src/views/register/steps/verify_email.rs rename templates/pages/{account/emails/verify.html => register/steps/verify_email.html} (94%) diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index d7b39ff24..39e45a8e5 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -379,9 +379,9 @@ where get(self::views::register::password::get).post(self::views::register::password::post), ) .route( - mas_router::AccountVerifyEmail::route(), - get(self::views::account::emails::verify::get) - .post(self::views::account::emails::verify::post), + mas_router::RegisterVerifyEmail::route(), + get(self::views::register::steps::verify_email::get) + .post(self::views::register::steps::verify_email::post), ) .route( mas_router::AccountRecoveryStart::route(), diff --git a/crates/handlers/src/views/account/emails/mod.rs b/crates/handlers/src/views/account/emails/mod.rs deleted file mode 100644 index 86aa5de33..000000000 --- a/crates/handlers/src/views/account/emails/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright 2024, 2025 New Vector Ltd. -// Copyright 2022-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. - -pub mod verify; diff --git a/crates/handlers/src/views/account/emails/verify.rs b/crates/handlers/src/views/account/emails/verify.rs deleted file mode 100644 index abb505330..000000000 --- a/crates/handlers/src/views/account/emails/verify.rs +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright 2024 New Vector Ltd. -// Copyright 2022-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. - -use anyhow::Context; -use axum::{ - extract::{Form, Path, Query, State}, - response::{Html, IntoResponse, Response}, -}; -use mas_axum_utils::{ - cookies::CookieJar, - csrf::{CsrfExt, ProtectedForm}, - FancyError, SessionInfoExt, -}; -use mas_router::UrlBuilder; -use mas_storage::{ - queue::{ProvisionUserJob, QueueJobRepositoryExt as _}, - user::UserEmailRepository, - BoxClock, BoxRepository, BoxRng, RepositoryAccess, -}; -use mas_templates::{EmailVerificationPageContext, TemplateContext, Templates}; -use serde::Deserialize; -use ulid::Ulid; - -use crate::{views::shared::OptionalPostAuthAction, BoundActivityTracker, PreferredLanguage}; - -#[expect(dead_code)] -#[derive(Deserialize, Debug)] -pub struct CodeForm { - code: String, -} - -#[tracing::instrument( - name = "handlers.views.account_email_verify.get", - fields(user_email.id = %id), - skip_all, - err, -)] -pub(crate) async fn get( - mut rng: BoxRng, - clock: BoxClock, - PreferredLanguage(locale): PreferredLanguage, - State(templates): State, - State(url_builder): State, - activity_tracker: BoundActivityTracker, - mut repo: BoxRepository, - Query(_query): Query, - Path(id): Path, - cookie_jar: CookieJar, -) -> Result { - let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); - let (session_info, cookie_jar) = cookie_jar.session_info(); - - let maybe_session = session_info.load_session(&mut repo).await?; - - let Some(session) = maybe_session else { - let login = mas_router::Login::default(); - return Ok((cookie_jar, url_builder.redirect(&login)).into_response()); - }; - - activity_tracker - .record_browser_session(&clock, &session) - .await; - - let user_email = repo - .user_email() - .lookup(id) - .await? - .filter(|u| u.user_id == session.user.id) - .context("Could not find user email")?; - - let ctx = EmailVerificationPageContext::new(user_email) - .with_session(session) - .with_csrf(csrf_token.form_value()) - .with_language(locale); - - let content = templates.render_account_verify_email(&ctx)?; - - Ok((cookie_jar, Html(content)).into_response()) -} - -#[tracing::instrument( - name = "handlers.views.account_email_verify.post", - fields(user_email.id = %id), - skip_all, - err, -)] -pub(crate) async fn post( - clock: BoxClock, - mut rng: BoxRng, - mut repo: BoxRepository, - cookie_jar: CookieJar, - State(url_builder): State, - activity_tracker: BoundActivityTracker, - Query(query): Query, - Path(id): Path, - Form(form): Form>, -) -> Result { - let _form = cookie_jar.verify_form(&clock, form)?; - let (session_info, cookie_jar) = cookie_jar.session_info(); - - let maybe_session = session_info.load_session(&mut repo).await?; - - let Some(session) = maybe_session else { - let login = mas_router::Login::default(); - return Ok((cookie_jar, url_builder.redirect(&login)).into_response()); - }; - - let _user_email = repo - .user_email() - .lookup(id) - .await? - .filter(|u| u.user_id == session.user.id) - .context("Could not find user email")?; - - // XXX: this logic should be extracted somewhere else, since most of it is - // duplicated in mas_graphql - - // TODO: Use the new email authentication codes - - repo.queue_job() - .schedule_job(&mut rng, &clock, ProvisionUserJob::new(&session.user)) - .await?; - - repo.save().await?; - - activity_tracker - .record_browser_session(&clock, &session) - .await; - - let destination = query.go_next_or_default(&url_builder, &mas_router::Account::default()); - Ok((cookie_jar, destination).into_response()) -} diff --git a/crates/handlers/src/views/mod.rs b/crates/handlers/src/views/mod.rs index 1ce31f3cf..336ec9f2f 100644 --- a/crates/handlers/src/views/mod.rs +++ b/crates/handlers/src/views/mod.rs @@ -4,7 +4,6 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. -pub mod account; pub mod app; pub mod index; pub mod login; diff --git a/crates/handlers/src/views/register/mod.rs b/crates/handlers/src/views/register/mod.rs index a532f4355..dbd8a25ed 100644 --- a/crates/handlers/src/views/register/mod.rs +++ b/crates/handlers/src/views/register/mod.rs @@ -18,6 +18,7 @@ use super::shared::OptionalPostAuthAction; use crate::{BoundActivityTracker, PreferredLanguage}; pub(crate) mod password; +pub(crate) mod steps; #[tracing::instrument(name = "handlers.views.register.get", skip_all, err)] pub(crate) async fn get( diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs index a4fbc6763..55ae7f45d 100644 --- a/crates/handlers/src/views/register/password.rs +++ b/crates/handlers/src/views/register/password.rs @@ -354,8 +354,9 @@ pub(crate) async fn post( repo.save().await?; - // TODO: redirect to the next step on the registration - Ok(format!("{}", registration.id).into_response()) + Ok(url_builder + .redirect(&mas_router::RegisterVerifyEmail::new(registration.id)) + .into_response()) } async fn render( @@ -468,10 +469,19 @@ mod tests { let request = cookies.with_cookies(request); let response = state.request(request).await; cookies.save_cookies(&response); - response.assert_status(StatusCode::OK); + response.assert_status(StatusCode::SEE_OTHER); + let location = response.headers().get(LOCATION).unwrap(); + + // The handler redirects with the ID as the last portion of the path + let id = location + .to_str() + .unwrap() + .rsplit('/') + .next() + .unwrap() + .parse() + .unwrap(); - // The handler gives us the registration ID in the body for now - let id = response.body().parse().unwrap(); // There should be a new registration in the database let mut repo = state.repository().await.unwrap(); let registration = repo.user_registration().lookup(id).await.unwrap().unwrap(); diff --git a/crates/handlers/src/views/account/mod.rs b/crates/handlers/src/views/register/steps/mod.rs similarity index 50% rename from crates/handlers/src/views/account/mod.rs rename to crates/handlers/src/views/register/steps/mod.rs index f85da5266..8d658ed85 100644 --- a/crates/handlers/src/views/account/mod.rs +++ b/crates/handlers/src/views/register/steps/mod.rs @@ -1,7 +1,6 @@ -// Copyright 2024 New Vector Ltd. -// Copyright 2021-2024 The Matrix.org Foundation C.I.C. +// Copyright 2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. -pub mod emails; +pub(crate) mod verify_email; diff --git a/crates/handlers/src/views/register/steps/verify_email.rs b/crates/handlers/src/views/register/steps/verify_email.rs new file mode 100644 index 000000000..b5a0e4664 --- /dev/null +++ b/crates/handlers/src/views/register/steps/verify_email.rs @@ -0,0 +1,253 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use anyhow::Context; +use axum::{ + extract::{Form, Path, State}, + response::{Html, IntoResponse, Response}, +}; +use axum_extra::TypedHeader; +use mas_axum_utils::{ + cookies::CookieJar, + csrf::{CsrfExt, ProtectedForm}, + FancyError, SessionInfoExt, +}; +use mas_data_model::UserAgent; +use mas_router::{PostAuthAction, UrlBuilder}; +use mas_storage::{ + queue::{ProvisionUserJob, QueueJobRepositoryExt as _}, + user::UserEmailRepository, + BoxClock, BoxRepository, BoxRng, RepositoryAccess, +}; +use mas_templates::{ + FieldError, RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField, + TemplateContext, Templates, ToFormState, +}; +use serde::{Deserialize, Serialize}; +use ulid::Ulid; + +use crate::{views::shared::OptionalPostAuthAction, BoundActivityTracker, PreferredLanguage}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct CodeForm { + code: String, +} + +impl ToFormState for CodeForm { + type Field = mas_templates::RegisterStepsVerifyEmailFormField; +} + +#[tracing::instrument( + name = "handlers.views.register.steps.verify_email.get", + fields(user_registration.id = %id), + skip_all, + err, +)] +pub(crate) async fn get( + mut rng: BoxRng, + clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, + State(templates): State, + State(url_builder): State, + mut repo: BoxRepository, + Path(id): Path, + cookie_jar: CookieJar, +) -> Result { + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + + let registration = repo + .user_registration() + .lookup(id) + .await? + .context("Could not find user registration")?; + + // If the registration is completed, we can go to the registration destination + // XXX: this might not be the right thing to do? Maybe an error page would be + // better? + if registration.completed_at.is_some() { + let post_auth_action: Option = registration + .post_auth_action + .map(serde_json::from_value) + .transpose()?; + + return Ok(OptionalPostAuthAction::from(post_auth_action) + .go_next(&url_builder) + .into_response()); + } + + let email_authentication_id = registration + .email_authentication_id + .context("No email authentication started for this registration")?; + let email_authentication = repo + .user_email() + .lookup_authentication(email_authentication_id) + .await? + .context("Could not find email authentication")?; + + if email_authentication.completed_at.is_some() { + // XXX: display a better error here + return Err(FancyError::from(anyhow::anyhow!( + "Email authentication already completed" + ))); + } + + let ctx = RegisterStepsVerifyEmailContext::new(email_authentication) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let content = templates.render_register_steps_verify_email(&ctx)?; + + Ok((cookie_jar, Html(content)).into_response()) +} + +#[tracing::instrument( + name = "handlers.views.account_email_verify.post", + fields(user_email.id = %id), + skip_all, + err, +)] +pub(crate) async fn post( + clock: BoxClock, + mut rng: BoxRng, + PreferredLanguage(locale): PreferredLanguage, + State(templates): State, + mut repo: BoxRepository, + cookie_jar: CookieJar, + user_agent: Option>, + State(url_builder): State, + activity_tracker: BoundActivityTracker, + Path(id): Path, + Form(form): Form>, +) -> Result { + let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); + let form = cookie_jar.verify_form(&clock, form)?; + + let registration = repo + .user_registration() + .lookup(id) + .await? + .context("Could not find user registration")?; + + // If the registration is completed, we can go to the registration destination + // XXX: this might not be the right thing to do? Maybe an error page would be + // better? + if registration.completed_at.is_some() { + let post_auth_action: Option = registration + .post_auth_action + .map(serde_json::from_value) + .transpose()?; + + return Ok(OptionalPostAuthAction::from(post_auth_action) + .go_next(&url_builder) + .into_response()); + } + + let email_authentication_id = registration + .email_authentication_id + .context("No email authentication started for this registration")?; + let email_authentication = repo + .user_email() + .lookup_authentication(email_authentication_id) + .await? + .context("Could not find email authentication")?; + + if email_authentication.completed_at.is_some() { + // XXX: display a better error here + return Err(FancyError::from(anyhow::anyhow!( + "Email authentication already completed" + ))); + } + + let Some(code) = repo + .user_email() + .find_authentication_code(&email_authentication, &form.code) + .await? + else { + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + let ctx = + RegisterStepsVerifyEmailContext::new(email_authentication) + .with_form_state(form.to_form_state().with_error_on_field( + RegisterStepsVerifyEmailFormField::Code, + FieldError::Invalid, + )) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let content = templates.render_register_steps_verify_email(&ctx)?; + + return Ok((cookie_jar, Html(content)).into_response()); + }; + + let email_authentication = repo + .user_email() + .complete_authentication(&clock, email_authentication, &code) + .await?; + + let registration = repo + .user_registration() + .complete(&clock, registration) + .await?; + + // XXX: this should move somewhere else, and it doesn't check for uniqueness + let user = repo + .user() + .add(&mut rng, &clock, registration.username) + .await?; + let user_session = repo + .browser_session() + .add(&mut rng, &clock, &user, user_agent) + .await?; + + repo.user_email() + .add(&mut rng, &clock, &user, email_authentication.email) + .await?; + + if let Some(password) = registration.password { + let user_password = repo + .user_password() + .add( + &mut rng, + &clock, + &user, + password.version, + password.hashed_password, + None, + ) + .await?; + + repo.browser_session() + .authenticate_with_password(&mut rng, &clock, &user_session, &user_password) + .await?; + } + + if let Some(terms_url) = registration.terms_url { + repo.user_terms() + .accept_terms(&mut rng, &clock, &user, terms_url) + .await?; + } + + repo.queue_job() + .schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user)) + .await?; + + repo.save().await?; + + activity_tracker + .record_browser_session(&clock, &user_session) + .await; + + let post_auth_action: Option = registration + .post_auth_action + .map(serde_json::from_value) + .transpose()?; + + let cookie_jar = cookie_jar.set_session(&user_session); + + return Ok(( + cookie_jar, + OptionalPostAuthAction::from(post_auth_action).go_next(&url_builder), + ) + .into_response()); +} diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index d47368167..b46de76f5 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -444,47 +444,27 @@ impl From> for PasswordRegister { } } -/// `GET|POST /verify-email/:id` +/// `GET|POST /register/steps/verify-email/:id` #[derive(Debug, Clone)] -pub struct AccountVerifyEmail { +pub struct RegisterVerifyEmail { id: Ulid, - post_auth_action: Option, } -impl AccountVerifyEmail { +impl RegisterVerifyEmail { #[must_use] pub fn new(id: Ulid) -> Self { - Self { - id, - post_auth_action: None, - } - } - - #[must_use] - pub fn and_maybe(mut self, action: Option) -> Self { - self.post_auth_action = action; - self - } - - #[must_use] - pub fn and_then(mut self, action: PostAuthAction) -> Self { - self.post_auth_action = Some(action); - self + Self { id } } } -impl Route for AccountVerifyEmail { - type Query = PostAuthAction; +impl Route for RegisterVerifyEmail { + type Query = (); fn route() -> &'static str { - "/verify-email/:id" - } - - fn query(&self) -> Option<&Self::Query> { - self.post_auth_action.as_ref() + "/register/steps/verify-email/:id" } fn path(&self) -> std::borrow::Cow<'static, str> { - format!("/verify-email/{}", self.id).into() + format!("/register/steps/verify-email/{}", self.id).into() } } diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 1f0fa958f..fea88e58d 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -22,8 +22,8 @@ use mas_data_model::{ AuthorizationGrant, BrowserSession, Client, CompatSsoLogin, CompatSsoLoginState, DeviceCodeGrant, UpstreamOAuthLink, UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderPkceMode, - UpstreamOAuthProviderTokenAuthMethod, User, UserAgent, UserEmail, UserEmailAuthenticationCode, - UserRecoverySession, UserRegistration, + UpstreamOAuthProviderTokenAuthMethod, User, UserAgent, UserEmailAuthentication, + UserEmailAuthenticationCode, UserRecoverySession, UserRegistration, }; use mas_i18n::DataLocale; use mas_iana::jose::JsonWebSignatureAlg; @@ -942,12 +942,12 @@ impl TemplateContext for EmailVerificationContext { /// Fields of the email verification form #[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] #[serde(rename_all = "snake_case")] -pub enum EmailVerificationFormField { +pub enum RegisterStepsVerifyEmailFormField { /// The code field Code, } -impl FormField for EmailVerificationFormField { +impl FormField for RegisterStepsVerifyEmailFormField { fn keep(&self) -> bool { match self { Self::Code => true, @@ -955,45 +955,47 @@ impl FormField for EmailVerificationFormField { } } -/// Context used by the `pages/account/verify.html` templates +/// Context used by the `pages/register/steps/verify_email.html` templates #[derive(Serialize)] -pub struct EmailVerificationPageContext { - form: FormState, - email: UserEmail, +pub struct RegisterStepsVerifyEmailContext { + form: FormState, + authentication: UserEmailAuthentication, } -impl EmailVerificationPageContext { +impl RegisterStepsVerifyEmailContext { /// Constructs a context for the email verification page #[must_use] - pub fn new(email: UserEmail) -> Self { + pub fn new(authentication: UserEmailAuthentication) -> Self { Self { form: FormState::default(), - email, + authentication, } } /// Set the form state #[must_use] - pub fn with_form_state(self, form: FormState) -> Self { + pub fn with_form_state(self, form: FormState) -> Self { Self { form, ..self } } } -impl TemplateContext for EmailVerificationPageContext { +impl TemplateContext for RegisterStepsVerifyEmailContext { fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec where Self: Sized, { - let email = UserEmail { + let authentication = UserEmailAuthentication { id: Ulid::from_datetime_with_source(now.into(), rng), - user_id: Ulid::from_datetime_with_source(now.into(), rng), + user_session_id: None, + user_registration_id: None, email: "foobar@example.com".to_owned(), created_at: now, + completed_at: None, }; vec![Self { form: FormState::default(), - email, + authentication, }] } } diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 0f51d4826..1ae43ed24 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -36,14 +36,15 @@ pub use self::{ context::{ ApiDocContext, AppContext, CompatSsoContext, ConsentContext, DeviceConsentContext, DeviceLinkContext, DeviceLinkFormField, EmailRecoveryContext, EmailVerificationContext, - EmailVerificationPageContext, EmptyContext, ErrorContext, FormPostContext, IndexContext, - LoginContext, LoginFormField, NotFoundContext, PasswordRegisterContext, - PolicyViolationContext, PostAuthContext, PostAuthContextInner, ReauthContext, - ReauthFormField, RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField, - RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext, - RegisterFormField, SiteBranding, SiteConfigExt, SiteFeatures, TemplateContext, - UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField, - UpstreamSuggestLink, WithCaptcha, WithCsrf, WithLanguage, WithOptionalSession, WithSession, + EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField, + NotFoundContext, PasswordRegisterContext, PolicyViolationContext, PostAuthContext, + PostAuthContextInner, ReauthContext, ReauthFormField, RecoveryExpiredContext, + RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext, + RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField, + RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField, SiteBranding, + SiteConfigExt, SiteFeatures, TemplateContext, UpstreamExistingLinkContext, + UpstreamRegister, UpstreamRegisterFormField, UpstreamSuggestLink, WithCaptcha, WithCsrf, + WithLanguage, WithOptionalSession, WithSession, }, forms::{FieldError, FormError, FormField, FormState, ToFormState}, }; @@ -331,6 +332,9 @@ register_templates! { /// Render the password registration page pub fn render_password_register(WithLanguage>>) { "pages/register/password.html" } + /// Render the email verification page + pub fn render_register_steps_verify_email(WithLanguage>) { "pages/register/steps/verify_email.html" } + /// Render the client consent page pub fn render_consent(WithLanguage>>) { "pages/consent.html" } @@ -343,9 +347,6 @@ register_templates! { /// Render the home page pub fn render_index(WithLanguage>>) { "pages/index.html" } - /// Render the email verification page - pub fn render_account_verify_email(WithLanguage>>) { "pages/account/emails/verify.html" } - /// Render the account recovery start page pub fn render_recovery_start(WithLanguage>) { "pages/recovery/start.html" } @@ -425,11 +426,12 @@ impl Templates { check::render_swagger_callback(self, now, rng)?; check::render_login(self, now, rng)?; check::render_register(self, now, rng)?; + check::render_password_register(self, now, rng)?; + check::render_register_steps_verify_email(self, now, rng)?; check::render_consent(self, now, rng)?; check::render_policy_violation(self, now, rng)?; check::render_sso_login(self, now, rng)?; check::render_index(self, now, rng)?; - check::render_account_verify_email(self, now, rng)?; check::render_recovery_start(self, now, rng)?; check::render_recovery_progress(self, now, rng)?; check::render_recovery_finish(self, now, rng)?; diff --git a/templates/pages/account/emails/verify.html b/templates/pages/register/steps/verify_email.html similarity index 94% rename from templates/pages/account/emails/verify.html rename to templates/pages/register/steps/verify_email.html index e3330be4e..491721423 100644 --- a/templates/pages/account/emails/verify.html +++ b/templates/pages/register/steps/verify_email.html @@ -15,7 +15,7 @@

{{ _("mas.verify_email.headline") }}

-

{{ _("mas.verify_email.description", email=email.email) }}

+

{{ _("mas.verify_email.description", email=authentication.email) }}

@@ -30,7 +30,7 @@

{{ _("mas.verify_email.headline") }}

- {% call(f) field.field(label=_("mas.verify_email.6_digit_code"), name="code", class="mb-4 self-center") %} + {% call(f) field.field(label=_("mas.verify_email.6_digit_code"), name="code", form_state=form, class="mb-4 self-center") %}
%(email)s", "@description": { - "context": "pages/account/emails/verify.html:18:25-77" + "context": "pages/register/steps/verify_email.html:18:25-86" }, "headline": "Verify your email", "@headline": { - "context": "pages/account/emails/verify.html:17:27-57" + "context": "pages/register/steps/verify_email.html:17:27-57" } } } From 621b64889f7f506035f71f5e30174e0d55e693af Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 14 Jan 2025 16:52:53 +0100 Subject: [PATCH 16/26] Check that the email isn't used during the registration process --- crates/handlers/src/views/register/password.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs index 55ae7f45d..6853f43c9 100644 --- a/crates/handlers/src/views/register/password.rs +++ b/crates/handlers/src/views/register/password.rs @@ -25,7 +25,7 @@ use mas_policy::Policy; use mas_router::UrlBuilder; use mas_storage::{ queue::{QueueJobRepositoryExt as _, SendEmailAuthenticationCodeJob}, - user::{UserEmailRepository, UserRepository}, + user::{UserEmailFilter, UserEmailRepository, UserRepository}, BoxClock, BoxRepository, BoxRng, RepositoryAccess, }; use mas_templates::{ @@ -194,6 +194,13 @@ pub(crate) async fn post( state.add_error_on_field(RegisterFormField::Email, FieldError::Required); } else if Address::from_str(&form.email).is_err() { state.add_error_on_field(RegisterFormField::Email, FieldError::Invalid); + } else if repo + .user_email() + .count(UserEmailFilter::new().for_email(&form.email)) + .await? + > 0 + { + state.add_error_on_field(RegisterFormField::Email, FieldError::Exists); } if form.password.is_empty() { From 36aa1a0cbfc707396f4e91b2d4083de726abae1e Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 15 Jan 2025 12:45:48 +0100 Subject: [PATCH 17/26] Move the finishing of registration to a dedicated view --- crates/handlers/src/lib.rs | 4 + .../handlers/src/views/register/password.rs | 6 +- .../src/views/register/steps/finish.rs | 174 ++++++++++++++++++ .../handlers/src/views/register/steps/mod.rs | 1 + .../src/views/register/steps/verify_email.rs | 97 ++-------- crates/router/src/endpoints.rs | 30 ++- 6 files changed, 225 insertions(+), 87 deletions(-) create mode 100644 crates/handlers/src/views/register/steps/finish.rs diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 39e45a8e5..66876a25b 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -383,6 +383,10 @@ where get(self::views::register::steps::verify_email::get) .post(self::views::register::steps::verify_email::post), ) + .route( + mas_router::RegisterFinish::route(), + get(self::views::register::steps::finish::get), + ) .route( mas_router::AccountRecoveryStart::route(), get(self::views::recovery::start::get).post(self::views::recovery::start::post), diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs index 6853f43c9..37cfd861b 100644 --- a/crates/handlers/src/views/register/password.rs +++ b/crates/handlers/src/views/register/password.rs @@ -362,7 +362,7 @@ pub(crate) async fn post( repo.save().await?; Ok(url_builder - .redirect(&mas_router::RegisterVerifyEmail::new(registration.id)) + .redirect(&mas_router::RegisterFinish::new(registration.id)) .into_response()) } @@ -479,12 +479,12 @@ mod tests { response.assert_status(StatusCode::SEE_OTHER); let location = response.headers().get(LOCATION).unwrap(); - // The handler redirects with the ID as the last portion of the path + // The handler redirects with the ID as the second to last portion of the path let id = location .to_str() .unwrap() .rsplit('/') - .next() + .nth(1) .unwrap() .parse() .unwrap(); diff --git a/crates/handlers/src/views/register/steps/finish.rs b/crates/handlers/src/views/register/steps/finish.rs new file mode 100644 index 000000000..55db0a6c5 --- /dev/null +++ b/crates/handlers/src/views/register/steps/finish.rs @@ -0,0 +1,174 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use anyhow::Context as _; +use axum::{ + extract::{Path, State}, + response::IntoResponse, +}; +use axum_extra::TypedHeader; +use mas_axum_utils::{cookies::CookieJar, FancyError, SessionInfoExt as _}; +use mas_data_model::UserAgent; +use mas_router::{PostAuthAction, UrlBuilder}; +use mas_storage::{ + queue::{ProvisionUserJob, QueueJobRepositoryExt as _}, + user::UserEmailFilter, + BoxClock, BoxRepository, BoxRng, +}; +use ulid::Ulid; + +use crate::{views::shared::OptionalPostAuthAction, BoundActivityTracker}; + +#[tracing::instrument( + name = "handlers.views.register.steps.finish.get", + fields(user_registration.id = %id), + skip_all, + err, +)] +pub(crate) async fn get( + mut rng: BoxRng, + clock: BoxClock, + mut repo: BoxRepository, + activity_tracker: BoundActivityTracker, + user_agent: Option>, + State(url_builder): State, + cookie_jar: CookieJar, + Path(id): Path, +) -> Result { + let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); + let registration = repo + .user_registration() + .lookup(id) + .await? + .context("User registration not found")?; + + // If the registration is completed, we can go to the registration destination + // XXX: this might not be the right thing to do? Maybe an error page would be + // better? + if registration.completed_at.is_some() { + let post_auth_action: Option = registration + .post_auth_action + .map(serde_json::from_value) + .transpose()?; + + return Ok(( + cookie_jar, + OptionalPostAuthAction::from(post_auth_action).go_next(&url_builder), + )); + } + + // Let's perform last minute checks on the registration, especially to avoid + // race conditions where multiple users register with the same username or email + // address + + if repo.user().exists(®istration.username).await? { + return Err(FancyError::from(anyhow::anyhow!( + "Username is already taken" + ))); + } + + // TODO: query the homeserver + + // For now, we require an email address on the registration, but this might + // change in the future + let email_authentication_id = registration + .email_authentication_id + .context("No email authentication started for this registration")?; + let email_authentication = repo + .user_email() + .lookup_authentication(email_authentication_id) + .await? + .context("Could not load the email authentication")?; + + // Check that the email authentication has been completed + if email_authentication.completed_at.is_none() { + return Ok(( + cookie_jar, + url_builder.redirect(&mas_router::RegisterVerifyEmail::new(id)), + )); + } + + // Check that the email address isn't already used + if repo + .user_email() + .count(UserEmailFilter::new().for_email(&email_authentication.email)) + .await? + > 0 + { + return Err(FancyError::from(anyhow::anyhow!( + "Email address is already used" + ))); + } + + // Everuthing is good, let's complete the registration + let registration = repo + .user_registration() + .complete(&clock, registration) + .await?; + + // Now we can start the user creation + let user = repo + .user() + .add(&mut rng, &clock, registration.username) + .await?; + // Also create a browser session which will log the user in + let user_session = repo + .browser_session() + .add(&mut rng, &clock, &user, user_agent) + .await?; + + repo.user_email() + .add(&mut rng, &clock, &user, email_authentication.email) + .await?; + + if let Some(password) = registration.password { + let user_password = repo + .user_password() + .add( + &mut rng, + &clock, + &user, + password.version, + password.hashed_password, + None, + ) + .await?; + + repo.browser_session() + .authenticate_with_password(&mut rng, &clock, &user_session, &user_password) + .await?; + } + + if let Some(terms_url) = registration.terms_url { + repo.user_terms() + .accept_terms(&mut rng, &clock, &user, terms_url) + .await?; + } + + let mut job = ProvisionUserJob::new(&user); + if let Some(display_name) = registration.display_name { + job = job.set_display_name(display_name); + } + repo.queue_job().schedule_job(&mut rng, &clock, job).await?; + + repo.save().await?; + + activity_tracker + .record_browser_session(&clock, &user_session) + .await; + + let post_auth_action: Option = registration + .post_auth_action + .map(serde_json::from_value) + .transpose()?; + + // Login the user with the session we just created + let cookie_jar = cookie_jar.set_session(&user_session); + + return Ok(( + cookie_jar, + OptionalPostAuthAction::from(post_auth_action).go_next(&url_builder), + )); +} diff --git a/crates/handlers/src/views/register/steps/mod.rs b/crates/handlers/src/views/register/steps/mod.rs index 8d658ed85..4d479c352 100644 --- a/crates/handlers/src/views/register/steps/mod.rs +++ b/crates/handlers/src/views/register/steps/mod.rs @@ -3,4 +3,5 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. +pub(crate) mod finish; pub(crate) mod verify_email; diff --git a/crates/handlers/src/views/register/steps/verify_email.rs b/crates/handlers/src/views/register/steps/verify_email.rs index b5a0e4664..4ae18d777 100644 --- a/crates/handlers/src/views/register/steps/verify_email.rs +++ b/crates/handlers/src/views/register/steps/verify_email.rs @@ -8,19 +8,13 @@ use axum::{ extract::{Form, Path, State}, response::{Html, IntoResponse, Response}, }; -use axum_extra::TypedHeader; use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt, ProtectedForm}, - FancyError, SessionInfoExt, + FancyError, }; -use mas_data_model::UserAgent; use mas_router::{PostAuthAction, UrlBuilder}; -use mas_storage::{ - queue::{ProvisionUserJob, QueueJobRepositoryExt as _}, - user::UserEmailRepository, - BoxClock, BoxRepository, BoxRng, RepositoryAccess, -}; +use mas_storage::{user::UserEmailRepository, BoxClock, BoxRepository, BoxRng, RepositoryAccess}; use mas_templates::{ FieldError, RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField, TemplateContext, Templates, ToFormState, @@ -28,7 +22,7 @@ use mas_templates::{ use serde::{Deserialize, Serialize}; use ulid::Ulid; -use crate::{views::shared::OptionalPostAuthAction, BoundActivityTracker, PreferredLanguage}; +use crate::{views::shared::OptionalPostAuthAction, PreferredLanguage}; #[derive(Serialize, Deserialize, Debug)] pub struct CodeForm { @@ -72,8 +66,12 @@ pub(crate) async fn get( .map(serde_json::from_value) .transpose()?; - return Ok(OptionalPostAuthAction::from(post_auth_action) - .go_next(&url_builder) + return Ok(( + cookie_jar, + OptionalPostAuthAction::from(post_auth_action) + .go_next(&url_builder) + .into_response(), + ) .into_response()); } @@ -115,13 +113,10 @@ pub(crate) async fn post( State(templates): State, mut repo: BoxRepository, cookie_jar: CookieJar, - user_agent: Option>, State(url_builder): State, - activity_tracker: BoundActivityTracker, Path(id): Path, Form(form): Form>, ) -> Result { - let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); let form = cookie_jar.verify_form(&clock, form)?; let registration = repo @@ -139,8 +134,10 @@ pub(crate) async fn post( .map(serde_json::from_value) .transpose()?; - return Ok(OptionalPostAuthAction::from(post_auth_action) - .go_next(&url_builder) + return Ok(( + cookie_jar, + OptionalPostAuthAction::from(post_auth_action).go_next(&url_builder), + ) .into_response()); } @@ -180,74 +177,12 @@ pub(crate) async fn post( return Ok((cookie_jar, Html(content)).into_response()); }; - let email_authentication = repo - .user_email() - .complete_authentication(&clock, email_authentication, &code) - .await?; - - let registration = repo - .user_registration() - .complete(&clock, registration) - .await?; - - // XXX: this should move somewhere else, and it doesn't check for uniqueness - let user = repo - .user() - .add(&mut rng, &clock, registration.username) - .await?; - let user_session = repo - .browser_session() - .add(&mut rng, &clock, &user, user_agent) - .await?; - repo.user_email() - .add(&mut rng, &clock, &user, email_authentication.email) - .await?; - - if let Some(password) = registration.password { - let user_password = repo - .user_password() - .add( - &mut rng, - &clock, - &user, - password.version, - password.hashed_password, - None, - ) - .await?; - - repo.browser_session() - .authenticate_with_password(&mut rng, &clock, &user_session, &user_password) - .await?; - } - - if let Some(terms_url) = registration.terms_url { - repo.user_terms() - .accept_terms(&mut rng, &clock, &user, terms_url) - .await?; - } - - repo.queue_job() - .schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user)) + .complete_authentication(&clock, email_authentication, &code) .await?; repo.save().await?; - activity_tracker - .record_browser_session(&clock, &user_session) - .await; - - let post_auth_action: Option = registration - .post_auth_action - .map(serde_json::from_value) - .transpose()?; - - let cookie_jar = cookie_jar.set_session(&user_session); - - return Ok(( - cookie_jar, - OptionalPostAuthAction::from(post_auth_action).go_next(&url_builder), - ) - .into_response()); + let destination = mas_router::RegisterFinish::new(registration.id); + return Ok((cookie_jar, url_builder.redirect(&destination)).into_response()); } diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index b46de76f5..54b1a5cd1 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -444,7 +444,7 @@ impl From> for PasswordRegister { } } -/// `GET|POST /register/steps/verify-email/:id` +/// `GET|POST /register/steps/:id/verify-email` #[derive(Debug, Clone)] pub struct RegisterVerifyEmail { id: Ulid, @@ -460,11 +460,35 @@ impl RegisterVerifyEmail { impl Route for RegisterVerifyEmail { type Query = (); fn route() -> &'static str { - "/register/steps/verify-email/:id" + "/register/steps/:id/verify-email" } fn path(&self) -> std::borrow::Cow<'static, str> { - format!("/register/steps/verify-email/{}", self.id).into() + format!("/register/steps/{}/verify-email", self.id).into() + } +} + +/// `GET /register/steps/:id/finish` +#[derive(Debug, Clone)] +pub struct RegisterFinish { + id: Ulid, +} + +impl RegisterFinish { + #[must_use] + pub const fn new(id: Ulid) -> Self { + Self { id } + } +} + +impl Route for RegisterFinish { + type Query = (); + fn route() -> &'static str { + "/register/steps/:id/finish" + } + + fn path(&self) -> std::borrow::Cow<'static, str> { + format!("/register/steps/{}/finish", self.id).into() } } From f50a386b10c02242964e341ae603c307a78247ae Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 15 Jan 2025 15:27:40 +0100 Subject: [PATCH 18/26] Registration step to set a display name --- crates/handlers/src/lib.rs | 5 + .../src/views/register/steps/display_name.rs | 182 ++++++++++++++++++ .../src/views/register/steps/finish.rs | 8 + .../handlers/src/views/register/steps/mod.rs | 1 + crates/router/src/endpoints.rs | 24 +++ crates/templates/src/context.rs | 51 +++++ crates/templates/src/lib.rs | 5 + templates/components/button.html | 3 +- .../pages/register/steps/display_name.html | 52 +++++ .../pages/register/steps/verify_email.html | 1 - translations/en.json | 20 +- 11 files changed, 348 insertions(+), 4 deletions(-) create mode 100644 crates/handlers/src/views/register/steps/display_name.rs create mode 100644 templates/pages/register/steps/display_name.html diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 66876a25b..1dd7b5ef0 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -383,6 +383,11 @@ where get(self::views::register::steps::verify_email::get) .post(self::views::register::steps::verify_email::post), ) + .route( + mas_router::RegisterDisplayName::route(), + get(self::views::register::steps::display_name::get) + .post(self::views::register::steps::display_name::post), + ) .route( mas_router::RegisterFinish::route(), get(self::views::register::steps::finish::get), diff --git a/crates/handlers/src/views/register/steps/display_name.rs b/crates/handlers/src/views/register/steps/display_name.rs new file mode 100644 index 000000000..1af314ecf --- /dev/null +++ b/crates/handlers/src/views/register/steps/display_name.rs @@ -0,0 +1,182 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use anyhow::Context as _; +use axum::{ + extract::{Path, State}, + response::{Html, IntoResponse, Response}, + Form, +}; +use mas_axum_utils::{ + cookies::CookieJar, + csrf::{CsrfExt as _, ProtectedForm}, + FancyError, +}; +use mas_router::{PostAuthAction, UrlBuilder}; +use mas_storage::{BoxClock, BoxRepository, BoxRng}; +use mas_templates::{ + FieldError, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, + TemplateContext as _, Templates, ToFormState, +}; +use serde::{Deserialize, Serialize}; +use ulid::Ulid; + +use crate::{views::shared::OptionalPostAuthAction, PreferredLanguage}; + +#[derive(Deserialize, Default)] +#[serde(rename_all = "snake_case")] +enum FormAction { + #[default] + Set, + Skip, +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct DisplayNameForm { + #[serde(skip_serializing, default)] + action: FormAction, + #[serde(default)] + display_name: String, +} + +impl ToFormState for DisplayNameForm { + type Field = mas_templates::RegisterStepsDisplayNameFormField; +} + +#[tracing::instrument( + name = "handlers.views.register.steps.display_name.get", + fields(user_registration.id = %id), + skip_all, + err, +)] +pub(crate) async fn get( + mut rng: BoxRng, + clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, + State(templates): State, + State(url_builder): State, + mut repo: BoxRepository, + Path(id): Path, + cookie_jar: CookieJar, +) -> Result { + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + + let registration = repo + .user_registration() + .lookup(id) + .await? + .context("Could not find user registration")?; + + // If the registration is completed, we can go to the registration destination + // XXX: this might not be the right thing to do? Maybe an error page would be + // better? + if registration.completed_at.is_some() { + let post_auth_action: Option = registration + .post_auth_action + .map(serde_json::from_value) + .transpose()?; + + return Ok(( + cookie_jar, + OptionalPostAuthAction::from(post_auth_action) + .go_next(&url_builder) + .into_response(), + ) + .into_response()); + } + + let ctx = RegisterStepsDisplayNameContext::new() + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let content = templates.render_register_steps_display_name(&ctx)?; + + Ok((cookie_jar, Html(content)).into_response()) +} + +#[tracing::instrument( + name = "handlers.views.register.steps.display_name.post", + fields(user_registration.id = %id), + skip_all, + err, +)] +pub(crate) async fn post( + mut rng: BoxRng, + clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, + State(templates): State, + State(url_builder): State, + mut repo: BoxRepository, + Path(id): Path, + cookie_jar: CookieJar, + Form(form): Form>, +) -> Result { + let registration = repo + .user_registration() + .lookup(id) + .await? + .context("Could not find user registration")?; + + // If the registration is completed, we can go to the registration destination + // XXX: this might not be the right thing to do? Maybe an error page would be + // better? + if registration.completed_at.is_some() { + let post_auth_action: Option = registration + .post_auth_action + .map(serde_json::from_value) + .transpose()?; + + return Ok(( + cookie_jar, + OptionalPostAuthAction::from(post_auth_action) + .go_next(&url_builder) + .into_response(), + ) + .into_response()); + } + + let form = cookie_jar.verify_form(&clock, form)?; + + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + + let display_name = match form.action { + FormAction::Set => { + let display_name = form.display_name.trim(); + + if display_name.is_empty() || display_name.len() > 255 { + let ctx = RegisterStepsDisplayNameContext::new() + .with_form_state(form.to_form_state().with_error_on_field( + RegisterStepsDisplayNameFormField::DisplayName, + FieldError::Invalid, + )) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + return Ok(( + cookie_jar, + Html(templates.render_register_steps_display_name(&ctx)?), + ) + .into_response()); + } + + display_name.to_owned() + } + FormAction::Skip => { + // If the user chose to skip, we do the same as Synapse and use the localpart as + // default display name + registration.username.clone() + } + }; + + let registration = repo + .user_registration() + .set_display_name(registration, display_name) + .await?; + + repo.save().await?; + + let destination = mas_router::RegisterFinish::new(registration.id); + return Ok((cookie_jar, url_builder.redirect(&destination)).into_response()); +} diff --git a/crates/handlers/src/views/register/steps/finish.rs b/crates/handlers/src/views/register/steps/finish.rs index 55db0a6c5..2c4679a28 100644 --- a/crates/handlers/src/views/register/steps/finish.rs +++ b/crates/handlers/src/views/register/steps/finish.rs @@ -102,6 +102,14 @@ pub(crate) async fn get( ))); } + // Check that the display name is set + if registration.display_name.is_none() { + return Ok(( + cookie_jar, + url_builder.redirect(&mas_router::RegisterDisplayName::new(registration.id)), + )); + } + // Everuthing is good, let's complete the registration let registration = repo .user_registration() diff --git a/crates/handlers/src/views/register/steps/mod.rs b/crates/handlers/src/views/register/steps/mod.rs index 4d479c352..1b090abb9 100644 --- a/crates/handlers/src/views/register/steps/mod.rs +++ b/crates/handlers/src/views/register/steps/mod.rs @@ -3,5 +3,6 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. +pub(crate) mod display_name; pub(crate) mod finish; pub(crate) mod verify_email; diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index 54b1a5cd1..fa707a66b 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -444,6 +444,30 @@ impl From> for PasswordRegister { } } +/// `GET|POST /register/steps/:id/display-name` +#[derive(Debug, Clone)] +pub struct RegisterDisplayName { + id: Ulid, +} + +impl RegisterDisplayName { + #[must_use] + pub fn new(id: Ulid) -> Self { + Self { id } + } +} + +impl Route for RegisterDisplayName { + type Query = (); + fn route() -> &'static str { + "/register/steps/:id/display-name" + } + + fn path(&self) -> std::borrow::Cow<'static, str> { + format!("/register/steps/{}/display-name", self.id).into() + } +} + /// `GET|POST /register/steps/:id/verify-email` #[derive(Debug, Clone)] pub struct RegisterVerifyEmail { diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index fea88e58d..f5e652ed4 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -1000,6 +1000,57 @@ impl TemplateContext for RegisterStepsVerifyEmailContext { } } +/// Fields for the display name form +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RegisterStepsDisplayNameFormField { + /// The display name + DisplayName, +} + +impl FormField for RegisterStepsDisplayNameFormField { + fn keep(&self) -> bool { + match self { + Self::DisplayName => true, + } + } +} + +/// Context used by the `display_name.html` template +#[derive(Serialize, Default)] +pub struct RegisterStepsDisplayNameContext { + form: FormState, +} + +impl RegisterStepsDisplayNameContext { + /// Constructs a context for the display name page + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Set the form state + #[must_use] + pub fn with_form_state( + mut self, + form_state: FormState, + ) -> Self { + self.form = form_state; + self + } +} + +impl TemplateContext for RegisterStepsDisplayNameContext { + fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec + where + Self: Sized, + { + vec![Self { + form: FormState::default(), + }] + } +} + /// Fields of the account recovery start form #[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] #[serde(rename_all = "snake_case")] diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 1ae43ed24..07e67cc63 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -41,6 +41,7 @@ pub use self::{ PostAuthContextInner, ReauthContext, ReauthFormField, RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField, + RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures, TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField, UpstreamSuggestLink, WithCaptcha, WithCsrf, @@ -335,6 +336,9 @@ register_templates! { /// Render the email verification page pub fn render_register_steps_verify_email(WithLanguage>) { "pages/register/steps/verify_email.html" } + /// Render the display name page + pub fn render_register_steps_display_name(WithLanguage>) { "pages/register/steps/display_name.html" } + /// Render the client consent page pub fn render_consent(WithLanguage>>) { "pages/consent.html" } @@ -428,6 +432,7 @@ impl Templates { check::render_register(self, now, rng)?; check::render_password_register(self, now, rng)?; check::render_register_steps_verify_email(self, now, rng)?; + check::render_register_steps_display_name(self, now, rng)?; check::render_consent(self, now, rng)?; check::render_policy_violation(self, now, rng)?; check::render_sso_login(self, now, rng)?; diff --git a/templates/components/button.html b/templates/components/button.html index e956f1c41..3b037f2f5 100644 --- a/templates/components/button.html +++ b/templates/components/button.html @@ -29,6 +29,7 @@ class="", value="", disabled=False, + kind="primary", size="lg", autocomplete=False, autocorrect=False, @@ -39,7 +40,7 @@ type="{{ type }}" {% if disabled %}disabled{% endif %} class="cpd-button {{ class }}" - data-kind="primary" + data-kind="{{ kind }}" data-size="{{ size }}" {% if autocapitalize %}autocapitilize="{{ autocapitilize }}"{% endif %} {% if autocomplete %}autocomplete="{{ autocomplete }}"{% endif %} diff --git a/templates/pages/register/steps/display_name.html b/templates/pages/register/steps/display_name.html new file mode 100644 index 000000000..b4e774ca5 --- /dev/null +++ b/templates/pages/register/steps/display_name.html @@ -0,0 +1,52 @@ +{# +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +-#} + +{% extends "base.html" %} + +{% block content %} +
+
+ {{ icon.visibility_on() }} +
+
+

{{ _("mas.choose_display_name.headline") }}

+

{{ _("mas.choose_display_name.description") }}

+
+
+ +
+ + {% if form.errors is not empty %} + {% for error in form.errors %} +
+ {{ errors.form_error_message(error=error) }} +
+ {% endfor %} + {% endif %} + + + + + {% call(f) field.field(label=_("common.display_name"), name="display_name", form_state=form, class="mb-4") %} + + {% endcall %} + + {{ button.button(text=_("action.continue")) }} + + +
+ + + {{ button.button(text=_("action.skip"), kind="tertiary") }} +
+
+{% endblock content %} diff --git a/templates/pages/register/steps/verify_email.html b/templates/pages/register/steps/verify_email.html index 491721423..39b1c9abb 100644 --- a/templates/pages/register/steps/verify_email.html +++ b/templates/pages/register/steps/verify_email.html @@ -33,7 +33,6 @@

{{ _("mas.verify_email.headline") }}

{% call(f) field.field(label=_("mas.verify_email.6_digit_code"), name="code", form_state=form, class="mb-4 self-center") %}
%(client_name)s at %(redirect_uri)s wants to access your account.", "@client_wants_access": { From 5851584c1ccfb02f006c8585b26c83751428fd14 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 15 Jan 2025 16:17:17 +0100 Subject: [PATCH 19/26] Link the registration to the browser through a signed cookie --- crates/axum-utils/src/cookies.rs | 7 ++ crates/handlers/src/views/register/cookie.rs | 103 ++++++++++++++++++ crates/handlers/src/views/register/mod.rs | 1 + .../handlers/src/views/register/password.rs | 11 +- .../src/views/register/steps/finish.rs | 14 +++ 5 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 crates/handlers/src/views/register/cookie.rs diff --git a/crates/axum-utils/src/cookies.rs b/crates/axum-utils/src/cookies.rs index 9371d5422..1c9e0eb09 100644 --- a/crates/axum-utils/src/cookies.rs +++ b/crates/axum-utils/src/cookies.rs @@ -138,6 +138,13 @@ impl CookieJar { self } + /// Remove a cookie from the jar + #[must_use] + pub fn remove(mut self, key: &str) -> Self { + self.inner = self.inner.remove(key.to_owned()); + self + } + /// Load and deserialize a cookie from the jar /// /// Returns `None` if the cookie is not present diff --git a/crates/handlers/src/views/register/cookie.rs b/crates/handlers/src/views/register/cookie.rs new file mode 100644 index 000000000..7e3eb8173 --- /dev/null +++ b/crates/handlers/src/views/register/cookie.rs @@ -0,0 +1,103 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +// TODO: move that to a standalone cookie manager + +use std::collections::BTreeSet; + +use chrono::{DateTime, Duration, Utc}; +use mas_axum_utils::cookies::CookieJar; +use mas_data_model::UserRegistration; +use mas_storage::Clock; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use ulid::Ulid; + +/// Name of the cookie +static COOKIE_NAME: &str = "user-registration-sessions"; + +/// Sessions expire after an hour +static SESSION_MAX_TIME: Duration = Duration::hours(1); + +/// The content of the cookie, which stores a list of user registration IDs +#[derive(Serialize, Deserialize, Default, Debug)] +pub struct UserRegistrationSessions(BTreeSet); + +#[derive(Debug, Error, PartialEq, Eq)] +#[error("user registration session not found")] +pub struct UserRegistrationSessionNotFound; + +impl UserRegistrationSessions { + /// Load the user registration sessions cookie + pub fn load(cookie_jar: &CookieJar) -> Self { + match cookie_jar.load(COOKIE_NAME) { + Ok(Some(sessions)) => sessions, + Ok(None) => Self::default(), + Err(e) => { + tracing::warn!( + error = &e as &dyn std::error::Error, + "Invalid upstream sessions cookie" + ); + Self::default() + } + } + } + + /// Returns true if the cookie is empty + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Save the user registration sessions to the cookie jar + pub fn save(self, cookie_jar: CookieJar, clock: &C) -> CookieJar + where + C: Clock, + { + let this = self.expire(clock.now()); + + if this.is_empty() { + cookie_jar.remove(COOKIE_NAME) + } else { + cookie_jar.save(COOKIE_NAME, &this, false) + } + } + + fn expire(mut self, now: DateTime) -> Self { + self.0.retain(|id| { + let Ok(ts) = id.timestamp_ms().try_into() else { + return false; + }; + let Some(when) = DateTime::from_timestamp_millis(ts) else { + return false; + }; + now - when < SESSION_MAX_TIME + }); + + self + } + + /// Add a new session, for a provider and a random state + pub fn add(mut self, user_registration: &UserRegistration) -> Self { + self.0.insert(user_registration.id); + self + } + + /// Check if the session is in the list + pub fn contains(&self, user_registration: &UserRegistration) -> bool { + self.0.contains(&user_registration.id) + } + + /// Mark a link as consumed to avoid replay + pub fn consume_session( + mut self, + user_registration: &UserRegistration, + ) -> Result { + if !self.0.remove(&user_registration.id) { + return Err(UserRegistrationSessionNotFound); + } + + Ok(self) + } +} diff --git a/crates/handlers/src/views/register/mod.rs b/crates/handlers/src/views/register/mod.rs index dbd8a25ed..ea8cb40ce 100644 --- a/crates/handlers/src/views/register/mod.rs +++ b/crates/handlers/src/views/register/mod.rs @@ -17,6 +17,7 @@ use mas_templates::{RegisterContext, TemplateContext, Templates}; use super::shared::OptionalPostAuthAction; use crate::{BoundActivityTracker, PreferredLanguage}; +mod cookie; pub(crate) mod password; pub(crate) mod steps; diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs index 37cfd861b..aebcca7c5 100644 --- a/crates/handlers/src/views/register/password.rs +++ b/crates/handlers/src/views/register/password.rs @@ -35,6 +35,7 @@ use mas_templates::{ use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; +use super::cookie::UserRegistrationSessions; use crate::{ captcha::Form as CaptchaForm, passwords::PasswordManager, views::shared::OptionalPostAuthAction, BoundActivityTracker, Limiter, PreferredLanguage, @@ -361,8 +362,14 @@ pub(crate) async fn post( repo.save().await?; - Ok(url_builder - .redirect(&mas_router::RegisterFinish::new(registration.id)) + let cookie_jar = UserRegistrationSessions::load(&cookie_jar) + .add(®istration) + .save(cookie_jar, &clock); + + Ok(( + cookie_jar, + url_builder.redirect(&mas_router::RegisterFinish::new(registration.id)), + ) .into_response()) } diff --git a/crates/handlers/src/views/register/steps/finish.rs b/crates/handlers/src/views/register/steps/finish.rs index 2c4679a28..eaae8b3f1 100644 --- a/crates/handlers/src/views/register/steps/finish.rs +++ b/crates/handlers/src/views/register/steps/finish.rs @@ -19,6 +19,7 @@ use mas_storage::{ }; use ulid::Ulid; +use super::super::cookie::UserRegistrationSessions; use crate::{views::shared::OptionalPostAuthAction, BoundActivityTracker}; #[tracing::instrument( @@ -59,6 +60,14 @@ pub(crate) async fn get( )); } + // Check that this registration belongs to this browser + let registrations = UserRegistrationSessions::load(&cookie_jar); + if !registrations.contains(®istration) { + return Err(FancyError::from(anyhow::anyhow!( + "Could not find the registration in the browser cookies" + ))); + } + // Let's perform last minute checks on the registration, especially to avoid // race conditions where multiple users register with the same username or email // address @@ -116,6 +125,11 @@ pub(crate) async fn get( .complete(&clock, registration) .await?; + // Consume the registration session + let cookie_jar = registrations + .consume_session(®istration)? + .save(cookie_jar, &clock); + // Now we can start the user creation let user = repo .user() From 02db622274cd6675fbdcbebcecc6699f306cd4a2 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 15 Jan 2025 16:39:56 +0100 Subject: [PATCH 20/26] Expire registration sessions after an hour --- crates/handlers/src/views/register/steps/finish.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/handlers/src/views/register/steps/finish.rs b/crates/handlers/src/views/register/steps/finish.rs index eaae8b3f1..aebdfb432 100644 --- a/crates/handlers/src/views/register/steps/finish.rs +++ b/crates/handlers/src/views/register/steps/finish.rs @@ -9,6 +9,7 @@ use axum::{ response::IntoResponse, }; use axum_extra::TypedHeader; +use chrono::Duration; use mas_axum_utils::{cookies::CookieJar, FancyError, SessionInfoExt as _}; use mas_data_model::UserAgent; use mas_router::{PostAuthAction, UrlBuilder}; @@ -60,6 +61,14 @@ pub(crate) async fn get( )); } + // Make sure the registration session hasn't expired + // XXX: this duration is hard-coded, could be configurable + if clock.now() - registration.created_at > Duration::hours(1) { + return Err(FancyError::from(anyhow::anyhow!( + "Registration session has expired" + ))); + } + // Check that this registration belongs to this browser let registrations = UserRegistrationSessions::load(&cookie_jar); if !registrations.contains(®istration) { From 1ec6192284a3d834271e6028ec0e8ec06c91a797 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 15 Jan 2025 16:49:58 +0100 Subject: [PATCH 21/26] Check with the homeserver the username is still available before registering --- .../handlers/src/views/register/steps/finish.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/handlers/src/views/register/steps/finish.rs b/crates/handlers/src/views/register/steps/finish.rs index aebdfb432..21e382682 100644 --- a/crates/handlers/src/views/register/steps/finish.rs +++ b/crates/handlers/src/views/register/steps/finish.rs @@ -12,6 +12,7 @@ use axum_extra::TypedHeader; use chrono::Duration; use mas_axum_utils::{cookies::CookieJar, FancyError, SessionInfoExt as _}; use mas_data_model::UserAgent; +use mas_matrix::BoxHomeserverConnection; use mas_router::{PostAuthAction, UrlBuilder}; use mas_storage::{ queue::{ProvisionUserJob, QueueJobRepositoryExt as _}, @@ -36,6 +37,7 @@ pub(crate) async fn get( activity_tracker: BoundActivityTracker, user_agent: Option>, State(url_builder): State, + State(homeserver): State, cookie_jar: CookieJar, Path(id): Path, ) -> Result { @@ -72,6 +74,7 @@ pub(crate) async fn get( // Check that this registration belongs to this browser let registrations = UserRegistrationSessions::load(&cookie_jar); if !registrations.contains(®istration) { + // XXX: we should have a better error screen here return Err(FancyError::from(anyhow::anyhow!( "Could not find the registration in the browser cookies" ))); @@ -82,12 +85,21 @@ pub(crate) async fn get( // address if repo.user().exists(®istration.username).await? { + // XXX: this could have a better error message, but as this is unlikely to + // happen, we're fine with a vague message for now return Err(FancyError::from(anyhow::anyhow!( "Username is already taken" ))); } - // TODO: query the homeserver + if !homeserver + .is_localpart_available(®istration.username) + .await? + { + return Err(FancyError::from(anyhow::anyhow!( + "Username is not available" + ))); + } // For now, we require an email address on the registration, but this might // change in the future @@ -115,6 +127,8 @@ pub(crate) async fn get( .await? > 0 { + // XXX: this could have a better error message, but as this is unlikely to + // happen, we're fine with a vague message for now return Err(FancyError::from(anyhow::anyhow!( "Email address is already used" ))); From ef74d4716907f6d8f6e7688764b2a9da958528ac Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 15 Jan 2025 17:08:15 +0100 Subject: [PATCH 22/26] Cleanup the unverified emails from the database --- .../20250115155255_cleanup_unverified_emails.sql | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 crates/storage-pg/migrations/20250115155255_cleanup_unverified_emails.sql diff --git a/crates/storage-pg/migrations/20250115155255_cleanup_unverified_emails.sql b/crates/storage-pg/migrations/20250115155255_cleanup_unverified_emails.sql new file mode 100644 index 000000000..61a4101f6 --- /dev/null +++ b/crates/storage-pg/migrations/20250115155255_cleanup_unverified_emails.sql @@ -0,0 +1,14 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +-- This drops all the unverified email addresses from the database, as they are +-- now always verified when they land in the user_emails table. +-- We don't drop the `confirmed_at` column to allow rolling back + +-- First, truncate all the confirmation codes +TRUNCATE TABLE user_email_confirmation_codes; + +-- Then, delete all the unverified email addresses +DELETE FROM user_emails WHERE confirmed_at IS NULL; From ef077d0e51c68ffac547c393639e8d1d49018c7a Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 23 Jan 2025 12:09:26 +0100 Subject: [PATCH 23/26] Rate-limit email authentications --- crates/config/src/sections/rate_limiting.rs | 79 +++++++++++++- .../src/graphql/mutations/user_email.rs | 39 ++++++- crates/handlers/src/rate_limit.rs | 100 +++++++++++++++++- .../handlers/src/views/register/password.rs | 5 + .../src/views/register/steps/verify_email.rs | 19 +++- docs/config.schema.json | 79 ++++++++++++++ frontend/schema.graphql | 12 +++ .../components/UserProfile/AddEmailForm.tsx | 8 +- frontend/src/gql/graphql.ts | 8 +- .../src/routes/emails.$id.verify.lazy.tsx | 11 +- 10 files changed, 351 insertions(+), 9 deletions(-) diff --git a/crates/config/src/sections/rate_limiting.rs b/crates/config/src/sections/rate_limiting.rs index e2be8c057..9ee12fd15 100644 --- a/crates/config/src/sections/rate_limiting.rs +++ b/crates/config/src/sections/rate_limiting.rs @@ -1,4 +1,4 @@ -// 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 @@ -18,13 +18,19 @@ pub struct RateLimitingConfig { /// Account Recovery-specific rate limits #[serde(default)] pub account_recovery: AccountRecoveryRateLimitingConfig, + /// Login-specific rate limits #[serde(default)] pub login: LoginRateLimitingConfig, + /// Controls how many registrations attempts are permitted /// based on source address. #[serde(default = "default_registration")] pub registration: RateLimiterConfiguration, + + /// Email authentication-specific rate limits + #[serde(default)] + pub email_authentication: EmailauthenticationRateLimitingConfig, } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] @@ -37,6 +43,7 @@ pub struct LoginRateLimitingConfig { /// change their own password. #[serde(default = "default_login_per_ip")] pub per_ip: RateLimiterConfiguration, + /// Controls how many login attempts are permitted /// based on the account that is being attempted to be logged into. /// This can protect against a distributed brute force attack @@ -58,6 +65,7 @@ pub struct AccountRecoveryRateLimitingConfig { /// Note: this limit also applies to re-sends. #[serde(default = "default_account_recovery_per_ip")] pub per_ip: RateLimiterConfiguration, + /// Controls how many account recovery attempts are permitted /// based on the e-mail address entered into the recovery form. /// This can protect against causing e-mail spam to one target. @@ -67,6 +75,35 @@ pub struct AccountRecoveryRateLimitingConfig { pub per_address: RateLimiterConfiguration, } +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +pub struct EmailauthenticationRateLimitingConfig { + /// Controls how many email authentication attempts are permitted + /// based on the source IP address. + /// This can protect against causing e-mail spam to many targets. + #[serde(default = "default_email_authentication_per_ip")] + pub per_ip: RateLimiterConfiguration, + + /// Controls how many email authentication attempts are permitted + /// based on the e-mail address entered into the authentication form. + /// This can protect against causing e-mail spam to one target. + /// + /// Note: this limit also applies to re-sends. + #[serde(default = "default_email_authentication_per_address")] + pub per_address: RateLimiterConfiguration, + + /// Controls how many authentication emails are permitted to be sent per + /// authentication session. This ensures not too many authentication codes + /// are created for the same authentication session. + #[serde(default = "default_email_authentication_emails_per_session")] + pub emails_per_session: RateLimiterConfiguration, + + /// Controls how many code authentication attempts are permitted per + /// authentication session. This can protect against brute-forcing the + /// code. + #[serde(default = "default_email_authentication_attempt_per_session")] + pub attempt_per_session: RateLimiterConfiguration, +} + #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] pub struct RateLimiterConfiguration { /// A one-off burst of actions that the user can perform @@ -193,12 +230,41 @@ fn default_account_recovery_per_address() -> RateLimiterConfiguration { } } +fn default_email_authentication_per_ip() -> RateLimiterConfiguration { + RateLimiterConfiguration { + burst: NonZeroU32::new(5).unwrap(), + per_second: 1.0 / 60.0, + } +} + +fn default_email_authentication_per_address() -> RateLimiterConfiguration { + RateLimiterConfiguration { + burst: NonZeroU32::new(3).unwrap(), + per_second: 1.0 / 3600.0, + } +} + +fn default_email_authentication_emails_per_session() -> RateLimiterConfiguration { + RateLimiterConfiguration { + burst: NonZeroU32::new(2).unwrap(), + per_second: 1.0 / 300.0, + } +} + +fn default_email_authentication_attempt_per_session() -> RateLimiterConfiguration { + RateLimiterConfiguration { + burst: NonZeroU32::new(10).unwrap(), + per_second: 1.0 / 60.0, + } +} + impl Default for RateLimitingConfig { fn default() -> Self { RateLimitingConfig { login: LoginRateLimitingConfig::default(), registration: default_registration(), account_recovery: AccountRecoveryRateLimitingConfig::default(), + email_authentication: EmailauthenticationRateLimitingConfig::default(), } } } @@ -220,3 +286,14 @@ impl Default for AccountRecoveryRateLimitingConfig { } } } + +impl Default for EmailauthenticationRateLimitingConfig { + fn default() -> Self { + EmailauthenticationRateLimitingConfig { + per_ip: default_email_authentication_per_ip(), + per_address: default_email_authentication_per_address(), + emails_per_session: default_email_authentication_emails_per_session(), + attempt_per_session: default_email_authentication_attempt_per_session(), + } + } +} diff --git a/crates/handlers/src/graphql/mutations/user_email.rs b/crates/handlers/src/graphql/mutations/user_email.rs index afc3f7c9e..41ec2f01c 100644 --- a/crates/handlers/src/graphql/mutations/user_email.rs +++ b/crates/handlers/src/graphql/mutations/user_email.rs @@ -238,6 +238,8 @@ enum StartEmailAuthenticationStatus { Started, /// The email address is invalid InvalidEmailAddress, + /// Too many attempts to start an email authentication + RateLimited, /// The email address isn't allowed by the policy Denied, /// The email address is already in use @@ -249,6 +251,7 @@ enum StartEmailAuthenticationStatus { enum StartEmailAuthenticationPayload { Started(UserEmailAuthentication), InvalidEmailAddress, + RateLimited, Denied { violations: Vec, }, @@ -262,6 +265,7 @@ impl StartEmailAuthenticationPayload { match self { Self::Started(_) => StartEmailAuthenticationStatus::Started, Self::InvalidEmailAddress => StartEmailAuthenticationStatus::InvalidEmailAddress, + Self::RateLimited => StartEmailAuthenticationStatus::RateLimited, Self::Denied { .. } => StartEmailAuthenticationStatus::Denied, Self::InUse => StartEmailAuthenticationStatus::InUse, } @@ -271,7 +275,9 @@ impl StartEmailAuthenticationPayload { async fn authentication(&self) -> Option<&UserEmailAuthentication> { match self { Self::Started(authentication) => Some(authentication), - Self::InvalidEmailAddress | Self::Denied { .. } | Self::InUse => None, + Self::InvalidEmailAddress | Self::RateLimited | Self::Denied { .. } | Self::InUse => { + None + } } } @@ -302,6 +308,7 @@ enum CompleteEmailAuthenticationPayload { Completed, InvalidCode, CodeExpired, + RateLimited, } /// The status of the `completeEmailAuthentication` mutation @@ -313,6 +320,8 @@ enum CompleteEmailAuthenticationStatus { InvalidCode, /// The authentication code has expired CodeExpired, + /// Too many attempts to complete an email authentication + RateLimited, } #[Object(use_type_description)] @@ -323,6 +332,7 @@ impl CompleteEmailAuthenticationPayload { Self::Completed => CompleteEmailAuthenticationStatus::Completed, Self::InvalidCode => CompleteEmailAuthenticationStatus::InvalidCode, Self::CodeExpired => CompleteEmailAuthenticationStatus::CodeExpired, + Self::RateLimited => CompleteEmailAuthenticationStatus::RateLimited, } } } @@ -345,6 +355,8 @@ enum ResendEmailAuthenticationCodePayload { Resent, /// The email authentication session is already completed Completed, + /// Too many attempts to resend an email authentication code + RateLimited, } /// The status of the `resendEmailAuthenticationCode` mutation @@ -354,6 +366,8 @@ enum ResendEmailAuthenticationCodeStatus { Resent, /// The email authentication session is already completed Completed, + /// Too many attempts to resend an email authentication code + RateLimited, } #[Object(use_type_description)] @@ -363,6 +377,7 @@ impl ResendEmailAuthenticationCodePayload { match self { Self::Resent => ResendEmailAuthenticationCodeStatus::Resent, Self::Completed => ResendEmailAuthenticationCodeStatus::Completed, + Self::RateLimited => ResendEmailAuthenticationCodeStatus::RateLimited, } } } @@ -536,6 +551,7 @@ impl UserEmailMutations { let mut rng = state.rng(); let clock = state.clock(); let requester = ctx.requester(); + let limiter = state.limiter(); // Only allow calling this if the requester is a browser session let Some(browser_session) = requester.browser_session() else { @@ -563,7 +579,12 @@ impl UserEmailMutations { return Ok(StartEmailAuthenticationPayload::InvalidEmailAddress); } - // TODO: check rate limting + if let Err(e) = + limiter.check_email_authentication_email(ctx.requester_fingerprint(), &input.email) + { + tracing::warn!(error = &e as &dyn std::error::Error); + return Ok(StartEmailAuthenticationPayload::RateLimited); + } let mut repo = state.repository().await?; @@ -615,6 +636,7 @@ impl UserEmailMutations { let state = ctx.state(); let mut rng = state.rng(); let clock = state.clock(); + let limiter = state.limiter(); let id = NodeType::UserEmailAuthentication.extract_ulid(&input.id)?; let Some(browser_session) = ctx.requester().browser_session() else { @@ -647,6 +669,13 @@ impl UserEmailMutations { return Ok(ResendEmailAuthenticationCodePayload::Completed); } + if let Err(e) = limiter + .check_email_authentication_send_code(ctx.requester_fingerprint(), &authentication) + { + tracing::warn!(error = &e as &dyn std::error::Error); + return Ok(ResendEmailAuthenticationCodePayload::RateLimited); + } + repo.queue_job() .schedule_job( &mut rng, @@ -669,6 +698,7 @@ impl UserEmailMutations { let state = ctx.state(); let mut rng = state.rng(); let clock = state.clock(); + let limiter = state.limiter(); let id = NodeType::UserEmailAuthentication.extract_ulid(&input.id)?; @@ -695,6 +725,11 @@ impl UserEmailMutations { return Ok(CompleteEmailAuthenticationPayload::InvalidCode); } + if let Err(e) = limiter.check_email_authentication_attempt(&authentication) { + tracing::warn!(error = &e as &dyn std::error::Error); + return Ok(CompleteEmailAuthenticationPayload::RateLimited); + } + let Some(code) = repo .user_email() .find_authentication_code(&authentication, &input.code) diff --git a/crates/handlers/src/rate_limit.rs b/crates/handlers/src/rate_limit.rs index 23648d38e..eff30d86f 100644 --- a/crates/handlers/src/rate_limit.rs +++ b/crates/handlers/src/rate_limit.rs @@ -8,7 +8,7 @@ use std::{net::IpAddr, sync::Arc, time::Duration}; use governor::{clock::QuantaClock, state::keyed::DashMapStateStore, RateLimiter}; use mas_config::RateLimitingConfig; -use mas_data_model::User; +use mas_data_model::{User, UserEmailAuthentication}; use ulid::Ulid; #[derive(Debug, Clone, thiserror::Error)] @@ -35,6 +35,18 @@ pub enum RegistrationLimitedError { Requester(RequesterFingerprint), } +#[derive(Debug, Clone, thiserror::Error)] +pub enum EmailAuthenticationLimitedError { + #[error("Too many email authentication requests for requester {0}")] + Requester(RequesterFingerprint), + + #[error("Too many email authentication requests for authentication session {0}")] + Authentication(Ulid), + + #[error("Too many email authentication requests for email {0}")] + Email(String), +} + /// Key used to rate limit requests per requester #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct RequesterFingerprint { @@ -78,6 +90,10 @@ struct LimiterInner { password_check_for_requester: KeyedRateLimiter, password_check_for_user: KeyedRateLimiter, registration_per_requester: KeyedRateLimiter, + email_authentication_per_requester: KeyedRateLimiter, + email_authentication_per_email: KeyedRateLimiter, + email_authentication_emails_per_session: KeyedRateLimiter, + email_authentication_attempt_per_session: KeyedRateLimiter, } impl LimiterInner { @@ -92,6 +108,18 @@ impl LimiterInner { password_check_for_requester: RateLimiter::keyed(config.login.per_ip.to_quota()?), password_check_for_user: RateLimiter::keyed(config.login.per_account.to_quota()?), registration_per_requester: RateLimiter::keyed(config.registration.to_quota()?), + email_authentication_per_email: RateLimiter::keyed( + config.email_authentication.per_address.to_quota()?, + ), + email_authentication_per_requester: RateLimiter::keyed( + config.email_authentication.per_ip.to_quota()?, + ), + email_authentication_emails_per_session: RateLimiter::keyed( + config.email_authentication.emails_per_session.to_quota()?, + ), + email_authentication_attempt_per_session: RateLimiter::keyed( + config.email_authentication.attempt_per_session.to_quota()?, + ), }) } } @@ -127,6 +155,16 @@ impl Limiter { this.inner.password_check_for_requester.retain_recent(); this.inner.password_check_for_user.retain_recent(); this.inner.registration_per_requester.retain_recent(); + this.inner.email_authentication_per_email.retain_recent(); + this.inner + .email_authentication_per_requester + .retain_recent(); + this.inner + .email_authentication_emails_per_session + .retain_recent(); + this.inner + .email_authentication_attempt_per_session + .retain_recent(); interval.tick().await; } @@ -199,6 +237,66 @@ impl Limiter { Ok(()) } + + /// Check if an email can be sent to the address for an email + /// authentication session + /// + /// # Errors + /// + /// Returns an error if the operation is rate limited. + pub fn check_email_authentication_email( + &self, + requester: RequesterFingerprint, + email: &str, + ) -> Result<(), EmailAuthenticationLimitedError> { + self.inner + .email_authentication_per_requester + .check_key(&requester) + .map_err(|_| EmailAuthenticationLimitedError::Requester(requester))?; + + // Convert to lowercase to prevent bypassing the limit by enumerating different + // case variations. + // A case-folding transformation may be more proper. + let canonical_email = email.to_lowercase(); + self.inner + .email_authentication_per_email + .check_key(&canonical_email) + .map_err(|_| EmailAuthenticationLimitedError::Email(email.to_owned()))?; + Ok(()) + } + + /// Check if an attempt can be done on an email authentication session + /// + /// # Errors + /// + /// Returns an error if the operation is rate limited. + pub fn check_email_authentication_attempt( + &self, + authentication: &UserEmailAuthentication, + ) -> Result<(), EmailAuthenticationLimitedError> { + self.inner + .email_authentication_attempt_per_session + .check_key(&authentication.id) + .map_err(|_| EmailAuthenticationLimitedError::Authentication(authentication.id)) + } + + /// Check if a new authentication code can be sent for an email + /// authentication session + /// + /// # Errors + /// + /// Returns an error if the operation is rate limited. + pub fn check_email_authentication_send_code( + &self, + requester: RequesterFingerprint, + authentication: &UserEmailAuthentication, + ) -> Result<(), EmailAuthenticationLimitedError> { + self.check_email_authentication_email(requester, &authentication.email)?; + self.inner + .email_authentication_emails_per_session + .check_key(&authentication.id) + .map_err(|_| EmailAuthenticationLimitedError::Authentication(authentication.id)) + } } #[cfg(test)] diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs index aebcca7c5..624b22508 100644 --- a/crates/handlers/src/views/register/password.rs +++ b/crates/handlers/src/views/register/password.rs @@ -286,6 +286,11 @@ pub(crate) async fn post( tracing::warn!(error = &e as &dyn std::error::Error); state.add_error_on_form(FormError::RateLimitExceeded); } + + if let Err(e) = limiter.check_email_authentication_email(requester, &form.email) { + tracing::warn!(error = &e as &dyn std::error::Error); + state.add_error_on_form(FormError::RateLimitExceeded); + } } state diff --git a/crates/handlers/src/views/register/steps/verify_email.rs b/crates/handlers/src/views/register/steps/verify_email.rs index 4ae18d777..9faf347e8 100644 --- a/crates/handlers/src/views/register/steps/verify_email.rs +++ b/crates/handlers/src/views/register/steps/verify_email.rs @@ -22,7 +22,7 @@ use mas_templates::{ use serde::{Deserialize, Serialize}; use ulid::Ulid; -use crate::{views::shared::OptionalPostAuthAction, PreferredLanguage}; +use crate::{views::shared::OptionalPostAuthAction, Limiter, PreferredLanguage}; #[derive(Serialize, Deserialize, Debug)] pub struct CodeForm { @@ -111,6 +111,7 @@ pub(crate) async fn post( mut rng: BoxRng, PreferredLanguage(locale): PreferredLanguage, State(templates): State, + State(limiter): State, mut repo: BoxRepository, cookie_jar: CookieJar, State(url_builder): State, @@ -157,6 +158,22 @@ pub(crate) async fn post( ))); } + if let Err(e) = limiter.check_email_authentication_attempt(&email_authentication) { + tracing::warn!(error = &e as &dyn std::error::Error); + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + let ctx = RegisterStepsVerifyEmailContext::new(email_authentication) + .with_form_state( + form.to_form_state() + .with_error_on_form(mas_templates::FormError::RateLimitExceeded), + ) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let content = templates.render_register_steps_verify_email(&ctx)?; + + return Ok((cookie_jar, Html(content)).into_response()); + } + let Some(code) = repo .user_email() .find_authentication_code(&email_authentication, &form.code) diff --git a/docs/config.schema.json b/docs/config.schema.json index daed90d8b..fafe759d3 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -1715,6 +1715,32 @@ "$ref": "#/definitions/RateLimiterConfiguration" } ] + }, + "email_authentication": { + "description": "Email authentication-specific rate limits", + "default": { + "per_ip": { + "burst": 5, + "per_second": 0.016666666666666666 + }, + "per_address": { + "burst": 3, + "per_second": 0.0002777777777777778 + }, + "emails_per_session": { + "burst": 2, + "per_second": 0.0033333333333333335 + }, + "attempt_per_session": { + "burst": 10, + "per_second": 0.016666666666666666 + } + }, + "allOf": [ + { + "$ref": "#/definitions/EmailauthenticationRateLimitingConfig" + } + ] } } }, @@ -1796,6 +1822,59 @@ } } }, + "EmailauthenticationRateLimitingConfig": { + "type": "object", + "properties": { + "per_ip": { + "description": "Controls how many email authentication attempts are permitted based on the source IP address. This can protect against causing e-mail spam to many targets.", + "default": { + "burst": 5, + "per_second": 0.016666666666666666 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] + }, + "per_address": { + "description": "Controls how many email authentication attempts are permitted based on the e-mail address entered into the authentication form. This can protect against causing e-mail spam to one target.\n\nNote: this limit also applies to re-sends.", + "default": { + "burst": 3, + "per_second": 0.0002777777777777778 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] + }, + "emails_per_session": { + "description": "Controls how many authentication emails are permitted to be sent per authentication session. This ensures not too many authentication codes are created for the same authentication session.", + "default": { + "burst": 2, + "per_second": 0.0033333333333333335 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] + }, + "attempt_per_session": { + "description": "Controls how many code authentication attempts are permitted per authentication session. This can protect against brute-forcing the code.", + "default": { + "burst": 10, + "per_second": 0.016666666666666666 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] + } + } + }, "UpstreamOAuth2Config": { "description": "Upstream OAuth 2.0 providers configuration", "type": "object", diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 104cce890..1602835a6 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -524,6 +524,10 @@ enum CompleteEmailAuthenticationStatus { The authentication code has expired """ CODE_EXPIRED + """ + Too many attempts to complete an email authentication + """ + RATE_LIMITED } """ @@ -1265,6 +1269,10 @@ enum ResendEmailAuthenticationCodeStatus { The email authentication session is already completed """ COMPLETED + """ + Too many attempts to resend an email authentication code + """ + RATE_LIMITED } """ @@ -1634,6 +1642,10 @@ enum StartEmailAuthenticationStatus { """ INVALID_EMAIL_ADDRESS """ + Too many attempts to start an email authentication + """ + RATE_LIMITED + """ The email address isn't allowed by the policy """ DENIED diff --git a/frontend/src/components/UserProfile/AddEmailForm.tsx b/frontend/src/components/UserProfile/AddEmailForm.tsx index d96061122..6459f495e 100644 --- a/frontend/src/components/UserProfile/AddEmailForm.tsx +++ b/frontend/src/components/UserProfile/AddEmailForm.tsx @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -61,7 +61,7 @@ const AddEmailForm: React.FC<{ const formData = new FormData(e.currentTarget); const email = formData.get("input") as string; - addEmail.mutate({ email, language: i18n.languages[0] }); + await addEmail.mutateAsync({ email, language: i18n.languages[0] }); }; const status = addEmail.data?.startEmailAuthentication.status ?? null; @@ -93,6 +93,10 @@ const AddEmailForm: React.FC<{ )} + {status === "RATE_LIMITED" && ( + {t("frontend.errors.rate_limit_exceeded")} + )} + {status === "DENIED" && ( <> diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 458c68f02..fbc5e83b2 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -348,7 +348,9 @@ export type CompleteEmailAuthenticationStatus = /** The authentication was completed */ | 'COMPLETED' /** The authentication code is invalid */ - | 'INVALID_CODE'; + | 'INVALID_CODE' + /** Too many attempts to complete an email authentication */ + | 'RATE_LIMITED'; /** The input of the `createOauth2Session` mutation. */ export type CreateOAuth2SessionInput = { @@ -972,6 +974,8 @@ export type ResendEmailAuthenticationCodePayload = { export type ResendEmailAuthenticationCodeStatus = /** The email authentication session is already completed */ | 'COMPLETED' + /** Too many attempts to resend an email authentication code */ + | 'RATE_LIMITED' /** The email was resent */ | 'RESENT'; @@ -1205,6 +1209,8 @@ export type StartEmailAuthenticationStatus = | 'INVALID_EMAIL_ADDRESS' /** The email address is already in use */ | 'IN_USE' + /** Too many attempts to start an email authentication */ + | 'RATE_LIMITED' /** The email address was started */ | 'STARTED'; diff --git a/frontend/src/routes/emails.$id.verify.lazy.tsx b/frontend/src/routes/emails.$id.verify.lazy.tsx index 2ed624088..480109ef0 100644 --- a/frontend/src/routes/emails.$id.verify.lazy.tsx +++ b/frontend/src/routes/emails.$id.verify.lazy.tsx @@ -99,6 +99,8 @@ function EmailVerify(): React.ReactElement { "RESENT"; const invalidCode = verifyEmail.data?.completeEmailAuthentication.status === "INVALID_CODE"; + const rateLimited = + verifyEmail.data?.completeEmailAuthentication.status === "RATE_LIMITED"; return ( @@ -133,9 +135,16 @@ function EmailVerify(): React.ReactElement { )} + {rateLimited && ( + + )} + {t("frontend.verify_email.code_field_label")} From 7e6ab8ffc3e1b25497fc1e76b00d07b54337c5fc Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 23 Jan 2025 18:18:19 +0100 Subject: [PATCH 24/26] Disclose that email is already in use after verification --- .../src/graphql/mutations/user_email.rs | 35 ++++++++---- .../handlers/src/views/register/password.rs | 12 ++-- .../src/views/register/steps/finish.rs | 42 ++++++++++---- crates/templates/src/context.rs | 26 +++++++++ crates/templates/src/lib.rs | 12 ++-- frontend/locales/en.json | 7 +++ frontend/schema.graphql | 6 +- frontend/src/gql/graphql.ts | 4 +- frontend/src/routeTree.gen.ts | 26 +++++++++ frontend/src/routes/emails.$id.in-use.tsx | 56 +++++++++++++++++++ .../src/routes/emails.$id.verify.lazy.tsx | 13 +++++ .../pages/register/steps/email_in_use.html | 30 ++++++++++ translations/en.json | 12 +++- 13 files changed, 244 insertions(+), 37 deletions(-) create mode 100644 frontend/src/routes/emails.$id.in-use.tsx create mode 100644 templates/pages/register/steps/email_in_use.html diff --git a/crates/handlers/src/graphql/mutations/user_email.rs b/crates/handlers/src/graphql/mutations/user_email.rs index 41ec2f01c..38048d39a 100644 --- a/crates/handlers/src/graphql/mutations/user_email.rs +++ b/crates/handlers/src/graphql/mutations/user_email.rs @@ -242,7 +242,7 @@ enum StartEmailAuthenticationStatus { RateLimited, /// The email address isn't allowed by the policy Denied, - /// The email address is already in use + /// The email address is already in use on this account InUse, } @@ -308,6 +308,7 @@ enum CompleteEmailAuthenticationPayload { Completed, InvalidCode, CodeExpired, + InUse, RateLimited, } @@ -322,6 +323,8 @@ enum CompleteEmailAuthenticationStatus { CodeExpired, /// Too many attempts to complete an email authentication RateLimited, + /// The email address is already in use + InUse, } #[Object(use_type_description)] @@ -332,6 +335,7 @@ impl CompleteEmailAuthenticationPayload { Self::Completed => CompleteEmailAuthenticationStatus::Completed, Self::InvalidCode => CompleteEmailAuthenticationStatus::InvalidCode, Self::CodeExpired => CompleteEmailAuthenticationStatus::CodeExpired, + Self::InUse => CompleteEmailAuthenticationStatus::InUse, Self::RateLimited => CompleteEmailAuthenticationStatus::RateLimited, } } @@ -588,10 +592,17 @@ impl UserEmailMutations { let mut repo = state.repository().await?; - // Check if the email address is already in use + // Check if the email address is already in use by the same user + // We don't report here if the email address is already in use by another user, + // because we don't want to leak information about other users. We will do that + // only when the user enters the right verification code let count = repo .user_email() - .count(UserEmailFilter::new().for_email(&input.email)) + .count( + UserEmailFilter::new() + .for_email(&input.email) + .for_user(&browser_session.user), + ) .await?; if count > 0 { return Ok(StartEmailAuthenticationPayload::InUse); @@ -742,19 +753,23 @@ impl UserEmailMutations { return Ok(CompleteEmailAuthenticationPayload::CodeExpired); } - // Check that we can add the email address to the user + let authentication = repo + .user_email() + .complete_authentication(&clock, authentication, &code) + .await?; + + // Check the email is not already in use by anyone, including the current user let count = repo .user_email() .count(UserEmailFilter::new().for_email(&authentication.email)) .await?; + if count > 0 { - return Ok(CompleteEmailAuthenticationPayload::CodeExpired); - } + // We still want to consume the code so that it can't be reused + repo.save().await?; - let authentication = repo - .user_email() - .complete_authentication(&clock, authentication, &code) - .await?; + return Ok(CompleteEmailAuthenticationPayload::InUse); + } repo.user_email() .add( diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs index 624b22508..c2177c484 100644 --- a/crates/handlers/src/views/register/password.rs +++ b/crates/handlers/src/views/register/password.rs @@ -25,7 +25,7 @@ use mas_policy::Policy; use mas_router::UrlBuilder; use mas_storage::{ queue::{QueueJobRepositoryExt as _, SendEmailAuthenticationCodeJob}, - user::{UserEmailFilter, UserEmailRepository, UserRepository}, + user::{UserEmailRepository, UserRepository}, BoxClock, BoxRepository, BoxRng, RepositoryAccess, }; use mas_templates::{ @@ -191,17 +191,13 @@ pub(crate) async fn post( homeserver_denied_username = true; } + // Note that we don't check here if the email is already taken here, as + // we don't want to leak the information about other users. Instead, we will + // show an error message once the user confirmed their email address. if form.email.is_empty() { state.add_error_on_field(RegisterFormField::Email, FieldError::Required); } else if Address::from_str(&form.email).is_err() { state.add_error_on_field(RegisterFormField::Email, FieldError::Invalid); - } else if repo - .user_email() - .count(UserEmailFilter::new().for_email(&form.email)) - .await? - > 0 - { - state.add_error_on_field(RegisterFormField::Email, FieldError::Exists); } if form.password.is_empty() { diff --git a/crates/handlers/src/views/register/steps/finish.rs b/crates/handlers/src/views/register/steps/finish.rs index 21e382682..b460d6aed 100644 --- a/crates/handlers/src/views/register/steps/finish.rs +++ b/crates/handlers/src/views/register/steps/finish.rs @@ -6,7 +6,7 @@ use anyhow::Context as _; use axum::{ extract::{Path, State}, - response::IntoResponse, + response::{Html, IntoResponse, Response}, }; use axum_extra::TypedHeader; use chrono::Duration; @@ -19,10 +19,11 @@ use mas_storage::{ user::UserEmailFilter, BoxClock, BoxRepository, BoxRng, }; +use mas_templates::{RegisterStepsEmailInUseContext, TemplateContext as _, Templates}; use ulid::Ulid; use super::super::cookie::UserRegistrationSessions; -use crate::{views::shared::OptionalPostAuthAction, BoundActivityTracker}; +use crate::{views::shared::OptionalPostAuthAction, BoundActivityTracker, PreferredLanguage}; #[tracing::instrument( name = "handlers.views.register.steps.finish.get", @@ -38,9 +39,11 @@ pub(crate) async fn get( user_agent: Option>, State(url_builder): State, State(homeserver): State, + State(templates): State, + PreferredLanguage(lang): PreferredLanguage, cookie_jar: CookieJar, Path(id): Path, -) -> Result { +) -> Result { let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); let registration = repo .user_registration() @@ -60,7 +63,8 @@ pub(crate) async fn get( return Ok(( cookie_jar, OptionalPostAuthAction::from(post_auth_action).go_next(&url_builder), - )); + ) + .into_response()); } // Make sure the registration session hasn't expired @@ -117,21 +121,33 @@ pub(crate) async fn get( return Ok(( cookie_jar, url_builder.redirect(&mas_router::RegisterVerifyEmail::new(id)), - )); + ) + .into_response()); } // Check that the email address isn't already used + // It is important to do that here, as we we're not checking during the + // registration, because we don't want to disclose whether an email is + // already being used or not before we verified it if repo .user_email() .count(UserEmailFilter::new().for_email(&email_authentication.email)) .await? > 0 { - // XXX: this could have a better error message, but as this is unlikely to - // happen, we're fine with a vague message for now - return Err(FancyError::from(anyhow::anyhow!( - "Email address is already used" - ))); + let action = registration + .post_auth_action + .map(serde_json::from_value) + .transpose()?; + + let ctx = RegisterStepsEmailInUseContext::new(email_authentication.email, action) + .with_language(lang); + + return Ok(( + cookie_jar, + Html(templates.render_register_steps_email_in_use(&ctx)?), + ) + .into_response()); } // Check that the display name is set @@ -139,7 +155,8 @@ pub(crate) async fn get( return Ok(( cookie_jar, url_builder.redirect(&mas_router::RegisterDisplayName::new(registration.id)), - )); + ) + .into_response()); } // Everuthing is good, let's complete the registration @@ -215,5 +232,6 @@ pub(crate) async fn get( return Ok(( cookie_jar, OptionalPostAuthAction::from(post_auth_action).go_next(&url_builder), - )); + ) + .into_response()); } diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index f5e652ed4..7fcfcf8f6 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -1000,6 +1000,32 @@ impl TemplateContext for RegisterStepsVerifyEmailContext { } } +/// Context used by the `pages/register/steps/email_in_use.html` template +#[derive(Serialize)] +pub struct RegisterStepsEmailInUseContext { + email: String, + action: Option, +} + +impl RegisterStepsEmailInUseContext { + /// Constructs a context for the email in use page + #[must_use] + pub fn new(email: String, action: Option) -> Self { + Self { email, action } + } +} + +impl TemplateContext for RegisterStepsEmailInUseContext { + fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec + where + Self: Sized, + { + let email = "hello@example.com".to_owned(); + let action = PostAuthAction::continue_grant(Ulid::nil()); + vec![Self::new(email, Some(action))] + } +} + /// Fields for the display name form #[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] #[serde(rename_all = "snake_case")] diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 07e67cc63..60482f792 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -42,10 +42,10 @@ pub use self::{ RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, - RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField, SiteBranding, - SiteConfigExt, SiteFeatures, TemplateContext, UpstreamExistingLinkContext, - UpstreamRegister, UpstreamRegisterFormField, UpstreamSuggestLink, WithCaptcha, WithCsrf, - WithLanguage, WithOptionalSession, WithSession, + RegisterStepsEmailInUseContext, RegisterStepsVerifyEmailContext, + RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures, + TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField, + UpstreamSuggestLink, WithCaptcha, WithCsrf, WithLanguage, WithOptionalSession, WithSession, }, forms::{FieldError, FormError, FormField, FormState, ToFormState}, }; @@ -336,6 +336,9 @@ register_templates! { /// Render the email verification page pub fn render_register_steps_verify_email(WithLanguage>) { "pages/register/steps/verify_email.html" } + /// Render the email in use page + pub fn render_register_steps_email_in_use(WithLanguage) { "pages/register/steps/email_in_use.html" } + /// Render the display name page pub fn render_register_steps_display_name(WithLanguage>) { "pages/register/steps/display_name.html" } @@ -432,6 +435,7 @@ impl Templates { check::render_register(self, now, rng)?; check::render_password_register(self, now, rng)?; check::render_register_steps_verify_email(self, now, rng)?; + check::render_register_steps_email_in_use(self, now, rng)?; check::render_register_steps_display_name(self, now, rng)?; check::render_consent(self, now, rng)?; check::render_policy_violation(self, now, rng)?; diff --git a/frontend/locales/en.json b/frontend/locales/en.json index cc977da72..72df9d5fe 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -78,6 +78,9 @@ "tablet": "Tablet", "unknown": "Unknown device type" }, + "email_in_use": { + "heading": "The email address {{email}} is already in use." + }, "end_session_button": { "confirmation_modal_title": "Are you sure you want to end this session?", "text": "Sign out" @@ -266,6 +269,10 @@ } }, "verify_email": { + "code_expired_alert": { + "description": "The code has expired. Please request a new code.", + "title": "Code expired" + }, "code_field_error": "Code not recognised", "code_field_label": "6-digit code", "code_field_wrong_shape": "Code must be 6 digits", diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 1602835a6..58678a73b 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -528,6 +528,10 @@ enum CompleteEmailAuthenticationStatus { Too many attempts to complete an email authentication """ RATE_LIMITED + """ + The email address is already in use + """ + IN_USE } """ @@ -1650,7 +1654,7 @@ enum StartEmailAuthenticationStatus { """ DENIED """ - The email address is already in use + The email address is already in use on this account """ IN_USE } diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index fbc5e83b2..c342bd3f4 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -349,6 +349,8 @@ export type CompleteEmailAuthenticationStatus = | 'COMPLETED' /** The authentication code is invalid */ | 'INVALID_CODE' + /** The email address is already in use */ + | 'IN_USE' /** Too many attempts to complete an email authentication */ | 'RATE_LIMITED'; @@ -1207,7 +1209,7 @@ export type StartEmailAuthenticationStatus = | 'DENIED' /** The email address is invalid */ | 'INVALID_EMAIL_ADDRESS' - /** The email address is already in use */ + /** The email address is already in use on this account */ | 'IN_USE' /** Too many attempts to start an email authentication */ | 'RATE_LIMITED' diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index bafc24851..2c412c68c 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -25,6 +25,7 @@ import { Route as PasswordRecoveryIndexImport } from './routes/password.recovery import { Route as PasswordChangeIndexImport } from './routes/password.change.index' import { Route as AccountSessionsIndexImport } from './routes/_account.sessions.index' 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' import { Route as AccountSessionsIdImport } from './routes/_account.sessions.$id' @@ -127,6 +128,12 @@ const EmailsIdVerifyRoute = EmailsIdVerifyImport.update({ import('./routes/emails.$id.verify.lazy').then((d) => d.Route), ) +const EmailsIdInUseRoute = EmailsIdInUseImport.update({ + id: '/emails/$id/in-use', + path: '/emails/$id/in-use', + getParentRoute: () => rootRoute, +} as any) + const AccountSessionsBrowsersRoute = AccountSessionsBrowsersImport.update({ id: '/sessions/browsers', path: '/sessions/browsers', @@ -217,6 +224,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AccountSessionsBrowsersImport parentRoute: typeof AccountImport } + '/emails/$id/in-use': { + id: '/emails/$id/in-use' + path: '/emails/$id/in-use' + fullPath: '/emails/$id/in-use' + preLoaderRoute: typeof EmailsIdInUseImport + parentRoute: typeof rootRoute + } '/emails/$id/verify': { id: '/emails/$id/verify' path: '/emails/$id/verify' @@ -300,6 +314,7 @@ export interface FileRoutesByFullPath { '/reset-cross-signing/': typeof ResetCrossSigningIndexRoute '/sessions/$id': typeof AccountSessionsIdRoute '/sessions/browsers': typeof AccountSessionsBrowsersRoute + '/emails/$id/in-use': typeof EmailsIdInUseRoute '/emails/$id/verify': typeof EmailsIdVerifyRoute '/password/change/success': typeof PasswordChangeSuccessLazyRoute '/sessions': typeof AccountSessionsIndexRoute @@ -316,6 +331,7 @@ export interface FileRoutesByTo { '/reset-cross-signing': typeof ResetCrossSigningIndexRoute '/sessions/$id': typeof AccountSessionsIdRoute '/sessions/browsers': typeof AccountSessionsBrowsersRoute + '/emails/$id/in-use': typeof EmailsIdInUseRoute '/emails/$id/verify': typeof EmailsIdVerifyRoute '/password/change/success': typeof PasswordChangeSuccessLazyRoute '/sessions': typeof AccountSessionsIndexRoute @@ -335,6 +351,7 @@ export interface FileRoutesById { '/reset-cross-signing/': typeof ResetCrossSigningIndexRoute '/_account/sessions/$id': typeof AccountSessionsIdRoute '/_account/sessions/browsers': typeof AccountSessionsBrowsersRoute + '/emails/$id/in-use': typeof EmailsIdInUseRoute '/emails/$id/verify': typeof EmailsIdVerifyRoute '/password/change/success': typeof PasswordChangeSuccessLazyRoute '/_account/sessions/': typeof AccountSessionsIndexRoute @@ -355,6 +372,7 @@ export interface FileRouteTypes { | '/reset-cross-signing/' | '/sessions/$id' | '/sessions/browsers' + | '/emails/$id/in-use' | '/emails/$id/verify' | '/password/change/success' | '/sessions' @@ -370,6 +388,7 @@ export interface FileRouteTypes { | '/reset-cross-signing' | '/sessions/$id' | '/sessions/browsers' + | '/emails/$id/in-use' | '/emails/$id/verify' | '/password/change/success' | '/sessions' @@ -387,6 +406,7 @@ export interface FileRouteTypes { | '/reset-cross-signing/' | '/_account/sessions/$id' | '/_account/sessions/browsers' + | '/emails/$id/in-use' | '/emails/$id/verify' | '/password/change/success' | '/_account/sessions/' @@ -400,6 +420,7 @@ export interface RootRouteChildren { ResetCrossSigningRoute: typeof ResetCrossSigningRouteWithChildren ClientsIdRoute: typeof ClientsIdRoute DevicesSplatRoute: typeof DevicesSplatRoute + EmailsIdInUseRoute: typeof EmailsIdInUseRoute EmailsIdVerifyRoute: typeof EmailsIdVerifyRoute PasswordChangeSuccessLazyRoute: typeof PasswordChangeSuccessLazyRoute PasswordChangeIndexRoute: typeof PasswordChangeIndexRoute @@ -411,6 +432,7 @@ const rootRouteChildren: RootRouteChildren = { ResetCrossSigningRoute: ResetCrossSigningRouteWithChildren, ClientsIdRoute: ClientsIdRoute, DevicesSplatRoute: DevicesSplatRoute, + EmailsIdInUseRoute: EmailsIdInUseRoute, EmailsIdVerifyRoute: EmailsIdVerifyRoute, PasswordChangeSuccessLazyRoute: PasswordChangeSuccessLazyRoute, PasswordChangeIndexRoute: PasswordChangeIndexRoute, @@ -431,6 +453,7 @@ export const routeTree = rootRoute "/reset-cross-signing", "/clients/$id", "/devices/$", + "/emails/$id/in-use", "/emails/$id/verify", "/password/change/success", "/password/change/", @@ -484,6 +507,9 @@ export const routeTree = rootRoute "filePath": "_account.sessions.browsers.tsx", "parent": "/_account" }, + "/emails/$id/in-use": { + "filePath": "emails.$id.in-use.tsx" + }, "/emails/$id/verify": { "filePath": "emails.$id.verify.tsx" }, diff --git a/frontend/src/routes/emails.$id.in-use.tsx b/frontend/src/routes/emails.$id.in-use.tsx new file mode 100644 index 000000000..c12120f5c --- /dev/null +++ b/frontend/src/routes/emails.$id.in-use.tsx @@ -0,0 +1,56 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +import { useSuspenseQuery } from "@tanstack/react-query"; +import { createFileRoute, notFound, redirect } from "@tanstack/react-router"; +import IconArrowLeft from "@vector-im/compound-design-tokens/assets/web/icons/arrow-left"; +import IconError from "@vector-im/compound-design-tokens/assets/web/icons/error"; +import { useTranslation } from "react-i18next"; +import { ButtonLink } from "../components/ButtonLink"; +import Layout from "../components/Layout"; +import PageHeading from "../components/PageHeading"; +import { query } from "./emails.$id.verify"; + +export const Route = createFileRoute("/emails/$id/in-use")({ + async loader({ context, params }): Promise { + const data = await context.queryClient.ensureQueryData(query(params.id)); + if (!data.userEmailAuthentication) { + throw notFound(); + } + + // If the user has not completed the verification process, it means they got + // to this page by mistake + if (!data.userEmailAuthentication.completedAt) { + throw redirect({ to: "/emails/$id/verify", params }); + } + }, + + component: EmailInUse, +}); + +function EmailInUse(): React.ReactElement { + const { id } = Route.useParams(); + const { + data: { userEmailAuthentication }, + } = useSuspenseQuery(query(id)); + if (!userEmailAuthentication) throw notFound(); + const { t } = useTranslation(); + + return ( + + + + + {t("action.back")} + + + ); +} diff --git a/frontend/src/routes/emails.$id.verify.lazy.tsx b/frontend/src/routes/emails.$id.verify.lazy.tsx index 480109ef0..71c6f3d6f 100644 --- a/frontend/src/routes/emails.$id.verify.lazy.tsx +++ b/frontend/src/routes/emails.$id.verify.lazy.tsx @@ -59,6 +59,8 @@ function EmailVerify(): React.ReactElement { if (data.completeEmailAuthentication.status === "COMPLETED") { await navigate({ to: "/" }); + } else if (data.completeEmailAuthentication.status === "IN_USE") { + await navigate({ to: "/emails/$id/in-use", params: { id } }); } }, }); @@ -99,6 +101,8 @@ function EmailVerify(): React.ReactElement { "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"; @@ -135,6 +139,15 @@ function EmailVerify(): React.ReactElement { )} + {codeExpired && ( + + {t("frontend.verify_email.code_expired_alert.description")} + + )} + {rateLimited && ( +
+
+ {{ icon.error() }} +
+ +
+

+ {{ _("mas.email_in_use.title", email=email) }} +

+

+ {{ _("mas.email_in_use.description") }} +

+
+
+ + {% set params = action | default({}) | to_params(prefix="?") %} + {{ button.link_outline(text=_("action.start_over"), href="/register" ~ params) }} + +{% endblock %} diff --git a/translations/en.json b/translations/en.json index c3797507a..0da0ccf85 100644 --- a/translations/en.json +++ b/translations/en.json @@ -30,7 +30,7 @@ }, "start_over": "Start over", "@start_over": { - "context": "pages/recovery/consumed.html:22:32-54, pages/recovery/expired.html:30:32-54" + "context": "pages/recovery/consumed.html:22:32-54, pages/recovery/expired.html:30:32-54, pages/register/steps/email_in_use.html:28:32-54" } }, "app": { @@ -232,6 +232,16 @@ } } }, + "email_in_use": { + "description": "If you have forgotten your account credentials, you can recover your account. You can also start over and use a different email address.", + "@description": { + "context": "pages/register/steps/email_in_use.html:22:13-46" + }, + "title": "The email address %(email)s is already in use", + "@title": { + "context": "pages/register/steps/email_in_use.html:19:13-53" + } + }, "emails": { "greeting": "Hello %(username)s,", "@greeting": { From a83cdfb517f34a9669c251bf30db3987604b05b3 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 23 Jan 2025 18:31:27 +0100 Subject: [PATCH 25/26] Clarify that VerifyEmailJob is kept for flushing old jobs --- crates/storage/src/queue/tasks.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/storage/src/queue/tasks.rs b/crates/storage/src/queue/tasks.rs index 0beca30c6..3e3eec5e6 100644 --- a/crates/storage/src/queue/tasks.rs +++ b/crates/storage/src/queue/tasks.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. @@ -9,7 +9,9 @@ use ulid::Ulid; use super::InsertableJob; -/// A job to verify an email address. +/// This is the previous iteration of the email verification job. It has been +/// replaced by [`SendEmailAuthenticationCodeJob`]. This struct is kept to be +/// able to consume jobs that are still in the queue. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct VerifyEmailJob { user_email_id: Ulid, From 8d50088d1e9a74d2a5c3d5293ffda82c3014198a Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 23 Jan 2025 18:35:11 +0100 Subject: [PATCH 26/26] Apply code style suggestion Co-authored-by: reivilibre --- templates/pages/register/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/pages/register/index.html b/templates/pages/register/index.html index b89bc0fb0..1f1d16ed6 100644 --- a/templates/pages/register/index.html +++ b/templates/pages/register/index.html @@ -36,7 +36,7 @@

{{ _("mas.register.create_account.heading") }}

{% endif %}
- {% for key, value in next["params"]|default({})|items %} + {% for key, value in next["params"] | default({}) | items %} {% endfor %}