diff --git a/frontend/locales/en.json b/frontend/locales/en.json index dbc360a59..7f0343e85 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -37,6 +37,18 @@ "account": { "account_password": "Account password", "contact_info": "Contact info", + "delete_account": { + "alert_description": "This account will be permanently erased and you’ll no longer have access to any of your messages.", + "alert_title": "You’re about to lose all of your data", + "button": "Delete account", + "dialog_description": "Confirm that you would like to delete your account:\n\n\nYou will not be able to reactivate your account\nYou will no longer be able to sign in\nNo one will be able to reuse your username (MXID), including you\nYou will leave all rooms and direct messages you are in\nYou will be removed from the identity server, and no one will be able to find you with your email or phone number\n\nYour 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?", + "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 you’re signed in.", "display_name_label": "Display name", diff --git a/frontend/src/components/AccountDeleteButton.tsx b/frontend/src/components/AccountDeleteButton.tsx new file mode 100644 index 000000000..6292ac60f --- /dev/null +++ b/frontend/src/components/AccountDeleteButton.tsx @@ -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; + siteConfig: FragmentType; +}; + +const UserCard: React.FC<{ + mxid: string; + displayName?: string | null; + username: string; +}> = ({ mxid, displayName, username }) => ( +
+ +
+ + {displayName || username} + + + {mxid} + +
+
+); + +const AccountDeleteButton: React.FC = (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) => { + // 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) => { + setIsMaybeValid(e.currentTarget.value === user.matrix.mxid); + }, + [user.matrix.mxid], + ); + + const onSubmit = useCallback( + (e: React.FormEvent) => { + 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 ( + + {t("frontend.account.delete_account.button")} + + } + > + + {t("frontend.account.delete_account.dialog_title")} + + + + , + list:
    , + item: , + profile: ( + + ), + }} + /> + + + + } name="hs-erase"> + + {t("frontend.account.delete_account.erase_checkbox_label")} + + + + + + {shouldPromptPassword ? ( + + + {t("frontend.account.delete_account.password_label")} + + + + + + {t("frontend.errors.field_required")} + + + {incorrectPassword && ( + + {t("frontend.account.delete_account.incorrect_password")} + + )} + + ) : ( + + + {t("frontend.account.delete_account.mxid_label", { + mxid: user.matrix.mxid, + })} + + + + + + {t("frontend.errors.field_required")} + + + value !== user.matrix.mxid}> + {t("frontend.account.delete_account.mxid_mismatch")} + + + )} + + {isMaybeValid && ( + + {t("frontend.account.delete_account.alert_description")} + + )} + + + + + + + + + ); +}; + +export default AccountDeleteButton; diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index 5a0aa8aad..bb1e9adb2 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -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. */ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 882bba82b..46df85274 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -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; +}>; + + +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 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; +export const AccountDeleteButton_SiteConfigFragmentDoc = new TypedDocumentString(` + fragment AccountDeleteButton_siteConfig on SiteConfig { + passwordLoginEnabled +} + `, {"fragmentName":"AccountDeleteButton_siteConfig"}) as unknown as TypedDocumentString; 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; +export const DeactivateUserDocument = new TypedDocumentString(` + mutation DeactivateUser($hsErase: Boolean!, $password: String) { + deactivateUser(input: {hsErase: $hsErase, password: $password}) { + status + } +} + `) as unknown as TypedDocumentString; 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; +/** + * @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, options?: RequestHandlerOptions) => + graphql.mutation( + '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)) diff --git a/frontend/src/routes/_account.index.lazy.tsx b/frontend/src/routes/_account.index.lazy.tsx index ce494e77c..ec24f2dfe 100644 --- a/frontend/src/routes/_account.index.lazy.tsx +++ b/frontend/src/routes/_account.index.lazy.tsx @@ -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 { - - + + + {siteConfig.accountDeactivationAllowed && ( + <> + + + + )} + + + ); } diff --git a/frontend/src/routes/_account.index.tsx b/frontend/src/routes/_account.index.tsx index 0e0e19bb2..bdeb470fa 100644 --- a/frontend/src/routes/_account.index.tsx +++ b/frontend/src/routes/_account.index.tsx @@ -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 } } `); diff --git a/frontend/stories/routes/index.stories.tsx b/frontend/stories/routes/index.stories.tsx index a9783825c..82150741e 100644 --- a/frontend/stories/routes/index.stories.tsx +++ b/frontend/stories/routes/index.stories.tsx @@ -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); diff --git a/frontend/tests/mocks/handlers.ts b/frontend/tests/mocks/handlers.ts index 55993aa11..c2753d186 100644 --- a/frontend/tests/mocks/handlers.ts +++ b/frontend/tests/mocks/handlers.ts @@ -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, + ), ), }, }), diff --git a/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap b/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap index f8f8bebea..40b463ea3 100644 --- a/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap +++ b/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap @@ -2,18 +2,18 @@ exports[`Account home page > display name edit box > displays an error if the display name is invalid 1`] = ` This is what others will see wherever you’re signed in. @@ -236,13 +236,13 @@ exports[`Account home page > display name edit box > lets edit the display name > display name edit box > lets edit the display name Cancel + + Sign out of account + +