Skip to content

Commit d7e5c41

Browse files
committed
Allow users to delete their account in the UI
1 parent 1464341 commit d7e5c41

File tree

9 files changed

+561
-65
lines changed

9 files changed

+561
-65
lines changed

frontend/locales/en.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,18 @@
3737
"account": {
3838
"account_password": "Account password",
3939
"contact_info": "Contact info",
40+
"delete_account": {
41+
"alert_description": "This account will be permanently erased and you’ll no longer have access to any of your messages.",
42+
"alert_title": "You’re about to lose all of your data",
43+
"button": "Delete account",
44+
"dialog_description": "<text>Confirm that you would like to delete your account:</text>\n<profile />\n<list>\n<item>You will not be able to reactivate your account</item>\n<item>You will no longer be able to sign in</item>\n<item>No one will be able to reuse your username (MXID), including you</item>\n<item>You will leave all rooms and direct messages you are in</item>\n<item>You will be removed from the identity server, and no one will be able to find you with your email or phone number</item>\n</list>\n<text>Your old messages will still be visible to people who received them. Would you like to hide your send messages from people who join rooms in the future?</text>",
45+
"dialog_title": "Delete this account?",
46+
"erase_checkbox_label": "Yes, hide all my messages from new joiners",
47+
"incorrect_password": "Incorrect password, please try again",
48+
"mxid_label": "Confirm your Matrix ID ({{ mxid }})",
49+
"mxid_mismatch": "This value does not match your Matrix ID",
50+
"password_label": "Enter your password to continue"
51+
},
4052
"edit_profile": {
4153
"display_name_help": "This is what others will see wherever you’re signed in.",
4254
"display_name_label": "Display name",
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
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 { useMutation } from "@tanstack/react-query";
7+
import IconDelete from "@vector-im/compound-design-tokens/assets/web/icons/delete";
8+
import { Alert, Avatar, Button, Form, Text } from "@vector-im/compound-web";
9+
import { useCallback, useEffect, useState } from "react";
10+
import { Trans, useTranslation } from "react-i18next";
11+
import { type FragmentType, graphql, useFragment } from "../gql";
12+
import { graphqlRequest } from "../graphql";
13+
import * as Dialog from "./Dialog";
14+
import LoadingSpinner from "./LoadingSpinner";
15+
import Separator from "./Separator";
16+
17+
export const USER_FRAGMENT = graphql(/* GraphQL */ `
18+
fragment AccountDeleteButton_user on User {
19+
username
20+
hasPassword
21+
matrix {
22+
mxid
23+
displayName
24+
}
25+
}
26+
`);
27+
28+
export const CONFIG_FRAGMENT = graphql(/* GraphQL */ `
29+
fragment AccountDeleteButton_siteConfig on SiteConfig {
30+
passwordLoginEnabled
31+
}
32+
`);
33+
34+
const MUTATION = graphql(/* GraphQL */ `
35+
mutation DeactivateUser($hsErase: Boolean!, $password: String) {
36+
deactivateUser(input: { hsErase: $hsErase, password: $password }) {
37+
status
38+
}
39+
}
40+
`);
41+
42+
type Props = {
43+
user: FragmentType<typeof USER_FRAGMENT>;
44+
siteConfig: FragmentType<typeof CONFIG_FRAGMENT>;
45+
};
46+
47+
const UserCard: React.FC<{
48+
mxid: string;
49+
displayName?: string | null;
50+
username: string;
51+
}> = ({ mxid, displayName, username }) => (
52+
<section className="flex items-center p-4 gap-4 border border-[var(--cpd-color-gray-400)] rounded-xl">
53+
<Avatar id={mxid} name={displayName || username} size="48px" />
54+
<div className="flex-1 flex flex-col">
55+
<Text type="body" weight="semibold" size="lg" className="text-primary">
56+
{displayName || username}
57+
</Text>
58+
<Text type="body" weight="regular" size="md" className="text-secondary">
59+
{mxid}
60+
</Text>
61+
</div>
62+
</section>
63+
);
64+
65+
const AccountDeleteButton: React.FC<Props> = (props) => {
66+
const user = useFragment(USER_FRAGMENT, props.user);
67+
const siteConfig = useFragment(CONFIG_FRAGMENT, props.siteConfig);
68+
const { t } = useTranslation();
69+
const mutation = useMutation({
70+
mutationFn: ({
71+
password,
72+
hsErase,
73+
}: { password: string | null; hsErase: boolean }) =>
74+
graphqlRequest({
75+
query: MUTATION,
76+
variables: { password, hsErase },
77+
}),
78+
onSuccess: (data) => {
79+
if (data.deactivateUser.status === "DEACTIVATED") {
80+
window.location.reload();
81+
}
82+
},
83+
});
84+
85+
// Track if the form may be valid or not, so that we show the alert and enable
86+
// the submit button only when it is
87+
const [isMaybeValid, setIsMaybeValid] = useState(false);
88+
89+
// We want to *delay* a little bit the submit button being enabled, so that:
90+
// - the user reads the alert
91+
// - *if the password manager autofills the password*, we ignore any auto-submitting of the form
92+
const [allowSubmitting, setAllowSubmitting] = useState(false);
93+
94+
useEffect(() => {
95+
// If the value of isMaybeValid switches to true, we want to flip
96+
// 'allowSubmitting' to true a little bit later
97+
if (isMaybeValid) {
98+
const timer = setTimeout(() => {
99+
setAllowSubmitting(true);
100+
}, 500);
101+
return () => clearTimeout(timer);
102+
}
103+
104+
// If it switches to false, we want to flip 'allowSubmitting' to false
105+
// immediately
106+
setAllowSubmitting(false);
107+
}, [isMaybeValid]);
108+
109+
const onPasswordChange = useCallback(
110+
(e: React.ChangeEvent<HTMLInputElement>) => {
111+
// We don't know if the password is correct, so we consider the form as
112+
// valid if the field is not empty
113+
setIsMaybeValid(e.currentTarget.value !== "");
114+
},
115+
[],
116+
);
117+
118+
const onMxidChange = useCallback(
119+
(e: React.ChangeEvent<HTMLInputElement>) => {
120+
setIsMaybeValid(e.currentTarget.value === user.matrix.mxid);
121+
},
122+
[user.matrix.mxid],
123+
);
124+
125+
const onSubmit = useCallback(
126+
(e: React.FormEvent<HTMLFormElement>) => {
127+
e.preventDefault();
128+
if (!allowSubmitting) return;
129+
130+
const data = new FormData(e.currentTarget);
131+
const password = data.get("password");
132+
if (password !== null && typeof password !== "string") throw new Error();
133+
const hsErase = data.get("hs-erase") === "on";
134+
135+
mutation.mutate({ password, hsErase });
136+
},
137+
[mutation.mutate, allowSubmitting],
138+
);
139+
140+
const incorrectPassword =
141+
mutation.data?.deactivateUser.status === "INCORRECT_PASSWORD";
142+
143+
// We still consider the form as submitted if the mutation is pending, or if
144+
// the mutation has returned a success, so that we continue showing the
145+
// loading spinner during the page reload
146+
const isSubmitting =
147+
mutation.isPending ||
148+
mutation.data?.deactivateUser.status === "DEACTIVATED";
149+
150+
const shouldPromptPassword =
151+
user.hasPassword && siteConfig.passwordLoginEnabled;
152+
153+
return (
154+
<Dialog.Dialog
155+
trigger={
156+
<Button
157+
kind="tertiary"
158+
destructive
159+
size="sm"
160+
className="self-center"
161+
Icon={IconDelete}
162+
>
163+
{t("frontend.account.delete_account.button")}
164+
</Button>
165+
}
166+
>
167+
<Dialog.Title>
168+
{t("frontend.account.delete_account.dialog_title")}
169+
</Dialog.Title>
170+
171+
<Dialog.Description className="flex flex-col gap-4">
172+
<Trans
173+
t={t}
174+
i18nKey="frontend.account.delete_account.dialog_description"
175+
components={{
176+
text: <Text type="body" weight="regular" size="md" />,
177+
list: <ul className="list-disc list-inside pl-2" />,
178+
item: <Text as="li" type="body" weight="regular" size="md" />,
179+
profile: (
180+
<UserCard
181+
mxid={user.matrix.mxid}
182+
username={user.username}
183+
displayName={user.matrix.displayName}
184+
/>
185+
),
186+
}}
187+
/>
188+
</Dialog.Description>
189+
190+
<Form.Root onSubmit={onSubmit}>
191+
<Form.InlineField control={<Form.CheckboxControl />} name="hs-erase">
192+
<Form.Label>
193+
{t("frontend.account.delete_account.erase_checkbox_label")}
194+
</Form.Label>
195+
</Form.InlineField>
196+
197+
<Separator className="my-1" />
198+
199+
{shouldPromptPassword ? (
200+
<Form.Field name="password" serverInvalid={incorrectPassword}>
201+
<Form.Label>
202+
{t("frontend.account.delete_account.password_label")}
203+
</Form.Label>
204+
205+
<Form.PasswordControl
206+
autoComplete="current-password"
207+
required
208+
onInput={onPasswordChange}
209+
/>
210+
211+
<Form.ErrorMessage match="valueMissing">
212+
{t("frontend.errors.field_required")}
213+
</Form.ErrorMessage>
214+
215+
{incorrectPassword && (
216+
<Form.ErrorMessage>
217+
{t("frontend.account.delete_account.incorrect_password")}
218+
</Form.ErrorMessage>
219+
)}
220+
</Form.Field>
221+
) : (
222+
<Form.Field name="mxid">
223+
<Form.Label>
224+
{t("frontend.account.delete_account.mxid_label", {
225+
mxid: user.matrix.mxid,
226+
})}
227+
</Form.Label>
228+
229+
<Form.TextControl
230+
required
231+
placeholder={user.matrix.mxid}
232+
onInput={onMxidChange}
233+
/>
234+
235+
<Form.ErrorMessage match="valueMissing">
236+
{t("frontend.errors.field_required")}
237+
</Form.ErrorMessage>
238+
239+
<Form.ErrorMessage match={(value) => value !== user.matrix.mxid}>
240+
{t("frontend.account.delete_account.mxid_mismatch")}
241+
</Form.ErrorMessage>
242+
</Form.Field>
243+
)}
244+
245+
{isMaybeValid && (
246+
<Alert
247+
type="critical"
248+
title={t("frontend.account.delete_account.alert_title")}
249+
>
250+
{t("frontend.account.delete_account.alert_description")}
251+
</Alert>
252+
)}
253+
254+
<Button
255+
type="submit"
256+
kind="primary"
257+
destructive
258+
disabled={!allowSubmitting || isSubmitting}
259+
Icon={isSubmitting ? undefined : IconDelete}
260+
>
261+
{isSubmitting && <LoadingSpinner inline />}
262+
{t("frontend.account.delete_account.button")}
263+
</Button>
264+
</Form.Root>
265+
266+
<Dialog.Close asChild>
267+
<Button kind="tertiary">{t("action.cancel")}</Button>
268+
</Dialog.Close>
269+
</Dialog.Dialog>
270+
);
271+
};
272+
273+
export default AccountDeleteButton;

0 commit comments

Comments
 (0)