Allow users to delete their account in the UI

This commit is contained in:
Quentin Gliech
2025-03-12 15:57:22 +01:00
parent 660fea1769
commit 2ddb2d2495
9 changed files with 561 additions and 65 deletions

View File

@@ -37,6 +37,18 @@
"account": {
"account_password": "Account password",
"contact_info": "Contact info",
"delete_account": {
"alert_description": "This account will be permanently erased and youll no longer have access to any of your messages.",
"alert_title": "Youre about to lose all of your data",
"button": "Delete account",
"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>",
"dialog_title": "Delete this account?",
"erase_checkbox_label": "Yes, hide all my messages from new joiners",
"incorrect_password": "Incorrect password, please try again",
"mxid_label": "Confirm your Matrix ID ({{ mxid }})",
"mxid_mismatch": "This value does not match your Matrix ID",
"password_label": "Enter your password to continue"
},
"edit_profile": {
"display_name_help": "This is what others will see wherever youre signed in.",
"display_name_label": "Display name",

View File

@@ -0,0 +1,273 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { useMutation } from "@tanstack/react-query";
import IconDelete from "@vector-im/compound-design-tokens/assets/web/icons/delete";
import { Alert, Avatar, Button, Form, Text } from "@vector-im/compound-web";
import { useCallback, useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { type FragmentType, graphql, useFragment } from "../gql";
import { graphqlRequest } from "../graphql";
import * as Dialog from "./Dialog";
import LoadingSpinner from "./LoadingSpinner";
import Separator from "./Separator";
export const USER_FRAGMENT = graphql(/* GraphQL */ `
fragment AccountDeleteButton_user on User {
username
hasPassword
matrix {
mxid
displayName
}
}
`);
export const CONFIG_FRAGMENT = graphql(/* GraphQL */ `
fragment AccountDeleteButton_siteConfig on SiteConfig {
passwordLoginEnabled
}
`);
const MUTATION = graphql(/* GraphQL */ `
mutation DeactivateUser($hsErase: Boolean!, $password: String) {
deactivateUser(input: { hsErase: $hsErase, password: $password }) {
status
}
}
`);
type Props = {
user: FragmentType<typeof USER_FRAGMENT>;
siteConfig: FragmentType<typeof CONFIG_FRAGMENT>;
};
const UserCard: React.FC<{
mxid: string;
displayName?: string | null;
username: string;
}> = ({ mxid, displayName, username }) => (
<section className="flex items-center p-4 gap-4 border border-[var(--cpd-color-gray-400)] rounded-xl">
<Avatar id={mxid} name={displayName || username} size="48px" />
<div className="flex-1 flex flex-col">
<Text type="body" weight="semibold" size="lg" className="text-primary">
{displayName || username}
</Text>
<Text type="body" weight="regular" size="md" className="text-secondary">
{mxid}
</Text>
</div>
</section>
);
const AccountDeleteButton: React.FC<Props> = (props) => {
const user = useFragment(USER_FRAGMENT, props.user);
const siteConfig = useFragment(CONFIG_FRAGMENT, props.siteConfig);
const { t } = useTranslation();
const mutation = useMutation({
mutationFn: ({
password,
hsErase,
}: { password: string | null; hsErase: boolean }) =>
graphqlRequest({
query: MUTATION,
variables: { password, hsErase },
}),
onSuccess: (data) => {
if (data.deactivateUser.status === "DEACTIVATED") {
window.location.reload();
}
},
});
// Track if the form may be valid or not, so that we show the alert and enable
// the submit button only when it is
const [isMaybeValid, setIsMaybeValid] = useState(false);
// We want to *delay* a little bit the submit button being enabled, so that:
// - the user reads the alert
// - *if the password manager autofills the password*, we ignore any auto-submitting of the form
const [allowSubmitting, setAllowSubmitting] = useState(false);
useEffect(() => {
// If the value of isMaybeValid switches to true, we want to flip
// 'allowSubmitting' to true a little bit later
if (isMaybeValid) {
const timer = setTimeout(() => {
setAllowSubmitting(true);
}, 500);
return () => clearTimeout(timer);
}
// If it switches to false, we want to flip 'allowSubmitting' to false
// immediately
setAllowSubmitting(false);
}, [isMaybeValid]);
const onPasswordChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
// We don't know if the password is correct, so we consider the form as
// valid if the field is not empty
setIsMaybeValid(e.currentTarget.value !== "");
},
[],
);
const onMxidChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setIsMaybeValid(e.currentTarget.value === user.matrix.mxid);
},
[user.matrix.mxid],
);
const onSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!allowSubmitting) return;
const data = new FormData(e.currentTarget);
const password = data.get("password");
if (password !== null && typeof password !== "string") throw new Error();
const hsErase = data.get("hs-erase") === "on";
mutation.mutate({ password, hsErase });
},
[mutation.mutate, allowSubmitting],
);
const incorrectPassword =
mutation.data?.deactivateUser.status === "INCORRECT_PASSWORD";
// We still consider the form as submitted if the mutation is pending, or if
// the mutation has returned a success, so that we continue showing the
// loading spinner during the page reload
const isSubmitting =
mutation.isPending ||
mutation.data?.deactivateUser.status === "DEACTIVATED";
const shouldPromptPassword =
user.hasPassword && siteConfig.passwordLoginEnabled;
return (
<Dialog.Dialog
trigger={
<Button
kind="tertiary"
destructive
size="sm"
className="self-center"
Icon={IconDelete}
>
{t("frontend.account.delete_account.button")}
</Button>
}
>
<Dialog.Title>
{t("frontend.account.delete_account.dialog_title")}
</Dialog.Title>
<Dialog.Description className="flex flex-col gap-4">
<Trans
t={t}
i18nKey="frontend.account.delete_account.dialog_description"
components={{
text: <Text type="body" weight="regular" size="md" />,
list: <ul className="list-disc list-inside pl-2" />,
item: <Text as="li" type="body" weight="regular" size="md" />,
profile: (
<UserCard
mxid={user.matrix.mxid}
username={user.username}
displayName={user.matrix.displayName}
/>
),
}}
/>
</Dialog.Description>
<Form.Root onSubmit={onSubmit}>
<Form.InlineField control={<Form.CheckboxControl />} name="hs-erase">
<Form.Label>
{t("frontend.account.delete_account.erase_checkbox_label")}
</Form.Label>
</Form.InlineField>
<Separator className="my-1" />
{shouldPromptPassword ? (
<Form.Field name="password" serverInvalid={incorrectPassword}>
<Form.Label>
{t("frontend.account.delete_account.password_label")}
</Form.Label>
<Form.PasswordControl
autoComplete="current-password"
required
onInput={onPasswordChange}
/>
<Form.ErrorMessage match="valueMissing">
{t("frontend.errors.field_required")}
</Form.ErrorMessage>
{incorrectPassword && (
<Form.ErrorMessage>
{t("frontend.account.delete_account.incorrect_password")}
</Form.ErrorMessage>
)}
</Form.Field>
) : (
<Form.Field name="mxid">
<Form.Label>
{t("frontend.account.delete_account.mxid_label", {
mxid: user.matrix.mxid,
})}
</Form.Label>
<Form.TextControl
required
placeholder={user.matrix.mxid}
onInput={onMxidChange}
/>
<Form.ErrorMessage match="valueMissing">
{t("frontend.errors.field_required")}
</Form.ErrorMessage>
<Form.ErrorMessage match={(value) => value !== user.matrix.mxid}>
{t("frontend.account.delete_account.mxid_mismatch")}
</Form.ErrorMessage>
</Form.Field>
)}
{isMaybeValid && (
<Alert
type="critical"
title={t("frontend.account.delete_account.alert_title")}
>
{t("frontend.account.delete_account.alert_description")}
</Alert>
)}
<Button
type="submit"
kind="primary"
destructive
disabled={!allowSubmitting || isSubmitting}
Icon={isSubmitting ? undefined : IconDelete}
>
{isSubmitting && <LoadingSpinner inline />}
{t("frontend.account.delete_account.button")}
</Button>
</Form.Root>
<Dialog.Close asChild>
<Button kind="tertiary">{t("action.cancel")}</Button>
</Dialog.Close>
</Dialog.Dialog>
);
};
export default AccountDeleteButton;

