Skip to content

Commit c4a3bdb

Browse files
committed
Confirm account password before adding/removing email addresses
1 parent 09d185d commit c4a3bdb

File tree

13 files changed

+569
-244
lines changed

13 files changed

+569
-244
lines changed

frontend/locales/en.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"clear": "Clear",
66
"close": "Close",
77
"collapse": "Collapse",
8+
"confirm": "Confirm",
89
"continue": "Continue",
910
"edit": "Edit",
1011
"expand": "Expand",
@@ -27,6 +28,7 @@
2728
"e2ee": "End-to-end encryption",
2829
"loading": "Loading…",
2930
"next": "Next",
31+
"password": "Password",
3032
"previous": "Previous",
3133
"saved": "Saved",
3234
"saving": "Saving…"
@@ -57,7 +59,9 @@
5759
"email_field_help": "Add an alternative email you can use to access this account.",
5860
"email_field_label": "Add email",
5961
"email_in_use_error": "The entered email is already in use",
60-
"email_invalid_error": "The entered email is invalid"
62+
"email_invalid_error": "The entered email is invalid",
63+
"incorrect_password_error": "Incorrect password, please try again",
64+
"password_confirmation": "Confirm your account password to add this email address"
6165
},
6266
"browser_session_details": {
6367
"current_badge": "Current"
@@ -258,7 +262,9 @@
258262
"user_email": {
259263
"delete_button_confirmation_modal": {
260264
"action": "Delete email",
261-
"body": "Delete this email?"
265+
"body": "Delete this email?",
266+
"incorrect_password": "Incorrect password, please try again",
267+
"password_confirmation": "Confirm your account password to delete this email address"
262268
},
263269
"delete_button_title": "Remove email address",
264270
"email": "Email"
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright 2025 New Vector Ltd.
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only
4+
// Please see LICENSE in the repository root for full details.
5+
6+
import { Button, Form } from "@vector-im/compound-web";
7+
import type React from "react";
8+
import { useCallback, useImperativeHandle, useRef, useState } from "react";
9+
import { useTranslation } from "react-i18next";
10+
import * as Dialog from "./Dialog";
11+
12+
type ModalRef = {
13+
prompt: () => Promise<string>;
14+
};
15+
16+
type Props = {
17+
title: string;
18+
destructive?: boolean;
19+
ref: React.Ref<ModalRef>;
20+
};
21+
22+
export const usePasswordConfirmation = () => {
23+
const ref = useRef<ModalRef>(null);
24+
25+
const prompt = useCallback(() => {
26+
if (ref.current === null) {
27+
throw new Error("PasswordConfirmationModal not mounted");
28+
}
29+
return ref.current.prompt();
30+
}, []);
31+
32+
return [prompt, ref] as const;
33+
};
34+
35+
const PasswordConfirmationModal: React.FC<Props> = ({
36+
title,
37+
destructive,
38+
ref,
39+
}) => {
40+
const [open, setOpen] = useState(false);
41+
const { t } = useTranslation();
42+
const resolversRef = useRef<PromiseWithResolvers<string>>(null);
43+
44+
useImperativeHandle(ref, () => ({
45+
prompt: () => {
46+
setOpen(true);
47+
if (resolversRef.current === null) {
48+
resolversRef.current = Promise.withResolvers();
49+
}
50+
return resolversRef.current.promise;
51+
},
52+
}));
53+
54+
const onOpenChange = useCallback((open: boolean) => {
55+
setOpen(open);
56+
if (!open) {
57+
resolversRef.current?.reject(new Error("User cancelled password prompt"));
58+
resolversRef.current = null;
59+
}
60+
}, []);
61+
62+
const onSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
63+
e.preventDefault();
64+
const data = new FormData(e.currentTarget);
65+
console.log(data);
66+
const password = data.get("password");
67+
if (typeof password !== "string") {
68+
throw new Error();
69+
}
70+
resolversRef.current?.resolve(password);
71+
resolversRef.current = null;
72+
setOpen(false);
73+
}, []);
74+
75+
return (
76+
<Dialog.Dialog open={open} onOpenChange={onOpenChange}>
77+
<Dialog.Title>{title}</Dialog.Title>
78+
79+
<Form.Root onSubmit={onSubmit}>
80+
<Form.Field name="password">
81+
<Form.Label>{t("common.password")}</Form.Label>
82+
<Form.PasswordControl autoFocus autoComplete="current-password" />
83+
</Form.Field>
84+
85+
<Button type="submit" kind="primary" destructive={destructive}>
86+
{t("action.confirm")}
87+
</Button>
88+
</Form.Root>
89+
90+
<Dialog.Close asChild>
91+
<Button kind="tertiary">{t("action.cancel")}</Button>
92+
</Dialog.Close>
93+
</Dialog.Dialog>
94+
);
95+
};
96+
97+
export default PasswordConfirmationModal;

