Confirm account password before adding/removing email addresses

This commit is contained in:
Quentin Gliech
2025-03-07 10:08:05 +01:00
parent 09d185da6d
commit 740dc01b2e
13 changed files with 576 additions and 243 deletions

View File

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

View File

@@ -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<string>;
};
type Props = {
title: string;
destructive?: boolean;
ref: React.Ref<ModalRef>;
};
/**
* 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<string>,
React.RefObject<ModalRef>,
] => {
const ref = useRef<ModalRef>({
prompt: () => {
throw new Error("PasswordConfirmationModal is not mounted!");
},
});
const prompt = useCallback(() => ref.current.prompt(), []);
return [prompt, ref] as const;
};
const PasswordConfirmationModal: React.FC<Props> = ({
title,
destructive,
ref,
}) => {
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const resolversRef = useRef<PromiseWithResolvers<string>>(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<HTMLFormElement>) => {
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 (
<Dialog.Dialog open={open} onOpenChange={onOpenChange}>
<Dialog.Title>{title}</Dialog.Title>
<Form.Root onSubmit={onSubmit}>
<Form.Field name="password">
<Form.Label>{t("common.password")}</Form.Label>
<Form.PasswordControl autoFocus autoComplete="current-password" />
</Form.Field>
<Button type="submit" kind="primary" destructive={destructive}>
{t("action.confirm")}
</Button>
</Form.Root>
<Dialog.Close asChild>
<Button kind="tertiary">{t("action.cancel")}</Button>
</Dialog.Close>
</Dialog.Dialog>
);
};
export default PasswordConfirmationModal;

View File

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

View File

@@ -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 }> = ({
</Translation>
);
const DeleteButtonWithConfirmation: React.FC<
ComponentProps<typeof DeleteButton> & { 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 (
<Dialog trigger={<DeleteButton {...rest} />}>
<Title>
{t("frontend.user_email.delete_button_confirmation_modal.body")}
</Title>
<Description className={styles.emailModalBox}>
<IconEmail />
<div>{email}</div>
</Description>
<div className="flex flex-col gap-4">
<Close asChild>
<Button
kind="primary"
destructive
onClick={onConfirm}
Icon={IconDelete}
>
{t("frontend.user_email.delete_button_confirmation_modal.action")}
</Button>
</Close>
<Close asChild>
<Button kind="tertiary" onClick={onDeny}>
{t("action.cancel")}
</Button>
</Close>
</div>
</Dialog>
);
};
const UserEmail: React.FC<{
email: FragmentType<typeof FRAGMENT>;
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<HTMLButtonElement>): Promise<void> => {
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 (
<Form.Root>
<Form.Field name="email">
<Form.Label>{t("frontend.user_email.email")}</Form.Label>
<>
<PasswordConfirmationModal
title={t(
"frontend.user_email.delete_button_confirmation_modal.password_confirmation",
)}
destructive
ref={passwordConfirmationRef}
/>
<Form.Root>
<Form.Field name="email">
<Form.Label>{t("frontend.user_email.email")}</Form.Label>
<div className="flex items-center gap-2">
<Form.TextControl
type="email"
readOnly
value={data.email}
className={styles.userEmailField}
/>
{canRemove && (
<DeleteButtonWithConfirmation
email={data.email}
disabled={removeEmail.isPending}
onClick={onRemoveClick}
<div className="flex items-center gap-2">
<Form.TextControl
type="email"
readOnly
value={data.email}
className={styles.userEmailField}
/>
)}
</div>
</Form.Field>
</Form.Root>
{canRemove && (
<Dialog
trigger={<DeleteButton />}
open={open}
onOpenChange={onOpenChange}
>
<Title>
{t(
"frontend.user_email.delete_button_confirmation_modal.body",
)}
</Title>
<Description className={styles.emailModalBox}>
<IconEmail />
<div>{data.email}</div>
</Description>
{status === "INCORRECT_PASSWORD" && (
<ErrorMessage>
{t(
"frontend.user_email.delete_button_confirmation_modal.incorrect_password",
)}
</ErrorMessage>
)}
<div className="flex flex-col gap-4">
<Button
kind="primary"
type="button"
destructive
onClick={onRemoveClick}
disabled={removeEmail.isPending}
Icon={removeEmail.isPending ? undefined : IconDelete}
>
{!!removeEmail.isPending && <LoadingSpinner inline />}
{t(
"frontend.user_email.delete_button_confirmation_modal.action",
)}
</Button>
<Close asChild>
<Button disabled={removeEmail.isPending} kind="tertiary">
{t("action.cancel")}
</Button>
</Close>
</div>
</Dialog>
)}
</div>
</Form.Field>
</Form.Root>
</>
);
};

View File

@@ -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<void>;
}> = ({ onAdd }) => {
user: FragmentType<typeof USER_FRAGMENT>;
siteConfig: FragmentType<typeof CONFIG_FRAGMENT>;
}> = ({ 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<HTMLFormElement>,
): Promise<void> => {
e.preventDefault();
const handleSubmit = useCallback(
async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const email = formData.get("input") as string;
await addEmail.mutateAsync({ email, language: i18n.languages[0] });
};
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],
});
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 (
<EditInPlace
onSave={handleSubmit}
required
type="email"
serverInvalid={!!status && status !== "STARTED"}
label={t("frontend.add_email_form.email_field_label")}
helpLabel={t("frontend.add_email_form.email_field_help")}
saveButtonLabel={t("action.save")}
savingLabel={t("common.saving")}
savedLabel={t("common.saved")}
cancelButtonLabel={t("action.cancel")}
>
<ErrorMessage
match="typeMismatch"
forceMatch={status === "INVALID_EMAIL_ADDRESS"}
<>
<PasswordConfirmationModal
title={t("frontend.add_email_form.password_confirmation")}
ref={passwordConfirmationRef}
/>
<EditInPlace
onSave={handleSubmit}
required
type="email"
serverInvalid={!!status && status !== "STARTED"}
label={t("frontend.add_email_form.email_field_label")}
helpLabel={t("frontend.add_email_form.email_field_help")}
saveButtonLabel={t("action.save")}
savingLabel={t("common.saving")}
savedLabel={t("common.saved")}
cancelButtonLabel={t("action.cancel")}
>
{t("frontend.add_email_form.email_invalid_error")}
</ErrorMessage>
{status === "IN_USE" && (
<ErrorMessage>
{t("frontend.add_email_form.email_in_use_error")}
<ErrorMessage
match="typeMismatch"
forceMatch={status === "INVALID_EMAIL_ADDRESS"}
>
{t("frontend.add_email_form.email_invalid_error")}
</ErrorMessage>
)}
{status === "RATE_LIMITED" && (
<ErrorMessage>{t("frontend.errors.rate_limit_exceeded")}</ErrorMessage>
)}
{status === "DENIED" && (
<>
{status === "IN_USE" && (
<ErrorMessage>
{t("frontend.add_email_form.email_denied_error")}
{t("frontend.add_email_form.email_in_use_error")}
</ErrorMessage>
)}
{violations.map((violation) => (
// XXX: those messages are bad, but it's better to show them than show a generic message
<HelpMessage key={violation}>{violation}</HelpMessage>
))}
</>
)}
</EditInPlace>
{status === "RATE_LIMITED" && (
<ErrorMessage>
{t("frontend.errors.rate_limit_exceeded")}
</ErrorMessage>
)}
{status === "DENIED" && (
<>
<ErrorMessage>
{t("frontend.add_email_form.email_denied_error")}
</ErrorMessage>
{violations.map((violation) => (
// XXX: those messages are bad, but it's better to show them than show a generic message
<HelpMessage key={violation}>{violation}</HelpMessage>
))}
</>
)}
{status === "INCORRECT_PASSWORD" && (
<ErrorMessage>
{t("frontend.add_email_form.incorrect_password_error")}
</ErrorMessage>
)}
</EditInPlace>
</>
);
};

View File

@@ -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<typeof CONFIG_FRAGMENT>;
}> = ({ siteConfig }) => {
const { emailChangeAllowed } = useFragment(CONFIG_FRAGMENT, siteConfig);
user: FragmentType<typeof USER_FRAGMENT>;
}> = ({ 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}
/>
))}

View File

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

View File

@@ -1654,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<Scalars['String']['input']>;
}>;
@@ -1675,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<Scalars['String']['input']>;
language: Scalars['String']['input'];
}>;
@@ -1696,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<{
@@ -2161,11 +2170,6 @@ export const UserEmail_EmailFragmentDoc = new TypedDocumentString(`
email
}
`, {"fragmentName":"UserEmail_email"}) as unknown as TypedDocumentString<UserEmail_EmailFragment, unknown>;
export const UserEmail_SiteConfigFragmentDoc = new TypedDocumentString(`
fragment UserEmail_siteConfig on SiteConfig {
emailChangeAllowed
}
`, {"fragmentName":"UserEmail_siteConfig"}) as unknown as TypedDocumentString<UserEmail_SiteConfigFragment, unknown>;
export const UserGreeting_UserFragmentDoc = new TypedDocumentString(`
fragment UserGreeting_user on User {
id
@@ -2180,9 +2184,25 @@ export const UserGreeting_SiteConfigFragmentDoc = new TypedDocumentString(`
displayNameChangeAllowed
}
`, {"fragmentName":"UserGreeting_siteConfig"}) as unknown as TypedDocumentString<UserGreeting_SiteConfigFragment, unknown>;
export const AddEmailForm_UserFragmentDoc = new TypedDocumentString(`
fragment AddEmailForm_user on User {
hasPassword
}
`, {"fragmentName":"AddEmailForm_user"}) as unknown as TypedDocumentString<AddEmailForm_UserFragment, unknown>;
export const AddEmailForm_SiteConfigFragmentDoc = new TypedDocumentString(`
fragment AddEmailForm_siteConfig on SiteConfig {
passwordLoginEnabled
}
`, {"fragmentName":"AddEmailForm_siteConfig"}) as unknown as TypedDocumentString<AddEmailForm_SiteConfigFragment, unknown>;
export const UserEmailList_UserFragmentDoc = new TypedDocumentString(`
fragment UserEmailList_user on User {
hasPassword
}
`, {"fragmentName":"UserEmailList_user"}) as unknown as TypedDocumentString<UserEmailList_UserFragment, unknown>;
export const UserEmailList_SiteConfigFragmentDoc = new TypedDocumentString(`
fragment UserEmailList_siteConfig on SiteConfig {
emailChangeAllowed
passwordLoginEnabled
}
`, {"fragmentName":"UserEmailList_siteConfig"}) as unknown as TypedDocumentString<UserEmailList_SiteConfigFragment, unknown>;
export const BrowserSessionsOverview_UserFragmentDoc = new TypedDocumentString(`
@@ -2257,8 +2277,8 @@ export const EndOAuth2SessionDocument = new TypedDocumentString(`
}
`) as unknown as TypedDocumentString<EndOAuth2SessionMutation, EndOAuth2SessionMutationVariables>;
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
@@ -2274,8 +2294,10 @@ export const SetDisplayNameDocument = new TypedDocumentString(`
}
`) as unknown as TypedDocumentString<SetDisplayNameMutation, SetDisplayNameMutationVariables>;
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 {
@@ -2318,6 +2340,8 @@ export const UserProfileDocument = new TypedDocumentString(`
... on BrowserSession {
id
user {
...AddEmailForm_user
...UserEmailList_user
hasPassword
emails(first: 0) {
totalCount
@@ -2328,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<UserProfileQuery, UserProfileQueryVariables>;
export const BrowserSessionListDocument = new TypedDocumentString(`
query BrowserSessionList($first: Int, $after: String, $last: Int, $before: String, $lastActive: DateFilter) {
@@ -2875,7 +2906,7 @@ export const mockEndOAuth2SessionMutation = (resolver: GraphQLResponseResolver<E
* @example
* mockRemoveEmailMutation(
* ({ query, variables }) => {
* const { id } = variables;
* const { id, password } = variables;
* return HttpResponse.json({
* data: { removeEmail }
* })
@@ -2919,7 +2950,7 @@ export const mockSetDisplayNameMutation = (resolver: GraphQLResponseResolver<Set
* @example
* mockAddEmailMutation(
* ({ query, variables }) => {
* const { email, language } = variables;
* const { email, password, language } = variables;
* return HttpResponse.json({
* data: { startEmailAuthentication }
* })

View File

@@ -85,9 +85,18 @@ function Index(): React.ReactElement {
defaultOpen
title={t("frontend.account.contact_info")}
>
<UserEmailList siteConfig={siteConfig} />
<UserEmailList
user={viewerSession.user}
siteConfig={siteConfig}
/>
{siteConfig.emailChangeAllowed && <AddEmailForm onAdd={onAdd} />}
{siteConfig.emailChangeAllowed && (
<AddEmailForm
user={viewerSession.user}
siteConfig={siteConfig}
onAdd={onAdd}
/>
)}
</Collapsible.Section>
<Separator kind="section" />

View File

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

View File

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

View File

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

View File

@@ -2,18 +2,18 @@
exports[`Account home page > display name edit box > displays an error if the display name is invalid 1`] = `
<div
aria-describedby="radix-:r72:"
aria-labelledby="radix-:r71:"
aria-describedby="radix-:r7q:"
aria-labelledby="radix-:r7p:"
class="_body_9cf7b0"
data-state="open"
id="radix-:r70:"
id="radix-:r7o:"
role="dialog"
style="pointer-events: auto;"
tabindex="-1"
>
<h2
class="_title_9cf7b0"
id="radix-:r71:"
id="radix-:r7p:"
>
Edit profile
</h2>
@@ -40,29 +40,29 @@ exports[`Account home page > display name edit box > displays an error if the di
<label
class="_label_19upo_59"
data-invalid="true"
for="radix-:r8c:"
for="radix-:r9a:"
>
Display name
</label>
<div
class="_container_1s836_8"
id=":r8d:"
id=":r9b:"
>
<input
aria-describedby="radix-:r8j:"
aria-describedby="radix-:r9h:"
aria-invalid="true"
autocomplete="name"
class="_control_sqdq4_10 _control_1s836_13"
data-invalid="true"
id="radix-:r8c:"
id="radix-:r9a:"
name="displayname"
title=""
type="text"
value="Alice"
/>
<button
aria-controls=":r8d:"
aria-labelledby=":r8e:"
aria-controls=":r9b:"
aria-labelledby=":r9c:"
class="_action_1s836_24"
type="button"
>
@@ -82,7 +82,7 @@ exports[`Account home page > display name edit box > displays an error if the di
</div>
<span
class="_message_19upo_85 _help-message_19upo_91"
id="radix-:r8j:"
id="radix-:r9h:"
>
This is what others will see wherever youre signed in.
</span>
@@ -92,13 +92,13 @@ exports[`Account home page > display name edit box > displays an error if the di
>
<label
class="_label_19upo_59"
for="radix-:r8k:"
for="radix-:r9i:"
>
Username
</label>
<input
class="_control_sqdq4_10"
id="radix-:r8k:"
id="radix-:r9i:"
name="mxid"
readonly=""
title=""
@@ -129,7 +129,7 @@ exports[`Account home page > display name edit box > displays an error if the di
Cancel
</button>
<button
aria-labelledby=":r8l:"
aria-labelledby=":r9j:"
class="_close_9cf7b0"
type="button"
>
@@ -150,18 +150,18 @@ exports[`Account home page > display name edit box > displays an error if the di
exports[`Account home page > display name edit box > lets edit the display name 1`] = `
<div
aria-describedby="radix-:r1e:"
aria-labelledby="radix-:r1d:"
aria-describedby="radix-:r1k:"
aria-labelledby="radix-:r1j:"
class="_body_9cf7b0"
data-state="open"
id="radix-:r1c:"
id="radix-:r1i:"
role="dialog"
style="pointer-events: auto;"
tabindex="-1"
>
<h2
class="_title_9cf7b0"
id="radix-:r1d:"
id="radix-:r1j:"
>
Edit profile
</h2>
@@ -186,27 +186,27 @@ exports[`Account home page > display name edit box > lets edit the display name
>
<label
class="_label_19upo_59"
for="radix-:r2o:"
for="radix-:r34:"
>
Display name
</label>
<div
class="_container_1s836_8"
id=":r2p:"
id=":r35:"
>
<input
aria-describedby="radix-:r2v:"
aria-describedby="radix-:r3b:"
autocomplete="name"
class="_control_sqdq4_10 _control_1s836_13"
id="radix-:r2o:"
id="radix-:r34:"
name="displayname"
title=""
type="text"
value="Alice"
/>
<button
aria-controls=":r2p:"
aria-labelledby=":r2q:"
aria-controls=":r35:"
aria-labelledby=":r36:"
class="_action_1s836_24"
type="button"
>
@@ -226,7 +226,7 @@ exports[`Account home page > display name edit box > lets edit the display name
</div>
<span
class="_message_19upo_85 _help-message_19upo_91"
id="radix-:r2v:"
id="radix-:r3b:"
>
This is what others will see wherever youre signed in.
</span>
@@ -236,13 +236,13 @@ exports[`Account home page > display name edit box > lets edit the display name
>
<label
class="_label_19upo_59"
for="radix-:r30:"
for="radix-:r3c:"
>
Username
</label>
<input
class="_control_sqdq4_10"
id="radix-:r30:"
id="radix-:r3c:"
name="mxid"
readonly=""
title=""
@@ -273,7 +273,7 @@ exports[`Account home page > display name edit box > lets edit the display name
Cancel
</button>
<button
aria-labelledby=":r31:"
aria-labelledby=":r3d:"
class="_close_9cf7b0"
type="button"
>
@@ -463,7 +463,7 @@ exports[`Account home page > renders the page 1`] = `
>
<label
class="_label_19upo_59"
for="radix-:rg:"
for="radix-:rj:"
>
Email
</label>
@@ -472,7 +472,7 @@ exports[`Account home page > renders the page 1`] = `
>
<input
class="_control_sqdq4_10 _userEmailField_e2a518"
id="radix-:rg:"
id="radix-:rj:"
name="email"
readonly=""
title=""
@@ -490,7 +490,7 @@ exports[`Account home page > renders the page 1`] = `
>
<label
class="_label_19upo_59"
for="radix-:rh:"
for="radix-:rn:"
>
Add email
</label>
@@ -498,9 +498,9 @@ exports[`Account home page > renders the page 1`] = `
class="_controls_17lij_8"
>
<input
aria-describedby="radix-:ri:"
aria-describedby="radix-:ro:"
class="_control_sqdq4_10"
id="radix-:rh:"
id="radix-:rn:"
name="input"
required=""
title=""
@@ -509,7 +509,7 @@ exports[`Account home page > renders the page 1`] = `
</div>
<span
class="_message_19upo_85 _help-message_19upo_91"
id="radix-:ri:"
id="radix-:ro:"
>
Add an alternative email you can use to access this account.
</span>
@@ -524,7 +524,7 @@ exports[`Account home page > renders the page 1`] = `
role="separator"
/>
<section
aria-labelledby=":rj:"
aria-labelledby=":rp:"
class="_root_f1daaa"
data-state="open"
>
@@ -536,14 +536,14 @@ exports[`Account home page > renders the page 1`] = `
>
<h4
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 _triggerTitle_f1daaa"
id=":rj:"
id=":rp:"
>
Account password
</h4>
<button
aria-controls="radix-:rl:"
aria-controls="radix-:rr:"
aria-expanded="true"
aria-labelledby=":rm:"
aria-labelledby=":rs:"
class="_icon-button_m2erp_8 _triggerIcon_f1daaa"
data-state="open"
role="button"
@@ -573,7 +573,7 @@ exports[`Account home page > renders the page 1`] = `
<article
class="_content_f1daaa"
data-state="open"
id="radix-:rl:"
id="radix-:rr:"
style="transition-duration: 0s; animation-name: none;"
>
<form
@@ -584,14 +584,14 @@ exports[`Account home page > renders the page 1`] = `
>
<label
class="_label_19upo_59"
for="radix-:rr:"
for="radix-:r11:"
>
Password
</label>
<input
aria-describedby="radix-:rs:"
aria-describedby="radix-:r12:"
class="_control_sqdq4_10"
id="radix-:rr:"
id="radix-:r11:"
name="password_preview"
readonly=""
title=""
@@ -600,7 +600,7 @@ exports[`Account home page > renders the page 1`] = `
/>
<span
class="_message_19upo_85 _help-message_19upo_91"
id="radix-:rs:"
id="radix-:r12:"
>
<a
class="_link_7634c3"
@@ -620,7 +620,7 @@ exports[`Account home page > renders the page 1`] = `
role="separator"
/>
<section
aria-labelledby=":rt:"
aria-labelledby=":r13:"
class="_root_f1daaa"
data-state="closed"
>
@@ -632,14 +632,14 @@ exports[`Account home page > renders the page 1`] = `
>
<h4
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93 _triggerTitle_f1daaa"
id=":rt:"
id=":r13:"
>
End-to-end encryption
</h4>
<button
aria-controls="radix-:rv:"
aria-controls="radix-:r15:"
aria-expanded="false"
aria-labelledby=":r10:"
aria-labelledby=":r16:"
class="_icon-button_m2erp_8 _triggerIcon_f1daaa"
data-state="closed"
role="button"
@@ -675,7 +675,7 @@ exports[`Account home page > renders the page 1`] = `
/>
</div>
<button
aria-controls="radix-:r15:"
aria-controls="radix-:r1b:"
aria-expanded="false"
aria-haspopup="dialog"
class="_button_vczzf_8 _has-icon_vczzf_57 _destructive_vczzf_107"