View File

@@ -15,6 +15,9 @@ import * as types from './graphql';
* Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size
*/
type Documents = {
"\n fragment AccountDeleteButton_user on User {\n username\n hasPassword\n matrix {\n mxid\n displayName\n }\n }\n": typeof types.AccountDeleteButton_UserFragmentDoc,
"\n fragment AccountDeleteButton_siteConfig on SiteConfig {\n passwordLoginEnabled\n }\n": typeof types.AccountDeleteButton_SiteConfigFragmentDoc,
"\n mutation DeactivateUser($hsErase: Boolean!, $password: String) {\n deactivateUser(input: { hsErase: $hsErase, password: $password }) {\n status\n }\n }\n": typeof types.DeactivateUserDocument,
"\n fragment PasswordChange_siteConfig on SiteConfig {\n passwordChangeAllowed\n }\n": typeof types.PasswordChange_SiteConfigFragmentDoc,
"\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n finishedAt\n ...EndBrowserSessionButton_session\n userAgent {\n deviceType\n name\n os\n model\n }\n lastActiveAt\n }\n": typeof types.BrowserSession_SessionFragmentDoc,
"\n fragment OAuth2Client_detail on Oauth2Client {\n id\n clientId\n clientName\n clientUri\n logoUri\n tosUri\n policyUri\n redirectUris\n }\n": typeof types.OAuth2Client_DetailFragmentDoc,
@@ -44,7 +47,7 @@ type Documents = {
"\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 ...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 UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_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,
@@ -66,6 +69,9 @@ type Documents = {
"\n query SessionDetail($id: ID!) {\n viewerSession {\n ... on Node {\n id\n }\n }\n\n node(id: $id) {\n __typename\n id\n ...CompatSession_detail\n ...OAuth2Session_detail\n ...BrowserSession_detail\n }\n }\n": typeof types.SessionDetailDocument,
};
const documents: Documents = {
"\n fragment AccountDeleteButton_user on User {\n username\n hasPassword\n matrix {\n mxid\n displayName\n }\n }\n": types.AccountDeleteButton_UserFragmentDoc,
"\n fragment AccountDeleteButton_siteConfig on SiteConfig {\n passwordLoginEnabled\n }\n": types.AccountDeleteButton_SiteConfigFragmentDoc,
"\n mutation DeactivateUser($hsErase: Boolean!, $password: String) {\n deactivateUser(input: { hsErase: $hsErase, password: $password }) {\n status\n }\n }\n": types.DeactivateUserDocument,
"\n fragment PasswordChange_siteConfig on SiteConfig {\n passwordChangeAllowed\n }\n": types.PasswordChange_SiteConfigFragmentDoc,
"\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n finishedAt\n ...EndBrowserSessionButton_session\n userAgent {\n deviceType\n name\n os\n model\n }\n lastActiveAt\n }\n": types.BrowserSession_SessionFragmentDoc,
"\n fragment OAuth2Client_detail on Oauth2Client {\n id\n clientId\n clientName\n clientUri\n logoUri\n tosUri\n policyUri\n redirectUris\n }\n": types.OAuth2Client_DetailFragmentDoc,
@@ -95,7 +101,7 @@ const documents: Documents = {
"\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 ...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 UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_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,
@@ -117,6 +123,18 @@ const documents: Documents = {
"\n query SessionDetail($id: ID!) {\n viewerSession {\n ... on Node {\n id\n }\n }\n\n node(id: $id) {\n __typename\n id\n ...CompatSession_detail\n ...OAuth2Session_detail\n ...BrowserSession_detail\n }\n }\n": types.SessionDetailDocument,
};
/**
* 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 AccountDeleteButton_user on User {\n username\n hasPassword\n matrix {\n mxid\n displayName\n }\n }\n"): typeof import('./graphql').AccountDeleteButton_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 AccountDeleteButton_siteConfig on SiteConfig {\n passwordLoginEnabled\n }\n"): typeof import('./graphql').AccountDeleteButton_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 DeactivateUser($hsErase: Boolean!, $password: String) {\n deactivateUser(input: { hsErase: $hsErase, password: $password }) {\n status\n }\n }\n"): typeof import('./graphql').DeactivateUserDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -236,7 +254,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 ...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;
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 ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_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

@@ -1617,6 +1617,18 @@ export type Viewer = Anonymous | User;
/** Represents the current viewer's session */
export type ViewerSession = Anonymous | BrowserSession | Oauth2Session;
export type AccountDeleteButton_UserFragment = { __typename?: 'User', username: string, hasPassword: boolean, matrix: { __typename?: 'MatrixUser', mxid: string, displayName?: string | null } } & { ' $fragmentName'?: 'AccountDeleteButton_UserFragment' };
export type AccountDeleteButton_SiteConfigFragment = { __typename?: 'SiteConfig', passwordLoginEnabled: boolean } & { ' $fragmentName'?: 'AccountDeleteButton_SiteConfigFragment' };
export type DeactivateUserMutationVariables = Exact<{
hsErase: Scalars['Boolean']['input'];
password?: InputMaybe<Scalars['String']['input']>;
}>;
export type DeactivateUserMutation = { __typename?: 'Mutation', deactivateUser: { __typename?: 'DeactivateUserPayload', status: DeactivateUserStatus } };
export type PasswordChange_SiteConfigFragment = { __typename?: 'SiteConfig', passwordChangeAllowed: boolean } & { ' $fragmentName'?: 'PasswordChange_SiteConfigFragment' };
export type BrowserSession_SessionFragment = (
@@ -1749,10 +1761,10 @@ 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 } }
& { ' $fragmentRefs'?: { 'AddEmailForm_UserFragment': AddEmailForm_UserFragment;'UserEmailList_UserFragment': UserEmailList_UserFragment } }
& { ' $fragmentRefs'?: { 'AddEmailForm_UserFragment': AddEmailForm_UserFragment;'UserEmailList_UserFragment': UserEmailList_UserFragment;'AccountDeleteButton_UserFragment': AccountDeleteButton_UserFragment } }
) } | { __typename: 'Oauth2Session' }, siteConfig: (
{ __typename?: 'SiteConfig', emailChangeAllowed: boolean, passwordLoginEnabled: boolean }
& { ' $fragmentRefs'?: { 'AddEmailForm_SiteConfigFragment': AddEmailForm_SiteConfigFragment;'UserEmailList_SiteConfigFragment': UserEmailList_SiteConfigFragment;'PasswordChange_SiteConfigFragment': PasswordChange_SiteConfigFragment } }
{ __typename?: 'SiteConfig', emailChangeAllowed: boolean, passwordLoginEnabled: boolean, accountDeactivationAllowed: boolean }
& { ' $fragmentRefs'?: { 'AddEmailForm_SiteConfigFragment': AddEmailForm_SiteConfigFragment;'UserEmailList_SiteConfigFragment': UserEmailList_SiteConfigFragment;'PasswordChange_SiteConfigFragment': PasswordChange_SiteConfigFragment;'AccountDeleteButton_SiteConfigFragment': AccountDeleteButton_SiteConfigFragment } }
) };
export type BrowserSessionListQueryVariables = Exact<{
@@ -1944,6 +1956,21 @@ export class TypedDocumentString<TResult, TVariables>
return this.value;
}
}
export const AccountDeleteButton_UserFragmentDoc = new TypedDocumentString(`
fragment AccountDeleteButton_user on User {
username
hasPassword
matrix {
mxid
displayName
}
}
`, {"fragmentName":"AccountDeleteButton_user"}) as unknown as TypedDocumentString<AccountDeleteButton_UserFragment, unknown>;
export const AccountDeleteButton_SiteConfigFragmentDoc = new TypedDocumentString(`
fragment AccountDeleteButton_siteConfig on SiteConfig {
passwordLoginEnabled
}
`, {"fragmentName":"AccountDeleteButton_siteConfig"}) as unknown as TypedDocumentString<AccountDeleteButton_SiteConfigFragment, unknown>;
export const PasswordChange_SiteConfigFragmentDoc = new TypedDocumentString(`
fragment PasswordChange_siteConfig on SiteConfig {
passwordChangeAllowed
@@ -2275,6 +2302,13 @@ export const RecoverPassword_SiteConfigFragmentDoc = new TypedDocumentString(`
id
minimumPasswordComplexity
}`, {"fragmentName":"RecoverPassword_siteConfig"}) as unknown as TypedDocumentString<RecoverPassword_SiteConfigFragment, unknown>;
export const DeactivateUserDocument = new TypedDocumentString(`
mutation DeactivateUser($hsErase: Boolean!, $password: String) {
deactivateUser(input: {hsErase: $hsErase, password: $password}) {
status
}
}
`) as unknown as TypedDocumentString<DeactivateUserMutation, DeactivateUserMutationVariables>;
export const FooterDocument = new TypedDocumentString(`
query Footer {
siteConfig {
@@ -2384,6 +2418,7 @@ export const UserProfileDocument = new TypedDocumentString(`
user {
...AddEmailForm_user
...UserEmailList_user
...AccountDeleteButton_user
hasPassword
emails(first: 0) {
totalCount
@@ -2394,12 +2429,25 @@ export const UserProfileDocument = new TypedDocumentString(`
siteConfig {
emailChangeAllowed
passwordLoginEnabled
accountDeactivationAllowed
...AddEmailForm_siteConfig
...UserEmailList_siteConfig
...PasswordChange_siteConfig
...AccountDeleteButton_siteConfig
}
}
fragment PasswordChange_siteConfig on SiteConfig {
fragment AccountDeleteButton_user on User {
username
hasPassword
matrix {
mxid
displayName
}
}
fragment AccountDeleteButton_siteConfig on SiteConfig {
passwordLoginEnabled
}
fragment PasswordChange_siteConfig on SiteConfig {
passwordChangeAllowed
}
fragment AddEmailForm_user on User {
@@ -2854,6 +2902,28 @@ fragment OAuth2Session_detail on Oauth2Session {
}
}`) as unknown as TypedDocumentString<SessionDetailQuery, SessionDetailQueryVariables>;
/**
* @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions))
* @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options))
* @see https://mswjs.io/docs/basics/response-resolver
* @example
* mockDeactivateUserMutation(
* ({ query, variables }) => {
* const { hsErase, password } = variables;
* return HttpResponse.json({
* data: { deactivateUser }
* })
* },
* requestOptions
* )
*/
export const mockDeactivateUserMutation = (resolver: GraphQLResponseResolver<DeactivateUserMutation, DeactivateUserMutationVariables>, options?: RequestHandlerOptions) =>
graphql.mutation<DeactivateUserMutation, DeactivateUserMutationVariables>(
'DeactivateUser',
resolver,
options
)
/**
* @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions))
* @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options))

View File

@@ -13,6 +13,7 @@ import {
import IconSignOut from "@vector-im/compound-design-tokens/assets/web/icons/sign-out";
import { Button, Text } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import AccountDeleteButton from "../components/AccountDeleteButton";
import AccountManagementPasswordPreview from "../components/AccountManagementPasswordPreview";
import { ButtonLink } from "../components/ButtonLink";
import * as Collapsible from "../components/Collapsible";
@@ -127,9 +128,21 @@ function Index(): React.ReactElement {
</Collapsible.Section>
<Separator kind="section" />
</div>
<SignOutButton id={viewerSession.id} />
<SignOutButton id={viewerSession.id} />
{siteConfig.accountDeactivationAllowed && (
<>
<Separator />
<AccountDeleteButton
user={viewerSession.user}
siteConfig={siteConfig}
/>
</>
)}
<Separator />
</div>
</>
);
}

View File

@@ -20,6 +20,7 @@ const QUERY = graphql(/* GraphQL */ `
user {
...AddEmailForm_user
...UserEmailList_user
...AccountDeleteButton_user
hasPassword
emails(first: 0) {
totalCount
@@ -31,9 +32,11 @@ const QUERY = graphql(/* GraphQL */ `
siteConfig {
emailChangeAllowed
passwordLoginEnabled
accountDeactivationAllowed
...AddEmailForm_siteConfig
...UserEmailList_siteConfig
...PasswordChange_siteConfig
...AccountDeleteButton_siteConfig
}
}
`);

View File

@@ -7,6 +7,10 @@ import type { Meta, StoryObj } from "@storybook/react";
import { expect, userEvent, waitFor, within } from "@storybook/test";
import i18n from "i18next";
import { type GraphQLHandler, HttpResponse } from "msw";
import {
CONFIG_FRAGMENT as ACCOUNT_DELETE_BUTTON_CONFIG_FRAGMENT,
USER_FRAGMENT as ACCOUNT_DELETE_BUTTON_USER_FRAGMENT,
} from "../../src/components/AccountDeleteButton";
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 {
@@ -38,12 +42,14 @@ const userProfileHandler = ({
passwordLoginEnabled,
passwordChangeAllowed,
emailTotalCount,
accountDeactivationAllowed,
hasPassword,
}: {
emailChangeAllowed: boolean;
passwordLoginEnabled: boolean;
passwordChangeAllowed: boolean;
emailTotalCount: number;
accountDeactivationAllowed: boolean;
hasPassword: boolean;
}): GraphQLHandler =>
mockUserProfileQuery(() =>
@@ -71,6 +77,17 @@ const userProfileHandler = ({
},
USER_EMAIL_LIST_USER_FRAGMENT,
),
makeFragmentData(
{
hasPassword,
username: "alice",
matrix: {
displayName: "Alice",
mxid: "@alice:example.com",
},
},
ACCOUNT_DELETE_BUTTON_USER_FRAGMENT,
),
),
},
@@ -78,6 +95,7 @@ const userProfileHandler = ({
{
emailChangeAllowed,
passwordLoginEnabled,
accountDeactivationAllowed,
},
makeFragmentData(
{
@@ -99,6 +117,12 @@ const userProfileHandler = ({
},
PASSWORD_CHANGE_CONFIG_FRAGMENT,
),
makeFragmentData(
{
passwordLoginEnabled,
},
ACCOUNT_DELETE_BUTTON_CONFIG_FRAGMENT,
),
),
},
}),
@@ -153,6 +177,7 @@ export const MultipleEmails: Story = {
passwordChangeAllowed: true,
emailChangeAllowed: true,
emailTotalCount: 3,
accountDeactivationAllowed: true,
hasPassword: true,
}),
threeEmailsHandler,
@@ -171,6 +196,7 @@ export const NoEmails: Story = {
passwordChangeAllowed: true,
emailChangeAllowed: false,
emailTotalCount: 0,
accountDeactivationAllowed: true,
hasPassword: true,
}),
],
@@ -188,6 +214,7 @@ export const MultipleEmailsNoChange: Story = {
passwordChangeAllowed: true,
emailChangeAllowed: false,
emailTotalCount: 3,
accountDeactivationAllowed: true,
hasPassword: true,
}),
threeEmailsHandler,
@@ -206,6 +233,7 @@ export const NoEmailChange: Story = {
passwordChangeAllowed: true,
emailChangeAllowed: false,
emailTotalCount: 1,
accountDeactivationAllowed: true,
hasPassword: true,
}),
],
@@ -223,6 +251,7 @@ export const NoPasswordChange: Story = {
passwordChangeAllowed: false,
emailChangeAllowed: true,
emailTotalCount: 1,
accountDeactivationAllowed: true,
hasPassword: true,
}),
],
@@ -240,6 +269,7 @@ export const NoPasswordLogin: Story = {
passwordChangeAllowed: false,
emailChangeAllowed: true,
emailTotalCount: 1,
accountDeactivationAllowed: true,
hasPassword: true,
}),
],
@@ -247,8 +277,8 @@ export const NoPasswordLogin: Story = {
},
};
export const NoPasswordNoEmailChange: Story = {
name: "No password, no email change",
export const NoPasswordNoEmailChangeNoAccountDeactivation: Story = {
name: "No password, no email change, no account deactivation",
parameters: {
msw: {
handlers: [
@@ -257,6 +287,7 @@ export const NoPasswordNoEmailChange: Story = {
passwordChangeAllowed: false,
emailChangeAllowed: false,
emailTotalCount: 0,
accountDeactivationAllowed: false,
hasPassword: false,
}),
],
@@ -264,6 +295,24 @@ export const NoPasswordNoEmailChange: Story = {
},
};
export const NoAccountDeactivation: Story = {
name: "No account deactivation",
parameters: {
msw: {
handlers: [
userProfileHandler({
passwordLoginEnabled: true,
passwordChangeAllowed: true,
emailChangeAllowed: true,
emailTotalCount: 1,
accountDeactivationAllowed: false,
hasPassword: true,
}),
],
},
},
};
export const EditProfile: Story = {
play: async ({ canvasElement, globals }) => {
const t = i18n.getFixedT(globals.locale);

View File

@@ -4,6 +4,10 @@
// Please see LICENSE in the repository root for full details.
import { HttpResponse } from "msw";
import {
CONFIG_FRAGMENT as ACCOUNT_DELETE_BUTTON_CONFIG_FRAGMENT,
USER_FRAGMENT as ACCOUNT_DELETE_BUTTON_USER_FRAGMENT,
} from "../../src/components/AccountDeleteButton";
import { CONFIG_FRAGMENT as PASSWORD_CHANGE_CONFIG_FRAGMENT } from "../../src/components/AccountManagementPasswordPreview/AccountManagementPasswordPreview";
import { FRAGMENT as FOOTER_FRAGMENT } from "../../src/components/Footer/Footer";
import { FRAGMENT as USER_EMAIL_FRAGMENT } from "../../src/components/UserEmail/UserEmail";
@@ -113,6 +117,17 @@ export const handlers = [
},
USER_EMAIL_LIST_USER_FRAGMENT,
),
makeFragmentData(
{
hasPassword: true,
username: "alice",
matrix: {
displayName: "Alice",
mxid: "@alice:example.com",
},
},
ACCOUNT_DELETE_BUTTON_USER_FRAGMENT,
),
),
},
@@ -120,6 +135,7 @@ export const handlers = [
{
emailChangeAllowed: true,
passwordLoginEnabled: true,
accountDeactivationAllowed: true,
},
makeFragmentData(
{
@@ -141,6 +157,12 @@ export const handlers = [
},
PASSWORD_CHANGE_CONFIG_FRAGMENT,
),
makeFragmentData(
{
passwordLoginEnabled: true,
},
ACCOUNT_DELETE_BUTTON_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-:r7q:"
aria-labelledby="radix-:r7p:"
aria-describedby="radix-:r86:"
aria-labelledby="radix-:r85:"
class="_body_9cf7b0"
data-state="open"
id="radix-:r7o:"
id="radix-:r84:"
role="dialog"
style="pointer-events: auto;"
tabindex="-1"
>
<h2
class="_title_9cf7b0"
id="radix-:r7p:"
id="radix-:r85:"
>
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-:r9a:"
for="radix-:r9p:"
>
Display name
</label>
<div
class="_container_1s836_8"
id=":r9b:"
id=":r9q:"
>
<input
aria-describedby="radix-:r9h:"
aria-describedby="radix-:ra0:"
aria-invalid="true"
autocomplete="name"
class="_control_sqdq4_10 _control_1s836_13"
data-invalid="true"
id="radix-:r9a:"
id="radix-:r9p:"
name="displayname"
title=""
type="text"
value="Alice"
/>
<button
aria-controls=":r9b:"
aria-labelledby=":r9c:"
aria-controls=":r9q:"
aria-labelledby=":r9r:"
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-:r9h:"
id="radix-:ra0:"
>
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-:r9i:"
for="radix-:ra1:"
>
Username
</label>
<input
class="_control_sqdq4_10"
id="radix-:r9i:"
id="radix-:ra1:"
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=":r9j:"
aria-labelledby=":ra2:"
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-:r1k:"
aria-labelledby="radix-:r1j:"
aria-describedby="radix-:r1n:"
aria-labelledby="radix-:r1m:"
class="_body_9cf7b0"
data-state="open"
id="radix-:r1i:"
id="radix-:r1l:"
role="dialog"
style="pointer-events: auto;"
tabindex="-1"
>
<h2
class="_title_9cf7b0"
id="radix-:r1j:"
id="radix-:r1m:"
>
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-:r34:"
for="radix-:r3a:"
>
Display name
</label>
<div
class="_container_1s836_8"
id=":r35:"
id=":r3b:"
>
<input
aria-describedby="radix-:r3b:"
aria-describedby="radix-:r3h:"
autocomplete="name"
class="_control_sqdq4_10 _control_1s836_13"
id="radix-:r34:"
id="radix-:r3a:"
name="displayname"
title=""
type="text"
value="Alice"
/>
<button
aria-controls=":r35:"
aria-labelledby=":r36:"
aria-controls=":r3b:"
aria-labelledby=":r3c:"
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-:r3b:"
id="radix-:r3h:"
>
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-:r3c:"
for="radix-:r3i:"
>
Username
</label>
<input
class="_control_sqdq4_10"
id="radix-:r3c:"
id="radix-:r3i:"
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=":r3d:"
aria-labelledby=":r3j:"
class="_close_9cf7b0"
type="button"
>
@@ -670,33 +670,69 @@ exports[`Account home page > renders the page 1`] = `
class="_separator_162edc _section_162edc"
role="separator"
/>
</div>
<button
aria-controls="radix-:r1b:"
aria-expanded="false"
aria-haspopup="dialog"
class="_button_vczzf_8 _has-icon_vczzf_57 _destructive_vczzf_107"
data-kind="primary"
data-size="lg"
data-state="closed"
role="button"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
<button
aria-controls="radix-:r1b:"
aria-expanded="false"
aria-haspopup="dialog"
class="_button_vczzf_8 _has-icon_vczzf_57 _destructive_vczzf_107"
data-kind="primary"
data-size="lg"
data-state="closed"
role="button"
tabindex="0"
type="button"
>
<path
d="M9 12.031q0-.424.288-.712A.97.97 0 0 1 10 11.03h7.15l-1.875-1.875a.96.96 0 0 1-.3-.7q0-.4.325-.725a.93.93 0 0 1 .712-.287.98.98 0 0 1 .688.287l3.6 3.6q.15.15.212.325.063.175.063.375 0 .201-.062.375a.9.9 0 0 1-.213.325l-3.6 3.6q-.3.3-.712.288a.98.98 0 0 1-.688-.288 1.02 1.02 0 0 1-.312-.712.93.93 0 0 1 .287-.713l1.875-1.875H10a.97.97 0 0 1-.712-.287A.97.97 0 0 1 9 12.03m-6-7q0-.824.588-1.412A1.93 1.93 0 0 1 5 3.03h6q.424 0 .713.288.287.287.287.712t-.287.713A.97.97 0 0 1 11 5.03H5v14h6q.424 0 .713.288.287.287.287.712t-.287.713a.97.97 0 0 1-.713.287H5q-.824 0-1.412-.587A1.93 1.93 0 0 1 3 19.03z"
/>
</svg>
Sign out of account
</button>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9 12.031q0-.424.288-.712A.97.97 0 0 1 10 11.03h7.15l-1.875-1.875a.96.96 0 0 1-.3-.7q0-.4.325-.725a.93.93 0 0 1 .712-.287.98.98 0 0 1 .688.287l3.6 3.6q.15.15.212.325.063.175.063.375 0 .201-.062.375a.9.9 0 0 1-.213.325l-3.6 3.6q-.3.3-.712.288a.98.98 0 0 1-.688-.288 1.02 1.02 0 0 1-.312-.712.93.93 0 0 1 .287-.713l1.875-1.875H10a.97.97 0 0 1-.712-.287A.97.97 0 0 1 9 12.03m-6-7q0-.824.588-1.412A1.93 1.93 0 0 1 5 3.03h6q.424 0 .713.288.287.287.287.712t-.287.713A.97.97 0 0 1 11 5.03H5v14h6q.424 0 .713.288.287.287.287.712t-.287.713a.97.97 0 0 1-.713.287H5q-.824 0-1.412-.587A1.93 1.93 0 0 1 3 19.03z"
/>
</svg>
Sign out of account
</button>
<div
aria-orientation="horizontal"
class="_separator_162edc"
role="separator"
/>
<button
aria-controls="radix-:r1e:"
aria-expanded="false"
aria-haspopup="dialog"
class="_button_vczzf_8 self-center _has-icon_vczzf_57 _destructive_vczzf_107"
data-kind="tertiary"
data-size="sm"
data-state="closed"
role="button"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 21q-.824 0-1.412-.587A1.93 1.93 0 0 1 5 19V6a.97.97 0 0 1-.713-.287A.97.97 0 0 1 4 5q0-.424.287-.713A.97.97 0 0 1 5 4h4q0-.424.287-.712A.97.97 0 0 1 10 3h4q.424 0 .713.288Q15 3.575 15 4h4q.424 0 .712.287Q20 4.576 20 5t-.288.713A.97.97 0 0 1 19 6v13q0 .824-.587 1.413A1.93 1.93 0 0 1 17 21zM7 6v13h10V6zm2 10q0 .424.287.712Q9.576 17 10 17t.713-.288A.97.97 0 0 0 11 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 10 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 9 9zm4 0q0 .424.287.712.288.288.713.288.424 0 .713-.288A.97.97 0 0 0 15 16V9a.97.97 0 0 0-.287-.713A.97.97 0 0 0 14 8a.97.97 0 0 0-.713.287A.97.97 0 0 0 13 9z"
/>
</svg>
Delete account
</button>
<div
aria-orientation="horizontal"
class="_separator_162edc"
role="separator"
/>
</div>
<footer
class="_legalFooter_eb428f"
>