frontend/src/components/UserEmail/UserEmail.module.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ button[disabled] .user-email-delete-icon {
3838
display: flex;
3939
align-items: center;
4040
gap: var(--cpd-space-4x);
41+
border-radius: var(--cpd-space-4x);
4142
border: 1px solid var(--cpd-color-gray-400);
4243
padding: var(--cpd-space-3x);
4344
font: var(--cpd-font-body-md-semibold);

frontend/src/components/UserEmail/UserEmail.tsx

Lines changed: 127 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,25 @@
77
import { useMutation, useQueryClient } from "@tanstack/react-query";
88
import IconDelete from "@vector-im/compound-design-tokens/assets/web/icons/delete";
99
import IconEmail from "@vector-im/compound-design-tokens/assets/web/icons/email";
10-
import { Button, Form, IconButton, Tooltip } from "@vector-im/compound-web";
11-
import type { ComponentProps, ReactNode } from "react";
10+
import {
11+
Button,
12+
ErrorMessage,
13+
Form,
14+
IconButton,
15+
Tooltip,
16+
} from "@vector-im/compound-web";
17+
import { type ReactNode, useCallback, useState } from "react";
1218
import { Translation, useTranslation } from "react-i18next";
1319
import { type FragmentType, graphql, useFragment } from "../../gql";
1420
import { graphqlRequest } from "../../graphql";
1521
import { Close, Description, Dialog, Title } from "../Dialog";
22+
import LoadingSpinner from "../LoadingSpinner";
23+
import PasswordConfirmationModal, {
24+
usePasswordConfirmation,
25+
} from "../PasswordConfirmation";
1626
import styles from "./UserEmail.module.css";
1727

18-
// This component shows a single user email address, with controls to verify it,
19-
// resend the verification email, remove it, and set it as the primary email address.
28+
// This component shows a single user email address, with controls to remove it
2029

2130
export const FRAGMENT = graphql(/* GraphQL */ `
2231
fragment UserEmail_email on UserEmail {
@@ -25,15 +34,9 @@ export const FRAGMENT = graphql(/* GraphQL */ `
2534
}
2635
`);
2736

28-
export const CONFIG_FRAGMENT = graphql(/* GraphQL */ `
29-
fragment UserEmail_siteConfig on SiteConfig {
30-
emailChangeAllowed
31-
}
32-
`);
33-
3437
const REMOVE_EMAIL_MUTATION = graphql(/* GraphQL */ `
35-
mutation RemoveEmail($id: ID!) {
36-
removeEmail(input: { userEmailId: $id }) {
38+
mutation RemoveEmail($id: ID!, $password: String) {
39+
removeEmail(input: { userEmailId: $id, password: $password }) {
3740
status
3841
3942
user {
@@ -64,92 +67,135 @@ const DeleteButton: React.FC<{ disabled?: boolean; onClick?: () => void }> = ({
6467
</Translation>
6568
);
6669

67-
const DeleteButtonWithConfirmation: React.FC<
68-
ComponentProps<typeof DeleteButton> & { email: string }
69-
> = ({ email, onClick, ...rest }) => {
70-
const { t } = useTranslation();
71-
const onConfirm = (): void => {
72-
onClick?.();
73-
};
74-
75-
// NOOP function, otherwise we dont render a cancel button
76-
const onDeny = (): void => {};
77-
78-
return (
79-
<Dialog trigger={<DeleteButton {...rest} />}>
80-
<Title>
81-
{t("frontend.user_email.delete_button_confirmation_modal.body")}
82-
</Title>
83-
<Description className={styles.emailModalBox}>
84-
<IconEmail />
85-
<div>{email}</div>
86-
</Description>
87-
<div className="flex flex-col gap-4">
88-
<Close asChild>
89-
<Button
90-
kind="primary"
91-
destructive
92-
onClick={onConfirm}
93-
Icon={IconDelete}
94-
>
95-
{t("frontend.user_email.delete_button_confirmation_modal.action")}
96-
</Button>
97-
</Close>
98-
<Close asChild>
99-
<Button kind="tertiary" onClick={onDeny}>
100-
{t("action.cancel")}
101-
</Button>
102-
</Close>
103-
</div>
104-
</Dialog>
105-
);
106-
};
107-
10870
const UserEmail: React.FC<{
10971
email: FragmentType<typeof FRAGMENT>;
11072
canRemove?: boolean;
73+
shouldPromptPassword?: boolean;
11174
onRemove?: () => void;
112-
}> = ({ email, canRemove, onRemove }) => {
75+
}> = ({ email, canRemove, shouldPromptPassword, onRemove }) => {
11376
const { t } = useTranslation();
77+
const [open, setOpen] = useState(false);
11478
const data = useFragment(FRAGMENT, email);
11579
const queryClient = useQueryClient();
80+
const [promptPassword, passwordConfirmationRef] = usePasswordConfirmation();
11681

11782
const removeEmail = useMutation({
118-
mutationFn: (id: string) =>
119-
graphqlRequest({ query: REMOVE_EMAIL_MUTATION, variables: { id } }),
120-
onSuccess: (_data) => {
121-
onRemove?.();
83+
mutationFn: ({ id, password }: { id: string; password?: string }) =>
84+
graphqlRequest({
85+
query: REMOVE_EMAIL_MUTATION,
86+
variables: { id, password },
87+
}),
88+
89+
onSuccess: (data) => {
12290
queryClient.invalidateQueries({ queryKey: ["currentUserGreeting"] });
12391
queryClient.invalidateQueries({ queryKey: ["userEmails"] });
92+
93+
// Don't close the modal unless the mutation was successful removed (or not found)
94+
if (
95+
data.removeEmail.status !== "NOT_FOUND" &&
96+
data.removeEmail.status !== "REMOVED"
97+
) {
98+
return;
99+
}
100+
101+
onRemove?.();
102+
setOpen(false);
124103
},
125104
});
126105

127-
const onRemoveClick = (): void => {
128-
removeEmail.mutate(data.id);
129-
};
106+
const onRemoveClick = useCallback(
107+
async (_e: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
108+
let password = undefined;
109+
if (shouldPromptPassword) {
110+
password = await promptPassword();
111+
}
112+
removeEmail.mutate({ id: data.id, password });
113+
},
114+
[data.id, promptPassword, shouldPromptPassword, removeEmail.mutate],
115+
);
116+
117+
const onOpenChange = useCallback(
118+
(open: boolean) => {
119+
// Don't change the modal state if the mutation is pending
120+
if (removeEmail.isPending) return;
121+
removeEmail.reset();
122+
setOpen(open);
123+
},
124+
[removeEmail.isPending, removeEmail.reset],
125+
);
126+
127+
const status = removeEmail.data?.removeEmail.status ?? null;
130128

131129
return (
132-
<Form.Root>
133-
<Form.Field name="email">
134-
<Form.Label>{t("frontend.user_email.email")}</Form.Label>
135-
136-
<div className="flex items-center gap-2">
137-
<Form.TextControl
138-
type="email"
139-
readOnly
140-
value={data.email}
141-
className={styles.userEmailField}
142-
/>
143-
{canRemove && (
144-
<DeleteButtonWithConfirmation
145-
email={data.email}
146-
disabled={removeEmail.isPending}
147-
onClick={onRemoveClick}
130+
<>
131+
<PasswordConfirmationModal
132+
title={t(
133+
"frontend.user_email.delete_button_confirmation_modal.password_confirmation",
134+
)}
135+
destructive
136+
ref={passwordConfirmationRef}
137+
/>
138+
<Form.Root>
139+
<Form.Field name="email">
140+
<Form.Label>{t("frontend.user_email.email")}</Form.Label>
141+
142+
<div className="flex items-center gap-2">
143+
<Form.TextControl
144+
type="email"
145+
readOnly
146+
value={data.email}
147+
className={styles.userEmailField}
148148
/>
149-
)}
150-
</div>
151-
</Form.Field>
152-
</Form.Root>
149+
{canRemove && (
150+
<Dialog
151+
trigger={<DeleteButton />}
152+
open={open}
153+
onOpenChange={onOpenChange}
154+
>
155+
<Title>
156+
{t(
157+
"frontend.user_email.delete_button_confirmation_modal.body",
158+
)}
159+
</Title>
160+
<Description className={styles.emailModalBox}>
161+
<IconEmail />
162+
<div>{data.email}</div>
163+
</Description>
164+
165+
{status === "INCORRECT_PASSWORD" && (
166+
<ErrorMessage>
167+
{t(
168+
"frontend.user_email.delete_button_confirmation_modal.incorrect_password",
169+
)}
170+
</ErrorMessage>
171+
)}
172+
173+
<div className="flex flex-col gap-4">
174+
<Button
175+
kind="primary"
176+
type="button"
177+
destructive
178+
onClick={onRemoveClick}
179+
disabled={removeEmail.isPending}
180+
Icon={removeEmail.isPending ? undefined : IconDelete}
181+
>
182+
{!!removeEmail.isPending && <LoadingSpinner inline />}
183+
{t(
184+
"frontend.user_email.delete_button_confirmation_modal.action",
185+
)}
186+
</Button>
187+
<Close asChild>
188+
<Button disabled={removeEmail.isPending} kind="tertiary">
189+
{t("action.cancel")}
190+
</Button>
191+
</Close>
192+
</div>
193+
</Dialog>
194+
)}
195+
</div>
196+
</Form.Field>
197+
</Form.Root>
198+
</>
153199
);
154200
};
155201

0 commit comments

Comments
 (0)