diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 27c23eeb5..e8b037e45 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -207,6 +207,7 @@ pub fn site_config_from_config( && account_config.password_change_allowed, account_recovery_allowed: password_config.enabled() && account_config.password_recovery_enabled, + account_deactivation_allowed: account_config.account_deactivation_allowed, captcha, minimum_password_complexity: password_config.minimum_complexity(), session_expiration, diff --git a/crates/config/src/sections/account.rs b/crates/config/src/sections/account.rs index 7be79e357..987ff5741 100644 --- a/crates/config/src/sections/account.rs +++ b/crates/config/src/sections/account.rs @@ -61,6 +61,11 @@ pub struct AccountConfig { /// This has no effect if password login is disabled. #[serde(default = "default_false", skip_serializing_if = "is_default_false")] pub password_recovery_enabled: bool, + + /// Whether users are allowed to delete their own account. Defaults to + /// `true`. + #[serde(default = "default_true", skip_serializing_if = "is_default_true")] + pub account_deactivation_allowed: bool, } impl Default for AccountConfig { @@ -71,6 +76,7 @@ impl Default for AccountConfig { password_registration_enabled: default_false(), password_change_allowed: default_true(), password_recovery_enabled: default_false(), + account_deactivation_allowed: default_true(), } } } @@ -83,6 +89,7 @@ impl AccountConfig { && is_default_true(&self.displayname_change_allowed) && is_default_true(&self.password_change_allowed) && is_default_false(&self.password_recovery_enabled) + && is_default_true(&self.account_deactivation_allowed) } } diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs index 0e09f8a31..4688c0f11 100644 --- a/crates/data-model/src/site_config.rs +++ b/crates/data-model/src/site_config.rs @@ -76,6 +76,9 @@ pub struct SiteConfig { /// Whether users can recover their account via email. pub account_recovery_allowed: bool, + /// Whether users can delete their own account. + pub account_deactivation_allowed: bool, + /// Captcha configuration pub captcha: Option, diff --git a/crates/handlers/src/graphql/model/site_config.rs b/crates/handlers/src/graphql/model/site_config.rs index dc2cae188..598c0aabc 100644 --- a/crates/handlers/src/graphql/model/site_config.rs +++ b/crates/handlers/src/graphql/model/site_config.rs @@ -46,6 +46,9 @@ pub struct SiteConfig { /// Whether passwords are enabled and users can register using a password. password_registration_enabled: bool, + /// Whether users can delete their own account. + account_deactivation_allowed: bool, + /// Minimum password complexity, from 0 to 4, in terms of a zxcvbn score. /// The exact scorer (including dictionaries and other data tables) /// in use is . @@ -93,6 +96,7 @@ impl SiteConfig { password_login_enabled: data_model.password_login_enabled, password_change_allowed: data_model.password_change_allowed, password_registration_enabled: data_model.password_registration_enabled, + account_deactivation_allowed: data_model.account_deactivation_allowed, minimum_password_complexity: data_model.minimum_password_complexity, } } diff --git a/crates/handlers/src/graphql/mutations/mod.rs b/crates/handlers/src/graphql/mutations/mod.rs index dbc56a518..66bb5766c 100644 --- a/crates/handlers/src/graphql/mutations/mod.rs +++ b/crates/handlers/src/graphql/mutations/mod.rs @@ -11,7 +11,14 @@ mod oauth2_session; mod user; mod user_email; +use anyhow::Context as _; use async_graphql::MergedObject; +use mas_data_model::SiteConfig; +use mas_storage::BoxRepository; +use zeroize::Zeroizing; + +use super::Requester; +use crate::passwords::PasswordManager; /// The mutations root of the GraphQL interface. #[derive(Default, MergedObject)] @@ -30,3 +37,54 @@ impl Mutation { Self::default() } } + +/// Check the password if neeed +/// +/// Returns true if password verification is not needed, or if the password is +/// correct. Returns false if the password is incorrect or missing. +async fn verify_password_if_needed( + requester: &Requester, + config: &SiteConfig, + password_manager: &PasswordManager, + password: Option, + user: &mas_data_model::User, + repo: &mut BoxRepository, +) -> Result { + // If the requester is admin, they don't need to provide a password + if requester.is_admin() { + return Ok(true); + } + + // If password login is disabled, assume we don't want the user to reauth + if !config.password_login_enabled { + return Ok(true); + } + + // Else we need to check if the user has a password + let Some(user_password) = repo + .user_password() + .active(user) + .await + .context("Failed to load user password")? + else { + // User has no password, so we don't need to verify the password + return Ok(true); + }; + + let Some(password) = password else { + // There is a password on the user, but not provided in the input + return Ok(false); + }; + + let password = Zeroizing::new(password.into_bytes()); + + let res = password_manager + .verify( + user_password.version, + password, + user_password.hashed_password, + ) + .await; + + Ok(res.is_ok()) +} diff --git a/crates/handlers/src/graphql/mutations/user.rs b/crates/handlers/src/graphql/mutations/user.rs index bb50d1b04..ec9d2afe0 100644 --- a/crates/handlers/src/graphql/mutations/user.rs +++ b/crates/handlers/src/graphql/mutations/user.rs @@ -18,6 +18,7 @@ use ulid::Ulid; use url::Url; use zeroize::Zeroizing; +use super::verify_password_if_needed; use crate::graphql::{ UserId, model::{NodeType, User}, @@ -383,6 +384,61 @@ impl ResendRecoveryEmailPayload { } } +/// The input for the `deactivateUser` mutation. +#[derive(InputObject)] +pub struct DeactivateUserInput { + /// Whether to ask the homeserver to GDPR-erase the user + /// + /// This is equivalent to the `erase` parameter on the + /// `/_matrix/client/v3/account/deactivate` C-S API, which is + /// implementation-specific. + /// + /// What Synapse does is documented here: + /// + hs_erase: bool, + + /// The password of the user to deactivate. + password: Option, +} + +/// The payload for the `deactivateUser` mutation. +#[derive(Description)] +pub enum DeactivateUserPayload { + /// The user was deactivated. + Deactivated(mas_data_model::User), + + /// The password was wrong or missing. + IncorrectPassword, +} + +/// The status of the `deactivateUser` mutation. +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +pub enum DeactivateUserStatus { + /// The user was deactivated. + Deactivated, + + /// The password was wrong. + IncorrectPassword, +} + +#[Object(use_type_description)] +impl DeactivateUserPayload { + /// Status of the operation + async fn status(&self) -> DeactivateUserStatus { + match self { + Self::Deactivated(_) => DeactivateUserStatus::Deactivated, + Self::IncorrectPassword => DeactivateUserStatus::IncorrectPassword, + } + } + + async fn user(&self) -> Option { + match self { + Self::Deactivated(user) => Some(User(user.clone())), + Self::IncorrectPassword => None, + } + } +} + fn valid_username_character(c: char) -> bool { c.is_ascii_lowercase() || c.is_ascii_digit() @@ -868,4 +924,64 @@ impl UserMutations { recovery_session_id: recovery_session.id, }) } + + /// Deactivate the current user account + /// + /// If the user has a password, it *must* be supplied in the `password` + /// field. + async fn deactivate_user( + &self, + ctx: &Context<'_>, + input: DeactivateUserInput, + ) -> Result { + let state = ctx.state(); + let mut rng = state.rng(); + let clock = state.clock(); + let requester = ctx.requester(); + let site_config = state.site_config(); + + // Only allow calling this if the requester is a browser session + let Some(browser_session) = requester.browser_session() else { + return Err(async_graphql::Error::new("Unauthorized")); + }; + + if !site_config.account_deactivation_allowed { + return Err(async_graphql::Error::new( + "Account deactivation is not allowed on this server", + )); + } + + let mut repo = state.repository().await?; + if !verify_password_if_needed( + requester, + site_config, + &state.password_manager(), + input.password, + &browser_session.user, + &mut repo, + ) + .await? + { + return Ok(DeactivateUserPayload::IncorrectPassword); + } + + // Deactivate the user right away + let user = repo + .user() + .deactivate(&state.clock(), browser_session.user.clone()) + .await?; + + // and then schedule a job to deactivate it fully + repo.queue_job() + .schedule_job( + &mut rng, + &clock, + DeactivateUserJob::new(&user, input.hs_erase), + ) + .await?; + + repo.save().await?; + + Ok(DeactivateUserPayload::Deactivated(user)) + } } diff --git a/crates/handlers/src/graphql/mutations/user_email.rs b/crates/handlers/src/graphql/mutations/user_email.rs index 958b18be7..6f24f1ed4 100644 --- a/crates/handlers/src/graphql/mutations/user_email.rs +++ b/crates/handlers/src/graphql/mutations/user_email.rs @@ -6,75 +6,19 @@ use anyhow::Context as _; use async_graphql::{Context, Description, Enum, ID, InputObject, Object}; -use mas_data_model::SiteConfig; use mas_i18n::DataLocale; use mas_storage::{ - BoxRepository, RepositoryAccess, + RepositoryAccess, queue::{ProvisionUserJob, QueueJobRepositoryExt as _, SendEmailAuthenticationCodeJob}, user::{UserEmailFilter, UserEmailRepository, UserRepository}, }; -use zeroize::Zeroizing; -use crate::{ - graphql::{ - Requester, - model::{NodeType, User, UserEmail, UserEmailAuthentication}, - state::ContextExt, - }, - passwords::PasswordManager, +use super::verify_password_if_needed; +use crate::graphql::{ + model::{NodeType, User, UserEmail, UserEmailAuthentication}, + state::ContextExt, }; -/// Check the password if neeed -/// -/// Returns true if password verification is not needed, or if the password is -/// correct. Returns false if the password is incorrect or missing. -async fn verify_password_if_needed( - requester: &Requester, - config: &SiteConfig, - password_manager: &PasswordManager, - password: Option, - user: &mas_data_model::User, - repo: &mut BoxRepository, -) -> Result { - // If the requester is admin, they don't need to provide a password - if requester.is_admin() { - return Ok(true); - } - - // If password login is disabled, assume we don't want the user to reauth - if !config.password_login_enabled { - return Ok(true); - } - - // Else we need to check if the user has a password - let Some(user_password) = repo - .user_password() - .active(user) - .await - .context("Failed to load user password")? - else { - // User has no password, so we don't need to verify the password - return Ok(true); - }; - - let Some(password) = password else { - // There is a password on the user, but not provided in the input - return Ok(false); - }; - - let password = Zeroizing::new(password.into_bytes()); - - let res = password_manager - .verify( - user_password.version, - password, - user_password.hashed_password, - ) - .await; - - Ok(res.is_ok()) -} - #[derive(Default)] pub struct UserEmailMutations { _private: (), diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index 60da629e2..1cb6f29cd 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -137,6 +137,7 @@ pub fn test_site_config() -> SiteConfig { displayname_change_allowed: true, password_change_allowed: true, account_recovery_allowed: true, + account_deactivation_allowed: true, captcha: None, minimum_password_complexity: 1, session_expiration: None, diff --git a/docs/config.schema.json b/docs/config.schema.json index a998f08fd..0d8325529 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2463,6 +2463,10 @@ "password_recovery_enabled": { "description": "Whether email-based password recovery is enabled. Defaults to `false`.\n\nThis has no effect if password login is disabled.", "type": "boolean" + }, + "account_deactivation_allowed": { + "description": "Whether users are allowed to delete their own account. Defaults to `true`.", + "type": "boolean" } } }, diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 7289fec90..8eb0d44b2 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -309,6 +309,11 @@ account: # Defaults to `false`. # This has no effect if password login is disabled. password_recovery_enabled: false + + # Whether users are allowed to delete their own account + # + # Defaults to `true`. + account_deactivation_allowed: true ``` ## `captcha` 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/schema.graphql b/frontend/schema.graphql index eeb9b44e4..9cf0ca600 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -601,6 +601,52 @@ The input/output is a string in RFC3339 format. """ scalar DateTime +""" +The input for the `deactivateUser` mutation. +""" +input DeactivateUserInput { + """ + Whether to ask the homeserver to GDPR-erase the user + + This is equivalent to the `erase` parameter on the + `/_matrix/client/v3/account/deactivate` C-S API, which is + implementation-specific. + + What Synapse does is documented here: + + """ + hsErase: Boolean! + """ + The password of the user to deactivate. + """ + password: String +} + +""" +The payload for the `deactivateUser` mutation. +""" +type DeactivateUserPayload { + """ + Status of the operation + """ + status: DeactivateUserStatus! + user: User +} + +""" +The status of the `deactivateUser` mutation. +""" +enum DeactivateUserStatus { + """ + The user was deactivated. + """ + DEACTIVATED + """ + The password was wrong. + """ + INCORRECT_PASSWORD +} + """ The type of a user agent """ @@ -876,6 +922,13 @@ type Mutation { input: ResendRecoveryEmailInput! ): ResendRecoveryEmailPayload! """ + Deactivate the current user account + + If the user has a password, it *must* be supplied in the `password` + field. + """ + deactivateUser(input: DeactivateUserInput!): DeactivateUserPayload! + """ Create a new arbitrary OAuth 2.0 Session. Only available for administrators. @@ -1599,6 +1652,10 @@ type SiteConfig implements Node { """ passwordRegistrationEnabled: Boolean! """ + Whether users can delete their own account. + """ + accountDeactivationAllowed: Boolean! + """ Minimum password complexity, from 0 to 4, in terms of a zxcvbn score. The exact scorer (including dictionaries and other data tables) in use is . diff --git a/frontend/src/components/AccountDeleteButton.tsx b/frontend/src/components/AccountDeleteButton.tsx new file mode 100644 index 000000000..cb42edc81 --- /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/components/Client/__snapshots__/OAuth2ClientDetail.test.tsx.snap b/frontend/src/components/Client/__snapshots__/OAuth2ClientDetail.test.tsx.snap index 1acf56602..2558267f6 100644 --- a/frontend/src/components/Client/__snapshots__/OAuth2ClientDetail.test.tsx.snap +++ b/frontend/src/components/Client/__snapshots__/OAuth2ClientDetail.test.tsx.snap @@ -21,13 +21,12 @@ exports[` > renders client details 1`] = ` class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93" > Client info -