diff --git a/crates/handlers/src/graphql/mutations/user_email.rs b/crates/handlers/src/graphql/mutations/user_email.rs index cbbb10142..958b18be7 100644 --- a/crates/handlers/src/graphql/mutations/user_email.rs +++ b/crates/handlers/src/graphql/mutations/user_email.rs @@ -6,18 +6,75 @@ use anyhow::Context as _; use async_graphql::{Context, Description, Enum, ID, InputObject, Object}; +use mas_data_model::SiteConfig; use mas_i18n::DataLocale; use mas_storage::{ - RepositoryAccess, + BoxRepository, RepositoryAccess, queue::{ProvisionUserJob, QueueJobRepositoryExt as _, SendEmailAuthenticationCodeJob}, user::{UserEmailFilter, UserEmailRepository, UserRepository}, }; +use zeroize::Zeroizing; -use crate::graphql::{ - model::{NodeType, User, UserEmail, UserEmailAuthentication}, - state::ContextExt, +use crate::{ + graphql::{ + Requester, + model::{NodeType, User, UserEmail, UserEmailAuthentication}, + state::ContextExt, + }, + passwords::PasswordManager, }; +/// Check the password if neeed +/// +/// Returns true if password verification is not needed, or if the password is +/// correct. Returns false if the password is incorrect or missing. +async fn verify_password_if_needed( + requester: &Requester, + config: &SiteConfig, + password_manager: &PasswordManager, + password: Option, + user: &mas_data_model::User, + repo: &mut BoxRepository, +) -> Result { + // If the requester is admin, they don't need to provide a password + if requester.is_admin() { + return Ok(true); + } + + // If password login is disabled, assume we don't want the user to reauth + if !config.password_login_enabled { + return Ok(true); + } + + // Else we need to check if the user has a password + let Some(user_password) = repo + .user_password() + .active(user) + .await + .context("Failed to load user password")? + else { + // User has no password, so we don't need to verify the password + return Ok(true); + }; + + let Some(password) = password else { + // There is a password on the user, but not provided in the input + return Ok(false); + }; + + let password = Zeroizing::new(password.into_bytes()); + + let res = password_manager + .verify( + user_password.version, + password, + user_password.hashed_password, + ) + .await; + + Ok(res.is_ok()) +} + #[derive(Default)] pub struct UserEmailMutations { _private: (), @@ -120,6 +177,10 @@ impl AddEmailPayload { struct RemoveEmailInput { /// The ID of the email address to remove user_email_id: ID, + + /// The user's current password. This is required if the user is not an + /// admin and it has a password on its account. + password: Option, } /// The status of the `removeEmail` mutation @@ -130,6 +191,9 @@ enum RemoveEmailStatus { /// The email address was not found NotFound, + + /// The password provided is incorrect + IncorrectPassword, } /// The payload of the `removeEmail` mutation @@ -137,6 +201,7 @@ enum RemoveEmailStatus { enum RemoveEmailPayload { Removed(mas_data_model::UserEmail), NotFound, + IncorrectPassword, } #[Object(use_type_description)] @@ -146,6 +211,7 @@ impl RemoveEmailPayload { match self { RemoveEmailPayload::Removed(_) => RemoveEmailStatus::Removed, RemoveEmailPayload::NotFound => RemoveEmailStatus::NotFound, + RemoveEmailPayload::IncorrectPassword => RemoveEmailStatus::IncorrectPassword, } } @@ -153,20 +219,23 @@ impl RemoveEmailPayload { async fn email(&self) -> Option { match self { RemoveEmailPayload::Removed(email) => Some(UserEmail(email.clone())), - RemoveEmailPayload::NotFound => None, + RemoveEmailPayload::NotFound | RemoveEmailPayload::IncorrectPassword => None, } } /// The user to whom the email address belonged async fn user(&self, ctx: &Context<'_>) -> Result, async_graphql::Error> { let state = ctx.state(); - let mut repo = state.repository().await?; let user_id = match self { RemoveEmailPayload::Removed(email) => email.user_id, - RemoveEmailPayload::NotFound => return Ok(None), + RemoveEmailPayload::NotFound | RemoveEmailPayload::IncorrectPassword => { + return Ok(None); + } }; + let mut repo = state.repository().await?; + let user = repo .user() .lookup(user_id) @@ -226,6 +295,10 @@ struct StartEmailAuthenticationInput { /// The email address to add to the account email: String, + /// The user's current password. This is required if the user has a password + /// on its account. + password: Option, + /// The language to use for the email #[graphql(default = "en")] language: String, @@ -244,6 +317,8 @@ enum StartEmailAuthenticationStatus { Denied, /// The email address is already in use on this account InUse, + /// The password provided is incorrect + IncorrectPassword, } /// The payload of the `startEmailAuthentication` mutation @@ -256,6 +331,7 @@ enum StartEmailAuthenticationPayload { violations: Vec, }, InUse, + IncorrectPassword, } #[Object(use_type_description)] @@ -268,6 +344,7 @@ impl StartEmailAuthenticationPayload { Self::RateLimited => StartEmailAuthenticationStatus::RateLimited, Self::Denied { .. } => StartEmailAuthenticationStatus::Denied, Self::InUse => StartEmailAuthenticationStatus::InUse, + Self::IncorrectPassword => StartEmailAuthenticationStatus::IncorrectPassword, } } @@ -275,9 +352,11 @@ impl StartEmailAuthenticationPayload { async fn authentication(&self) -> Option<&UserEmailAuthentication> { match self { Self::Started(authentication) => Some(authentication), - Self::InvalidEmailAddress | Self::RateLimited | Self::Denied { .. } | Self::InUse => { - None - } + Self::InvalidEmailAddress + | Self::RateLimited + | Self::Denied { .. } + | Self::InUse + | Self::IncorrectPassword => None, } } @@ -494,6 +573,20 @@ impl UserEmailMutations { .await? .context("Failed to load user")?; + // Validate the password input if needed + if !verify_password_if_needed( + requester, + state.site_config(), + &state.password_manager(), + input.password, + &user, + &mut repo, + ) + .await? + { + return Ok(RemoveEmailPayload::IncorrectPassword); + } + // TODO: don't allow removing the last email address repo.user_email().remove(user_email.clone()).await?; @@ -627,6 +720,20 @@ impl UserEmailMutations { }); } + // Validate the password input if needed + if !verify_password_if_needed( + requester, + state.site_config(), + &state.password_manager(), + input.password, + &browser_session.user, + &mut repo, + ) + .await? + { + return Ok(StartEmailAuthenticationPayload::IncorrectPassword); + } + // Create a new authentication session let authentication = repo .user_email() diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 1f5b4499d..dbc360a59 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -5,6 +5,7 @@ "clear": "Clear", "close": "Close", "collapse": "Collapse", + "confirm": "Confirm", "continue": "Continue", "edit": "Edit", "expand": "Expand", @@ -27,6 +28,7 @@ "e2ee": "End-to-end encryption", "loading": "Loading…", "next": "Next", + "password": "Password", "previous": "Previous", "saved": "Saved", "saving": "Saving…" @@ -57,7 +59,9 @@ "email_field_help": "Add an alternative email you can use to access this account.", "email_field_label": "Add email", "email_in_use_error": "The entered email is already in use", - "email_invalid_error": "The entered email is invalid" + "email_invalid_error": "The entered email is invalid", + "incorrect_password_error": "Incorrect password, please try again", + "password_confirmation": "Confirm your account password to add this email address" }, "browser_session_details": { "current_badge": "Current" @@ -258,7 +262,9 @@ "user_email": { "delete_button_confirmation_modal": { "action": "Delete email", - "body": "Delete this email?" + "body": "Delete this email?", + "incorrect_password": "Incorrect password, please try again", + "password_confirmation": "Confirm your account password to delete this email address" }, "delete_button_title": "Remove email address", "email": "Email" diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 7ae680ec9..eeb9b44e4 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -1203,6 +1203,11 @@ input RemoveEmailInput { The ID of the email address to remove """ userEmailId: ID! + """ + The user's current password. This is required if the user is not an + admin and it has a password on its account. + """ + password: String } """ @@ -1235,6 +1240,10 @@ enum RemoveEmailStatus { The email address was not found """ NOT_FOUND + """ + The password provided is incorrect + """ + INCORRECT_PASSWORD } """ @@ -1610,6 +1619,11 @@ input StartEmailAuthenticationInput { """ email: String! """ + The user's current password. This is required if the user has a password + on its account. + """ + password: String + """ The language to use for the email """ language: String! = "en" @@ -1657,6 +1671,10 @@ enum StartEmailAuthenticationStatus { The email address is already in use on this account """ IN_USE + """ + The password provided is incorrect + """ + INCORRECT_PASSWORD } """ diff --git a/frontend/src/components/PasswordConfirmation.tsx b/frontend/src/components/PasswordConfirmation.tsx new file mode 100644 index 000000000..9d3accb80 --- /dev/null +++ b/frontend/src/components/PasswordConfirmation.tsx @@ -0,0 +1,105 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +import { Button, Form } from "@vector-im/compound-web"; +import type React from "react"; +import { useCallback, useImperativeHandle, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as Dialog from "./Dialog"; + +type ModalRef = { + prompt: () => Promise; +}; + +type Props = { + title: string; + destructive?: boolean; + ref: React.Ref; +}; + +/** + * A hook that returns a function that prompts the user to enter a password. + * The returned function returns a promise that resolves to the password, and + * throws an error if the user cancels the prompt. + * + * It also returns a ref that must be passed to a mounted Modal component. + */ +export const usePasswordConfirmation = (): [ + () => Promise, + React.RefObject, +] => { + const ref = useRef({ + prompt: () => { + throw new Error("PasswordConfirmationModal is not mounted!"); + }, + }); + + const prompt = useCallback(() => ref.current.prompt(), []); + + return [prompt, ref] as const; +}; + +const PasswordConfirmationModal: React.FC = ({ + title, + destructive, + ref, +}) => { + const [open, setOpen] = useState(false); + const { t } = useTranslation(); + const resolversRef = useRef>(null); + + useImperativeHandle(ref, () => ({ + prompt: () => { + setOpen(true); + if (resolversRef.current === null) { + resolversRef.current = Promise.withResolvers(); + } + return resolversRef.current.promise; + }, + })); + + const onOpenChange = useCallback((open: boolean) => { + setOpen(open); + if (!open) { + resolversRef.current?.reject(new Error("User cancelled password prompt")); + resolversRef.current = null; + } + }, []); + + const onSubmit = useCallback((e: React.FormEvent) => { + e.preventDefault(); + const data = new FormData(e.currentTarget); + const password = data.get("password"); + if (typeof password !== "string") { + throw new Error(); // This should never happen + } + resolversRef.current?.resolve(password); + resolversRef.current = null; + setOpen(false); + }, []); + + return ( + + {title} + + + + {t("common.password")} + + + + + + + + + + + ); +}; + +export default PasswordConfirmationModal; diff --git a/frontend/src/components/UserEmail/UserEmail.module.css b/frontend/src/components/UserEmail/UserEmail.module.css index a59c48717..69bac1368 100644 --- a/frontend/src/components/UserEmail/UserEmail.module.css +++ b/frontend/src/components/UserEmail/UserEmail.module.css @@ -38,6 +38,7 @@ button[disabled] .user-email-delete-icon { display: flex; align-items: center; gap: var(--cpd-space-4x); + border-radius: var(--cpd-space-4x); border: 1px solid var(--cpd-color-gray-400); padding: var(--cpd-space-3x); font: var(--cpd-font-body-md-semibold); diff --git a/frontend/src/components/UserEmail/UserEmail.tsx b/frontend/src/components/UserEmail/UserEmail.tsx index 02771412e..725fb9e3b 100644 --- a/frontend/src/components/UserEmail/UserEmail.tsx +++ b/frontend/src/components/UserEmail/UserEmail.tsx @@ -7,16 +7,25 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import IconDelete from "@vector-im/compound-design-tokens/assets/web/icons/delete"; import IconEmail from "@vector-im/compound-design-tokens/assets/web/icons/email"; -import { Button, Form, IconButton, Tooltip } from "@vector-im/compound-web"; -import type { ComponentProps, ReactNode } from "react"; +import { + Button, + ErrorMessage, + Form, + IconButton, + Tooltip, +} from "@vector-im/compound-web"; +import { type ReactNode, useCallback, useState } from "react"; 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 LoadingSpinner from "../LoadingSpinner"; +import PasswordConfirmationModal, { + usePasswordConfirmation, +} from "../PasswordConfirmation"; import styles from "./UserEmail.module.css"; -// This component shows a single user email address, with controls to verify it, -// resend the verification email, remove it, and set it as the primary email address. +// This component shows a single user email address, with controls to remove it export const FRAGMENT = graphql(/* GraphQL */ ` fragment UserEmail_email on UserEmail { @@ -25,15 +34,9 @@ export const FRAGMENT = graphql(/* GraphQL */ ` } `); -export const CONFIG_FRAGMENT = graphql(/* GraphQL */ ` - fragment UserEmail_siteConfig on SiteConfig { - emailChangeAllowed - } -`); - const REMOVE_EMAIL_MUTATION = graphql(/* GraphQL */ ` - mutation RemoveEmail($id: ID!) { - removeEmail(input: { userEmailId: $id }) { + mutation RemoveEmail($id: ID!, $password: String) { + removeEmail(input: { userEmailId: $id, password: $password }) { status user { @@ -64,92 +67,135 @@ const DeleteButton: React.FC<{ disabled?: boolean; onClick?: () => void }> = ({ ); -const DeleteButtonWithConfirmation: React.FC< - ComponentProps & { email: string } -> = ({ email, onClick, ...rest }) => { - const { t } = useTranslation(); - const onConfirm = (): void => { - onClick?.(); - }; - - // NOOP function, otherwise we dont render a cancel button - const onDeny = (): void => {}; - - return ( - }> - - {t("frontend.user_email.delete_button_confirmation_modal.body")} - - - -
{email}
-
-
- - - - - - -
-
- ); -}; - const UserEmail: React.FC<{ email: FragmentType; canRemove?: boolean; + shouldPromptPassword?: boolean; onRemove?: () => void; -}> = ({ email, canRemove, onRemove }) => { +}> = ({ email, canRemove, shouldPromptPassword, onRemove }) => { const { t } = useTranslation(); + const [open, setOpen] = useState(false); const data = useFragment(FRAGMENT, email); const queryClient = useQueryClient(); + const [promptPassword, passwordConfirmationRef] = usePasswordConfirmation(); const removeEmail = useMutation({ - mutationFn: (id: string) => - graphqlRequest({ query: REMOVE_EMAIL_MUTATION, variables: { id } }), - onSuccess: (_data) => { - onRemove?.(); + mutationFn: ({ id, password }: { id: string; password?: string }) => + graphqlRequest({ + query: REMOVE_EMAIL_MUTATION, + variables: { id, password }, + }), + + onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ["currentUserGreeting"] }); queryClient.invalidateQueries({ queryKey: ["userEmails"] }); + + // Don't close the modal unless the mutation was successful removed (or not found) + if ( + data.removeEmail.status !== "NOT_FOUND" && + data.removeEmail.status !== "REMOVED" + ) { + return; + } + + onRemove?.(); + setOpen(false); }, }); - const onRemoveClick = (): void => { - removeEmail.mutate(data.id); - }; + const onRemoveClick = useCallback( + async (_e: React.MouseEvent): Promise => { + let password = undefined; + if (shouldPromptPassword) { + password = await promptPassword(); + } + removeEmail.mutate({ id: data.id, password }); + }, + [data.id, promptPassword, shouldPromptPassword, removeEmail.mutate], + ); + + const onOpenChange = useCallback( + (open: boolean) => { + // Don't change the modal state if the mutation is pending + if (removeEmail.isPending) return; + removeEmail.reset(); + setOpen(open); + }, + [removeEmail.isPending, removeEmail.reset], + ); + + const status = removeEmail.data?.removeEmail.status ?? null; return ( - - - {t("frontend.user_email.email")} - -
- - {canRemove && ( - + + + + {t("frontend.user_email.email")} + +
+ - )} -
-
-
+ {canRemove && ( + } + open={open} + onOpenChange={onOpenChange} + > + + {t( + "frontend.user_email.delete_button_confirmation_modal.body", + )} + + + +
{data.email}
+
+ + {status === "INCORRECT_PASSWORD" && ( + + {t( + "frontend.user_email.delete_button_confirmation_modal.incorrect_password", + )} + + )} + +
+ + + + +
+
+ )} +
+
+
+ ); }; diff --git a/frontend/src/components/UserProfile/AddEmailForm.tsx b/frontend/src/components/UserProfile/AddEmailForm.tsx index 6459f495e..8e8ab7962 100644 --- a/frontend/src/components/UserProfile/AddEmailForm.tsx +++ b/frontend/src/components/UserProfile/AddEmailForm.tsx @@ -10,13 +10,33 @@ import { ErrorMessage, HelpMessage, } from "@vector-im/compound-web"; +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; -import { graphql } from "../../gql"; +import { type FragmentType, graphql, useFragment } from "../../gql"; import { graphqlRequest } from "../../graphql"; +import PasswordConfirmationModal, { + usePasswordConfirmation, +} from "../PasswordConfirmation"; + +export const USER_FRAGMENT = graphql(/* GraphQL */ ` + fragment AddEmailForm_user on User { + hasPassword + } +`); + +export const CONFIG_FRAGMENT = graphql(/* GraphQL */ ` + fragment AddEmailForm_siteConfig on SiteConfig { + passwordLoginEnabled + } +`); const ADD_EMAIL_MUTATION = graphql(/* GraphQL */ ` - mutation AddEmail($email: String!, $language: String!) { - startEmailAuthentication(input: { email: $email, language: $language }) { + mutation AddEmail($email: String!, $password: String, $language: String!) { + startEmailAuthentication(input: { + email: $email, + password: $password, + language: $language + }) { status violations authentication { @@ -28,14 +48,26 @@ const ADD_EMAIL_MUTATION = graphql(/* GraphQL */ ` const AddEmailForm: React.FC<{ onAdd: (id: string) => Promise; -}> = ({ onAdd }) => { + user: FragmentType; + siteConfig: FragmentType; +}> = ({ user, siteConfig, onAdd }) => { + const { hasPassword } = useFragment(USER_FRAGMENT, user); + const { passwordLoginEnabled } = useFragment(CONFIG_FRAGMENT, siteConfig); + + const shouldPromptPassword = hasPassword && passwordLoginEnabled; + const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); + const [promptPassword, passwordConfirmationRef] = usePasswordConfirmation(); const addEmail = useMutation({ - mutationFn: ({ email, language }: { email: string; language: string }) => + mutationFn: ({ + email, + password, + language, + }: { email: string; password?: string; language: string }) => graphqlRequest({ query: ADD_EMAIL_MUTATION, - variables: { email, language }, + variables: { email, password, language }, }), onSuccess: async (data) => { queryClient.invalidateQueries({ queryKey: ["userEmails"] }); @@ -54,62 +86,96 @@ const AddEmailForm: React.FC<{ }, }); - const handleSubmit = async ( - e: React.FormEvent, - ): Promise => { - e.preventDefault(); + const handleSubmit = useCallback( + async (e: React.FormEvent): Promise => { + e.preventDefault(); + + const formData = new FormData(e.currentTarget); + const email = formData.get("input") as string; + let password = undefined; + if (shouldPromptPassword) { + password = await promptPassword(); + } + + const data = await addEmail.mutateAsync({ + email, + password, + language: i18n.languages[0], + }); - const formData = new FormData(e.currentTarget); - const email = formData.get("input") as string; - await addEmail.mutateAsync({ email, language: i18n.languages[0] }); - }; + if (data.startEmailAuthentication.status !== "STARTED") { + // This is so that the 'Edit in place' component doesn't show a 'Saved' message + throw new Error(); + } + }, + [ + addEmail.mutateAsync, + shouldPromptPassword, + promptPassword, + i18n.languages, + ], + ); const status = addEmail.data?.startEmailAuthentication.status ?? null; const violations = addEmail.data?.startEmailAuthentication.violations ?? []; return ( - - + + - {t("frontend.add_email_form.email_invalid_error")} - - - {status === "IN_USE" && ( - - {t("frontend.add_email_form.email_in_use_error")} + + {t("frontend.add_email_form.email_invalid_error")} - )} - {status === "RATE_LIMITED" && ( - {t("frontend.errors.rate_limit_exceeded")} - )} + {status === "IN_USE" && ( + + {t("frontend.add_email_form.email_in_use_error")} + + )} - {status === "DENIED" && ( - <> + {status === "RATE_LIMITED" && ( - {t("frontend.add_email_form.email_denied_error")} + {t("frontend.errors.rate_limit_exceeded")} + )} + + {status === "DENIED" && ( + <> + + {t("frontend.add_email_form.email_denied_error")} + + + {violations.map((violation) => ( + // XXX: those messages are bad, but it's better to show them than show a generic message + {violation} + ))} + + )} - {violations.map((violation) => ( - // XXX: those messages are bad, but it's better to show them than show a generic message - {violation} - ))} - - )} - + {status === "INCORRECT_PASSWORD" && ( + + {t("frontend.add_email_form.incorrect_password_error")} + + )} + + ); }; diff --git a/frontend/src/components/UserProfile/UserEmailList.tsx b/frontend/src/components/UserProfile/UserEmailList.tsx index 6db4adcf3..8c7394379 100644 --- a/frontend/src/components/UserProfile/UserEmailList.tsx +++ b/frontend/src/components/UserProfile/UserEmailList.tsx @@ -60,16 +60,30 @@ export const query = (pagination: AnyPagination = { first: 6 }) => }), }); +export const USER_FRAGMENT = graphql(/* GraphQL */ ` + fragment UserEmailList_user on User { + hasPassword + } +`); + export const CONFIG_FRAGMENT = graphql(/* GraphQL */ ` fragment UserEmailList_siteConfig on SiteConfig { emailChangeAllowed + passwordLoginEnabled } `); const UserEmailList: React.FC<{ siteConfig: FragmentType; -}> = ({ siteConfig }) => { - const { emailChangeAllowed } = useFragment(CONFIG_FRAGMENT, siteConfig); + user: FragmentType; +}> = ({ siteConfig, user }) => { + const { emailChangeAllowed, passwordLoginEnabled } = useFragment( + CONFIG_FRAGMENT, + siteConfig, + ); + const { hasPassword } = useFragment(USER_FRAGMENT, user); + const shouldPromptPassword = hasPassword && passwordLoginEnabled; + const [pending, startTransition] = useTransition(); const [pagination, setPagination] = usePagination(); @@ -102,6 +116,7 @@ const UserEmailList: React.FC<{ email={edge.node} key={edge.cursor} canRemove={canRemove} + shouldPromptPassword={shouldPromptPassword} onRemove={onRemove} /> ))} diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index 4561dfad1..5a0aa8aad 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -33,16 +33,18 @@ type Documents = { "\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n\n ...EndCompatSessionButton_session\n\n userAgent {\n name\n os\n model\n }\n\n ssoLogin {\n id\n redirectUri\n }\n }\n": typeof types.CompatSession_DetailFragmentDoc, "\n fragment OAuth2Session_detail on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n }\n\n client {\n id\n clientId\n clientName\n clientUri\n logoUri\n }\n }\n": typeof types.OAuth2Session_DetailFragmentDoc, "\n fragment UserEmail_email on UserEmail {\n id\n email\n }\n": typeof types.UserEmail_EmailFragmentDoc, - "\n fragment UserEmail_siteConfig on SiteConfig {\n emailChangeAllowed\n }\n": typeof types.UserEmail_SiteConfigFragmentDoc, - "\n mutation RemoveEmail($id: ID!) {\n removeEmail(input: { userEmailId: $id }) {\n status\n\n user {\n id\n }\n }\n }\n": typeof types.RemoveEmailDocument, + "\n mutation RemoveEmail($id: ID!, $password: String) {\n removeEmail(input: { userEmailId: $id, password: $password }) {\n status\n\n user {\n id\n }\n }\n }\n": typeof types.RemoveEmailDocument, "\n fragment UserGreeting_user on User {\n id\n matrix {\n mxid\n displayName\n }\n }\n": typeof types.UserGreeting_UserFragmentDoc, "\n fragment UserGreeting_siteConfig on SiteConfig {\n displayNameChangeAllowed\n }\n": typeof types.UserGreeting_SiteConfigFragmentDoc, "\n mutation SetDisplayName($userId: ID!, $displayName: String) {\n setDisplayName(input: { userId: $userId, displayName: $displayName }) {\n status\n }\n }\n": typeof types.SetDisplayNameDocument, - "\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 types.AddEmailDocument, + "\n fragment AddEmailForm_user on User {\n hasPassword\n }\n": typeof types.AddEmailForm_UserFragmentDoc, + "\n fragment AddEmailForm_siteConfig on SiteConfig {\n passwordLoginEnabled\n }\n": typeof types.AddEmailForm_SiteConfigFragmentDoc, + "\n mutation AddEmail($email: String!, $password: String, $language: String!) {\n startEmailAuthentication(input: {\n email: $email,\n password: $password,\n language: $language\n }) {\n status\n violations\n authentication {\n id\n }\n }\n }\n": typeof 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": typeof types.UserEmailListDocument, - "\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n }\n": typeof types.UserEmailList_SiteConfigFragmentDoc, + "\n fragment UserEmailList_user on User {\n hasPassword\n }\n": typeof types.UserEmailList_UserFragmentDoc, + "\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n }\n": typeof types.UserEmailList_SiteConfigFragmentDoc, "\n fragment BrowserSessionsOverview_user on User {\n id\n\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n": typeof types.BrowserSessionsOverview_UserFragmentDoc, - "\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n ...UserEmailList_siteConfig\n ...UserEmail_siteConfig\n ...PasswordChange_siteConfig\n }\n }\n": typeof types.UserProfileDocument, + "\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n }\n }\n": typeof types.UserProfileDocument, "\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": typeof types.BrowserSessionListDocument, "\n query SessionsOverview {\n viewer {\n __typename\n\n ... on User {\n id\n ...BrowserSessionsOverview_user\n }\n }\n }\n": typeof 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": typeof types.AppSessionsListDocument, @@ -82,16 +84,18 @@ const documents: Documents = { "\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n\n ...EndCompatSessionButton_session\n\n userAgent {\n name\n os\n model\n }\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\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n }\n\n client {\n id\n clientId\n clientName\n clientUri\n logoUri\n }\n }\n": types.OAuth2Session_DetailFragmentDoc, "\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 RemoveEmail($id: ID!, $password: String) {\n removeEmail(input: { userEmailId: $id, password: $password }) {\n status\n\n user {\n id\n }\n }\n }\n": types.RemoveEmailDocument, "\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($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 fragment AddEmailForm_user on User {\n hasPassword\n }\n": types.AddEmailForm_UserFragmentDoc, + "\n fragment AddEmailForm_siteConfig on SiteConfig {\n passwordLoginEnabled\n }\n": types.AddEmailForm_SiteConfigFragmentDoc, + "\n mutation AddEmail($email: String!, $password: String, $language: String!) {\n startEmailAuthentication(input: {\n email: $email,\n password: $password,\n language: $language\n }) {\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 UserEmailList_user on User {\n hasPassword\n }\n": types.UserEmailList_UserFragmentDoc, + "\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\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 query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n hasPassword\n emails(first: 0) {\n totalCount\n }\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 viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n }\n }\n": types.UserProfileDocument, "\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, @@ -188,11 +192,7 @@ export function graphql(source: "\n fragment UserEmail_email on UserEmail {\n /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n fragment UserEmail_siteConfig on SiteConfig {\n emailChangeAllowed\n }\n"): typeof import('./graphql').UserEmail_SiteConfigFragmentDoc; -/** - * 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; +export function graphql(source: "\n mutation RemoveEmail($id: ID!, $password: String) {\n removeEmail(input: { userEmailId: $id, password: $password }) {\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. */ @@ -208,7 +208,15 @@ 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($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; +export function graphql(source: "\n fragment AddEmailForm_user on User {\n hasPassword\n }\n"): typeof import('./graphql').AddEmailForm_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 AddEmailForm_siteConfig on SiteConfig {\n passwordLoginEnabled\n }\n"): typeof import('./graphql').AddEmailForm_SiteConfigFragmentDoc; +/** + * 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($email: String!, $password: String, $language: String!) {\n startEmailAuthentication(input: {\n email: $email,\n password: $password,\n language: $language\n }) {\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. */ @@ -216,7 +224,11 @@ export function graphql(source: "\n query UserEmailList(\n $first: Int\n /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n }\n"): typeof import('./graphql').UserEmailList_SiteConfigFragmentDoc; +export function graphql(source: "\n fragment UserEmailList_user on User {\n hasPassword\n }\n"): typeof import('./graphql').UserEmailList_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 UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\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. */ @@ -224,7 +236,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 query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n hasPassword\n emails(first: 0) {\n totalCount\n }\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 viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n ...AddEmailForm_siteConfig\n ...UserEmailList_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. */ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index b0b581717..b55a67402 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -935,6 +935,11 @@ export type QueryUsersArgs = { /** The input for the `removeEmail` mutation */ export type RemoveEmailInput = { + /** + * The user's current password. This is required if the user is not an + * admin and it has a password on its account. + */ + password?: InputMaybe; /** The ID of the email address to remove */ userEmailId: Scalars['ID']['input']; }; @@ -952,6 +957,8 @@ export type RemoveEmailPayload = { /** The status of the `removeEmail` mutation */ export type RemoveEmailStatus = + /** The password provided is incorrect */ + | 'INCORRECT_PASSWORD' /** The email address was not found */ | 'NOT_FOUND' /** The email address was removed */ @@ -1190,6 +1197,11 @@ export type StartEmailAuthenticationInput = { email: Scalars['String']['input']; /** The language to use for the email */ language?: Scalars['String']['input']; + /** + * The user's current password. This is required if the user has a password + * on its account. + */ + password?: InputMaybe; }; /** The payload of the `startEmailAuthentication` mutation */ @@ -1207,6 +1219,8 @@ export type StartEmailAuthenticationPayload = { export type StartEmailAuthenticationStatus = /** The email address isn't allowed by the policy */ | 'DENIED' + /** The password provided is incorrect */ + | 'INCORRECT_PASSWORD' /** The email address is invalid */ | 'INVALID_EMAIL_ADDRESS' /** The email address is already in use on this account */ @@ -1640,10 +1654,9 @@ export type OAuth2Session_DetailFragment = ( export type UserEmail_EmailFragment = { __typename?: 'UserEmail', id: string, email: string } & { ' $fragmentName'?: 'UserEmail_EmailFragment' }; -export type UserEmail_SiteConfigFragment = { __typename?: 'SiteConfig', emailChangeAllowed: boolean } & { ' $fragmentName'?: 'UserEmail_SiteConfigFragment' }; - export type RemoveEmailMutationVariables = Exact<{ id: Scalars['ID']['input']; + password?: InputMaybe; }>; @@ -1661,8 +1674,13 @@ export type SetDisplayNameMutationVariables = Exact<{ export type SetDisplayNameMutation = { __typename?: 'Mutation', setDisplayName: { __typename?: 'SetDisplayNamePayload', status: SetDisplayNameStatus } }; +export type AddEmailForm_UserFragment = { __typename?: 'User', hasPassword: boolean } & { ' $fragmentName'?: 'AddEmailForm_UserFragment' }; + +export type AddEmailForm_SiteConfigFragment = { __typename?: 'SiteConfig', passwordLoginEnabled: boolean } & { ' $fragmentName'?: 'AddEmailForm_SiteConfigFragment' }; + export type AddEmailMutationVariables = Exact<{ email: Scalars['String']['input']; + password?: InputMaybe; language: Scalars['String']['input']; }>; @@ -1682,16 +1700,21 @@ export type UserEmailListQuery = { __typename?: 'Query', viewer: { __typename: ' & { ' $fragmentRefs'?: { 'UserEmail_EmailFragment': UserEmail_EmailFragment } } ) }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } }; -export type UserEmailList_SiteConfigFragment = { __typename?: 'SiteConfig', emailChangeAllowed: boolean } & { ' $fragmentName'?: 'UserEmailList_SiteConfigFragment' }; +export type UserEmailList_UserFragment = { __typename?: 'User', hasPassword: boolean } & { ' $fragmentName'?: 'UserEmailList_UserFragment' }; + +export type UserEmailList_SiteConfigFragment = { __typename?: 'SiteConfig', emailChangeAllowed: boolean, passwordLoginEnabled: boolean } & { ' $fragmentName'?: 'UserEmailList_SiteConfigFragment' }; export type BrowserSessionsOverview_UserFragment = { __typename?: 'User', id: string, browserSessions: { __typename?: 'BrowserSessionConnection', totalCount: number } } & { ' $fragmentName'?: 'BrowserSessionsOverview_UserFragment' }; export type UserProfileQueryVariables = Exact<{ [key: string]: never; }>; -export type UserProfileQuery = { __typename?: 'Query', viewerSession: { __typename: 'Anonymous' } | { __typename: 'BrowserSession', id: string, user: { __typename?: 'User', hasPassword: boolean, emails: { __typename?: 'UserEmailConnection', totalCount: number } } } | { __typename: 'Oauth2Session' }, siteConfig: ( +export type UserProfileQuery = { __typename?: 'Query', viewerSession: { __typename: 'Anonymous' } | { __typename: 'BrowserSession', id: string, user: ( + { __typename?: 'User', hasPassword: boolean, emails: { __typename?: 'UserEmailConnection', totalCount: number } } + & { ' $fragmentRefs'?: { 'AddEmailForm_UserFragment': AddEmailForm_UserFragment;'UserEmailList_UserFragment': UserEmailList_UserFragment } } + ) } | { __typename: 'Oauth2Session' }, siteConfig: ( { __typename?: 'SiteConfig', emailChangeAllowed: boolean, passwordLoginEnabled: boolean } - & { ' $fragmentRefs'?: { 'UserEmailList_SiteConfigFragment': UserEmailList_SiteConfigFragment;'UserEmail_SiteConfigFragment': UserEmail_SiteConfigFragment;'PasswordChange_SiteConfigFragment': PasswordChange_SiteConfigFragment } } + & { ' $fragmentRefs'?: { 'AddEmailForm_SiteConfigFragment': AddEmailForm_SiteConfigFragment;'UserEmailList_SiteConfigFragment': UserEmailList_SiteConfigFragment;'PasswordChange_SiteConfigFragment': PasswordChange_SiteConfigFragment } } ) }; export type BrowserSessionListQueryVariables = Exact<{ @@ -2147,11 +2170,6 @@ export const UserEmail_EmailFragmentDoc = new TypedDocumentString(` email } `, {"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 @@ -2166,9 +2184,25 @@ export const UserGreeting_SiteConfigFragmentDoc = new TypedDocumentString(` displayNameChangeAllowed } `, {"fragmentName":"UserGreeting_siteConfig"}) as unknown as TypedDocumentString; +export const AddEmailForm_UserFragmentDoc = new TypedDocumentString(` + fragment AddEmailForm_user on User { + hasPassword +} + `, {"fragmentName":"AddEmailForm_user"}) as unknown as TypedDocumentString; +export const AddEmailForm_SiteConfigFragmentDoc = new TypedDocumentString(` + fragment AddEmailForm_siteConfig on SiteConfig { + passwordLoginEnabled +} + `, {"fragmentName":"AddEmailForm_siteConfig"}) as unknown as TypedDocumentString; +export const UserEmailList_UserFragmentDoc = new TypedDocumentString(` + fragment UserEmailList_user on User { + hasPassword +} + `, {"fragmentName":"UserEmailList_user"}) as unknown as TypedDocumentString; export const UserEmailList_SiteConfigFragmentDoc = new TypedDocumentString(` fragment UserEmailList_siteConfig on SiteConfig { emailChangeAllowed + passwordLoginEnabled } `, {"fragmentName":"UserEmailList_siteConfig"}) as unknown as TypedDocumentString; export const BrowserSessionsOverview_UserFragmentDoc = new TypedDocumentString(` @@ -2243,8 +2277,8 @@ export const EndOAuth2SessionDocument = new TypedDocumentString(` } `) as unknown as TypedDocumentString; export const RemoveEmailDocument = new TypedDocumentString(` - mutation RemoveEmail($id: ID!) { - removeEmail(input: {userEmailId: $id}) { + mutation RemoveEmail($id: ID!, $password: String) { + removeEmail(input: {userEmailId: $id, password: $password}) { status user { id @@ -2260,8 +2294,10 @@ export const SetDisplayNameDocument = new TypedDocumentString(` } `) as unknown as TypedDocumentString; export const AddEmailDocument = new TypedDocumentString(` - mutation AddEmail($email: String!, $language: String!) { - startEmailAuthentication(input: {email: $email, language: $language}) { + mutation AddEmail($email: String!, $password: String, $language: String!) { + startEmailAuthentication( + input: {email: $email, password: $password, language: $language} + ) { status violations authentication { @@ -2304,6 +2340,8 @@ export const UserProfileDocument = new TypedDocumentString(` ... on BrowserSession { id user { + ...AddEmailForm_user + ...UserEmailList_user hasPassword emails(first: 0) { totalCount @@ -2314,19 +2352,26 @@ export const UserProfileDocument = new TypedDocumentString(` siteConfig { emailChangeAllowed passwordLoginEnabled + ...AddEmailForm_siteConfig ...UserEmailList_siteConfig - ...UserEmail_siteConfig ...PasswordChange_siteConfig } } fragment PasswordChange_siteConfig on SiteConfig { passwordChangeAllowed } -fragment UserEmail_siteConfig on SiteConfig { - emailChangeAllowed +fragment AddEmailForm_user on User { + hasPassword +} +fragment AddEmailForm_siteConfig on SiteConfig { + passwordLoginEnabled +} +fragment UserEmailList_user on User { + hasPassword } fragment UserEmailList_siteConfig on SiteConfig { emailChangeAllowed + passwordLoginEnabled }`) as unknown as TypedDocumentString; export const BrowserSessionListDocument = new TypedDocumentString(` query BrowserSessionList($first: Int, $after: String, $last: Int, $before: String, $lastActive: DateFilter) { @@ -2861,7 +2906,7 @@ export const mockEndOAuth2SessionMutation = (resolver: GraphQLResponseResolver { - * const { id } = variables; + * const { id, password } = variables; * return HttpResponse.json({ * data: { removeEmail } * }) @@ -2905,7 +2950,7 @@ export const mockSetDisplayNameMutation = (resolver: GraphQLResponseResolver { - * const { email, language } = variables; + * const { email, password, language } = variables; * return HttpResponse.json({ * data: { startEmailAuthentication } * }) diff --git a/frontend/src/routes/_account.index.lazy.tsx b/frontend/src/routes/_account.index.lazy.tsx index 22ea129a6..819fa2419 100644 --- a/frontend/src/routes/_account.index.lazy.tsx +++ b/frontend/src/routes/_account.index.lazy.tsx @@ -85,9 +85,18 @@ function Index(): React.ReactElement { defaultOpen title={t("frontend.account.contact_info")} > - - - {siteConfig.emailChangeAllowed && } + + + {siteConfig.emailChangeAllowed && ( + + )} diff --git a/frontend/src/routes/_account.index.tsx b/frontend/src/routes/_account.index.tsx index 026d9bc9d..0e0e19bb2 100644 --- a/frontend/src/routes/_account.index.tsx +++ b/frontend/src/routes/_account.index.tsx @@ -18,6 +18,8 @@ const QUERY = graphql(/* GraphQL */ ` ... on BrowserSession { id user { + ...AddEmailForm_user + ...UserEmailList_user hasPassword emails(first: 0) { totalCount @@ -29,8 +31,8 @@ const QUERY = graphql(/* GraphQL */ ` siteConfig { emailChangeAllowed passwordLoginEnabled + ...AddEmailForm_siteConfig ...UserEmailList_siteConfig - ...UserEmail_siteConfig ...PasswordChange_siteConfig } } diff --git a/frontend/stories/routes/index.stories.tsx b/frontend/stories/routes/index.stories.tsx index 5be20d35f..a9783825c 100644 --- a/frontend/stories/routes/index.stories.tsx +++ b/frontend/stories/routes/index.stories.tsx @@ -8,11 +8,15 @@ import { expect, userEvent, waitFor, within } from "@storybook/test"; import i18n from "i18next"; import { type GraphQLHandler, HttpResponse } from "msw"; import { CONFIG_FRAGMENT as PASSWORD_CHANGE_CONFIG_FRAGMENT } from "../../src/components/AccountManagementPasswordPreview/AccountManagementPasswordPreview"; +import { FRAGMENT as USER_EMAIL_FRAGMENT } from "../../src/components/UserEmail/UserEmail"; import { - CONFIG_FRAGMENT as USER_EMAIL_CONFIG_FRAGMENT, - FRAGMENT as USER_EMAIL_FRAGMENT, -} from "../../src/components/UserEmail/UserEmail"; -import { CONFIG_FRAGMENT as USER_EMAIL_LIST_CONFIG_FRAGMENT } from "../../src/components/UserProfile/UserEmailList"; + CONFIG_FRAGMENT as ADD_USER_EMAIL_CONFIG_FRAGMENT, + USER_FRAGMENT as ADD_USER_EMAIL_USER_FRAGMENT, +} from "../../src/components/UserProfile/AddEmailForm"; +import { + CONFIG_FRAGMENT as USER_EMAIL_LIST_CONFIG_FRAGMENT, + USER_FRAGMENT as USER_EMAIL_LIST_USER_FRAGMENT, +} from "../../src/components/UserProfile/UserEmailList"; import { makeFragmentData } from "../../src/gql"; import { mockUserEmailListQuery, @@ -48,12 +52,26 @@ const userProfileHandler = ({ viewerSession: { __typename: "BrowserSession", id: "session-id", - user: { - hasPassword, - emails: { - totalCount: emailTotalCount, + user: Object.assign( + { + hasPassword, + emails: { + totalCount: emailTotalCount, + }, }, - }, + makeFragmentData( + { + hasPassword, + }, + ADD_USER_EMAIL_USER_FRAGMENT, + ), + makeFragmentData( + { + hasPassword, + }, + USER_EMAIL_LIST_USER_FRAGMENT, + ), + ), }, siteConfig: Object.assign( @@ -64,12 +82,14 @@ const userProfileHandler = ({ makeFragmentData( { emailChangeAllowed, + passwordLoginEnabled, }, - USER_EMAIL_CONFIG_FRAGMENT, + ADD_USER_EMAIL_CONFIG_FRAGMENT, ), makeFragmentData( { emailChangeAllowed, + passwordLoginEnabled, }, USER_EMAIL_LIST_CONFIG_FRAGMENT, ), diff --git a/frontend/tests/mocks/handlers.ts b/frontend/tests/mocks/handlers.ts index 83719c2e3..55993aa11 100644 --- a/frontend/tests/mocks/handlers.ts +++ b/frontend/tests/mocks/handlers.ts @@ -6,15 +6,19 @@ 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 { - CONFIG_FRAGMENT as USER_EMAIL_CONFIG_FRAGMENT, - FRAGMENT as USER_EMAIL_FRAGMENT, -} from "../../src/components/UserEmail/UserEmail"; +import { FRAGMENT as USER_EMAIL_FRAGMENT } from "../../src/components/UserEmail/UserEmail"; import { CONFIG_FRAGMENT as USER_GREETING_CONFIG_FRAGMENT, FRAGMENT as USER_GREETING_FRAGMENT, } from "../../src/components/UserGreeting/UserGreeting"; -import { CONFIG_FRAGMENT as USER_EMAIL_LIST_CONFIG_FRAGMENT } from "../../src/components/UserProfile/UserEmailList"; +import { + CONFIG_FRAGMENT as ADD_USER_EMAIL_CONFIG_FRAGMENT, + USER_FRAGMENT as ADD_USER_EMAIL_USER_FRAGMENT, +} from "../../src/components/UserProfile/AddEmailForm"; +import { + CONFIG_FRAGMENT as USER_EMAIL_LIST_CONFIG_FRAGMENT, + USER_FRAGMENT as USER_EMAIL_LIST_USER_FRAGMENT, +} from "../../src/components/UserProfile/UserEmailList"; import { makeFragmentData } from "../../src/gql"; import { mockCurrentUserGreetingQuery, @@ -90,12 +94,26 @@ export const handlers = [ viewerSession: { __typename: "BrowserSession", id: "browser-session-id", - user: { - hasPassword: true, - emails: { - totalCount: 1, + user: Object.assign( + { + hasPassword: true, + emails: { + totalCount: 1, + }, }, - }, + makeFragmentData( + { + hasPassword: true, + }, + ADD_USER_EMAIL_USER_FRAGMENT, + ), + makeFragmentData( + { + hasPassword: true, + }, + USER_EMAIL_LIST_USER_FRAGMENT, + ), + ), }, siteConfig: Object.assign( @@ -106,12 +124,14 @@ export const handlers = [ makeFragmentData( { emailChangeAllowed: true, + passwordLoginEnabled: true, }, - USER_EMAIL_CONFIG_FRAGMENT, + ADD_USER_EMAIL_CONFIG_FRAGMENT, ), makeFragmentData( { emailChangeAllowed: true, + passwordLoginEnabled: true, }, USER_EMAIL_LIST_CONFIG_FRAGMENT, ), diff --git a/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap b/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap index 89a73c89d..2dbaeda34 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