mirror of
https://github.com/element-hq/matrix-authentication-service.git
synced 2026-10-22 17:32:47 +00:00
Allow users to deactivate their own account in the UI (#4209)
This commit is contained in:
31 files changed
+1016
-238
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<CaptchaConfig>,
|
||||
|
||||
|
||||
@@ -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 <https://crates.io/crates/zxcvbn>.
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
user: &mas_data_model::User,
|
||||
repo: &mut BoxRepository,
|
||||
) -> Result<bool, async_graphql::Error> {
|
||||
// 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())
|
||||
}
|
||||
@@ -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:
|
||||
/// <https://element-hq.github.io/synapse/latest/admin_api/user_admin_api.html#deactivate-account>
|
||||
hs_erase: bool,
|
||||
|
||||
/// The password of the user to deactivate.
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<User> {
|
||||
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<DeactivateUserPayload, async_graphql::Error> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
user: &mas_data_model::User,
|
||||
repo: &mut BoxRepository,
|
||||
) -> Result<bool, async_graphql::Error> {
|
||||
// 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: (),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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": "<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 you’re signed in.",
|
||||
"display_name_label": "Display name",
|
||||
|
||||
@@ -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:
|
||||
<https://element-hq.github.io/synapse/latest/admin_api/user_admin_api.html#deactivate-account>
|
||||
"""
|
||||
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 <https://crates.io/crates/zxcvbn>.
|
||||
|
||||
@@ -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="56px" />
|
||||
<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-outside pl-6" />,
|
||||
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;
|
||||
@@ -21,13 +21,12 @@ exports[`<OAuth2ClientDetail> > renders client details 1`] = `
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
|
||||
>
|
||||
Client info
|
||||
<div
|
||||
class="_separator_7ckbw_8"
|
||||
data-kind="section"
|
||||
data-orientation="horizontal"
|
||||
role="separator"
|
||||
/>
|
||||
</h4>
|
||||
<div
|
||||
aria-orientation="horizontal"
|
||||
class="_separator_162edc _section_162edc -mt-4"
|
||||
role="separator"
|
||||
/>
|
||||
<ul
|
||||
class="flex flex-wrap gap-x-10 gap-y-6"
|
||||
>
|
||||
|
||||
@@ -89,17 +89,20 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* Make sure the border radius cuts the content */
|
||||
overflow: hidden;
|
||||
contain: paint;
|
||||
|
||||
/* Cap the block size */
|
||||
max-block-size: calc(100vh - var(--cpd-space-4x));
|
||||
max-block-size: calc(100svh - var(--cpd-space-4x));
|
||||
|
||||
/* Drawer comes in the Android style by default */
|
||||
--border-radius: 28px;
|
||||
--handle-block-size: 4px;
|
||||
--handle-inline-size: 32px;
|
||||
--handle-inset-block-start: var(--cpd-space-4x);
|
||||
--handle-inset-block-end: 0px;
|
||||
--content-inset-block-start: calc(
|
||||
var(--handle-inset-block-start) +
|
||||
var(--handle-block-size) +
|
||||
var(--handle-inset-block-end)
|
||||
);
|
||||
}
|
||||
|
||||
.drawer[data-platform="ios"] {
|
||||
@@ -111,31 +114,20 @@
|
||||
}
|
||||
|
||||
.drawer .body {
|
||||
padding-inline: var(--cpd-space-4x);
|
||||
padding-block-start: calc(
|
||||
var(--content-inset-block-start) +
|
||||
var(--cpd-space-6x)
|
||||
);
|
||||
padding-block-end: var(--cpd-space-12x);
|
||||
border-start-start-radius: var(--border-radius);
|
||||
border-start-end-radius: var(--border-radius);
|
||||
|
||||
/* Even with overflow: auto, the content can still overflow at the corners
|
||||
where it meets with the curved border. A contain: paint fixes that. */
|
||||
contain: paint;
|
||||
overflow: auto;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
--cpd-separator-spacing: 0;
|
||||
--cpd-separator-inset: var(--cpd-space-4x);
|
||||
padding-inline: var(--cpd-space-4x);
|
||||
margin-block-start: var(--cpd-space-2x);
|
||||
padding-block-start: var(--cpd-space-4x);
|
||||
padding-block-end: var(--cpd-space-12x);
|
||||
}
|
||||
|
||||
.drawer .body::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
.drawer .handle {
|
||||
align-self: center;
|
||||
block-size: var(--handle-block-size);
|
||||
inset-inline: calc((100% - var(--handle-inline-size)) / 2);
|
||||
inset-block-start: var(--handle-inset-block-start);
|
||||
inline-size: var(--handle-inline-size);
|
||||
margin-block-start: var(--handle-inset-block-start);
|
||||
margin-block-end: var(--handle-inset-block-end);
|
||||
background: var(--cpd-color-icon-secondary);
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
}
|
||||
@@ -64,6 +64,7 @@ export const Dialog: React.FC<Props> = ({
|
||||
<Portal>
|
||||
<Drawer.Overlay className={styles.overlay} />
|
||||
<Drawer.Content className={styles.drawer} data-platform={platform}>
|
||||
<Drawer.Handle className={styles.handle} />
|
||||
<div className={styles.body}>{children}</div>
|
||||
</Drawer.Content>
|
||||
</Portal>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
.separator {
|
||||
border-block-start: 1px solid var(--cpd-color-bg-subtle-primary);
|
||||
}
|
||||
|
||||
.section {
|
||||
border-block-start-width: 2px;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2025 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import cx from "classnames";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
import styles from "./Separator.module.css";
|
||||
|
||||
type Props = {
|
||||
kind?: "section";
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
const Separator = forwardRef<HTMLDivElement, Props>(
|
||||
({ kind, className, ...props }: Props, ref) => (
|
||||
// biome-ignore lint/a11y/useFocusableInteractive: this is a false positive
|
||||
<div
|
||||
aria-orientation="horizontal"
|
||||
role="separator"
|
||||
className={cx(
|
||||
styles.separator,
|
||||
kind === "section" && styles.section,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
export default Separator;
|
||||
@@ -0,0 +1,6 @@
|
||||
// Copyright 2025 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
export { default } from "./Separator";
|
||||
@@ -11,7 +11,6 @@ import IconSend from "@vector-im/compound-design-tokens/assets/web/icons/send";
|
||||
import IconUserProfile from "@vector-im/compound-design-tokens/assets/web/icons/user-profile";
|
||||
import {
|
||||
Heading,
|
||||
Separator,
|
||||
Text,
|
||||
VisualList,
|
||||
VisualListItem,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
import cx from "classnames";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Separator from "../Separator/Separator";
|
||||
|
||||
export const ScopeViewProfile: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -178,8 +178,10 @@ export const DataSection: React.FC<
|
||||
export const DataSectionHeader: React.FC<
|
||||
React.PropsWithChildren<{ className?: string }>
|
||||
> = ({ children, className }) => (
|
||||
<Heading as="h4" size="sm" weight="semibold" className={className}>
|
||||
{children}
|
||||
<Separator kind="section" />
|
||||
</Heading>
|
||||
<>
|
||||
<Heading as="h4" size="sm" weight="semibold" className={className}>
|
||||
{children}
|
||||
</Heading>
|
||||
<Separator className="-mt-4" kind="section" />
|
||||
</>
|
||||
);
|
||||
+30
-36
@@ -39,13 +39,12 @@ exports[`<CompatSessionDetail> > renders a compatability session details 1`] = `
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
|
||||
>
|
||||
Device details
|
||||
<div
|
||||
class="_separator_7ckbw_8"
|
||||
data-kind="section"
|
||||
data-orientation="horizontal"
|
||||
role="separator"
|
||||
/>
|
||||
</h4>
|
||||
<div
|
||||
aria-orientation="horizontal"
|
||||
class="_separator_162edc _section_162edc -mt-4"
|
||||
role="separator"
|
||||
/>
|
||||
<ul
|
||||
class="flex flex-wrap gap-x-10 gap-y-6"
|
||||
>
|
||||
@@ -199,13 +198,12 @@ exports[`<CompatSessionDetail> > renders a compatability session details 1`] = `
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
|
||||
>
|
||||
Client info
|
||||
<div
|
||||
class="_separator_7ckbw_8"
|
||||
data-kind="section"
|
||||
data-orientation="horizontal"
|
||||
role="separator"
|
||||
/>
|
||||
</h4>
|
||||
<div
|
||||
aria-orientation="horizontal"
|
||||
class="_separator_162edc _section_162edc -mt-4"
|
||||
role="separator"
|
||||
/>
|
||||
<ul
|
||||
class="flex flex-wrap gap-x-10 gap-y-6"
|
||||
>
|
||||
@@ -308,13 +306,12 @@ exports[`<CompatSessionDetail> > renders a compatability session without an ssoL
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
|
||||
>
|
||||
Device details
|
||||
<div
|
||||
class="_separator_7ckbw_8"
|
||||
data-kind="section"
|
||||
data-orientation="horizontal"
|
||||
role="separator"
|
||||
/>
|
||||
</h4>
|
||||
<div
|
||||
aria-orientation="horizontal"
|
||||
class="_separator_162edc _section_162edc -mt-4"
|
||||
role="separator"
|
||||
/>
|
||||
<ul
|
||||
class="flex flex-wrap gap-x-10 gap-y-6"
|
||||
>
|
||||
@@ -468,13 +465,12 @@ exports[`<CompatSessionDetail> > renders a compatability session without an ssoL
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
|
||||
>
|
||||
Client info
|
||||
<div
|
||||
class="_separator_7ckbw_8"
|
||||
data-kind="section"
|
||||
data-orientation="horizontal"
|
||||
role="separator"
|
||||
/>
|
||||
</h4>
|
||||
<div
|
||||
aria-orientation="horizontal"
|
||||
class="_separator_162edc _section_162edc -mt-4"
|
||||
role="separator"
|
||||
/>
|
||||
<ul
|
||||
class="flex flex-wrap gap-x-10 gap-y-6"
|
||||
>
|
||||
@@ -575,13 +571,12 @@ exports[`<CompatSessionDetail> > renders a finished compatability session detail
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
|
||||
>
|
||||
Device details
|
||||
<div
|
||||
class="_separator_7ckbw_8"
|
||||
data-kind="section"
|
||||
data-orientation="horizontal"
|
||||
role="separator"
|
||||
/>
|
||||
</h4>
|
||||
<div
|
||||
aria-orientation="horizontal"
|
||||
class="_separator_162edc _section_162edc -mt-4"
|
||||
role="separator"
|
||||
/>
|
||||
<ul
|
||||
class="flex flex-wrap gap-x-10 gap-y-6"
|
||||
>
|
||||
@@ -753,13 +748,12 @@ exports[`<CompatSessionDetail> > renders a finished compatability session detail
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
|
||||
>
|
||||
Client info
|
||||
<div
|
||||
class="_separator_7ckbw_8"
|
||||
data-kind="section"
|
||||
data-orientation="horizontal"
|
||||
role="separator"
|
||||
/>
|
||||
</h4>
|
||||
<div
|
||||
aria-orientation="horizontal"
|
||||
class="_separator_162edc _section_162edc -mt-4"
|
||||
role="separator"
|
||||
/>
|
||||
<ul
|
||||
class="flex flex-wrap gap-x-10 gap-y-6"
|
||||
>
|
||||
|
||||
+20
-24
@@ -37,13 +37,12 @@ exports[`<OAuth2SessionDetail> > renders a finished session details 1`] = `
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
|
||||
>
|
||||
Device details
|
||||
<div
|
||||
class="_separator_7ckbw_8"
|
||||
data-kind="section"
|
||||
data-orientation="horizontal"
|
||||
role="separator"
|
||||
/>
|
||||
</h4>
|
||||
<div
|
||||
aria-orientation="horizontal"
|
||||
class="_separator_162edc _section_162edc -mt-4"
|
||||
role="separator"
|
||||
/>
|
||||
<ul
|
||||
class="flex flex-wrap gap-x-10 gap-y-6"
|
||||
>
|
||||
@@ -215,13 +214,12 @@ exports[`<OAuth2SessionDetail> > renders a finished session details 1`] = `
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
|
||||
>
|
||||
Client info
|
||||
<div
|
||||
class="_separator_7ckbw_8"
|
||||
data-kind="section"
|
||||
data-orientation="horizontal"
|
||||
role="separator"
|
||||
/>
|
||||
</h4>
|
||||
<div
|
||||
aria-orientation="horizontal"
|
||||
class="_separator_162edc _section_162edc -mt-4"
|
||||
role="separator"
|
||||
/>
|
||||
<ul
|
||||
class="flex flex-wrap gap-x-10 gap-y-6"
|
||||
>
|
||||
@@ -318,13 +316,12 @@ exports[`<OAuth2SessionDetail> > renders session details 1`] = `
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
|
||||
>
|
||||
Device details
|
||||
<div
|
||||
class="_separator_7ckbw_8"
|
||||
data-kind="section"
|
||||
data-orientation="horizontal"
|
||||
role="separator"
|
||||
/>
|
||||
</h4>
|
||||
<div
|
||||
aria-orientation="horizontal"
|
||||
class="_separator_162edc _section_162edc -mt-4"
|
||||
role="separator"
|
||||
/>
|
||||
<ul
|
||||
class="flex flex-wrap gap-x-10 gap-y-6"
|
||||
>
|
||||
@@ -478,13 +475,12 @@ exports[`<OAuth2SessionDetail> > renders session details 1`] = `
|
||||
class="_typography_6v6n8_153 _font-heading-sm-semibold_6v6n8_93"
|
||||
>
|
||||
Client info
|
||||
<div
|
||||
class="_separator_7ckbw_8"
|
||||
data-kind="section"
|
||||
data-orientation="horizontal"
|
||||
role="separator"
|
||||
/>
|
||||
</h4>
|
||||
<div
|
||||
aria-orientation="horizontal"
|
||||
class="_separator_162edc _section_162edc -mt-4"
|
||||
role="separator"
|
||||
/>
|
||||
<ul
|
||||
class="flex flex-wrap gap-x-10 gap-y-6"
|
||||
>
|
||||
|
||||
+21
-3
@@ -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.
|
||||
*/
|
||||
|
||||
+121
-4
@@ -389,6 +389,38 @@ export type DateFilter = {
|
||||
before?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
};
|
||||
|
||||
/** The input for the `deactivateUser` mutation. */
|
||||
export type 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:
|
||||
* <https://element-hq.github.io/synapse/latest/admin_api/user_admin_api.html#deactivate-account>
|
||||
*/
|
||||
hsErase: Scalars['Boolean']['input'];
|
||||
/** The password of the user to deactivate. */
|
||||
password?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
/** The payload for the `deactivateUser` mutation. */
|
||||
export type DeactivateUserPayload = {
|
||||
__typename?: 'DeactivateUserPayload';
|
||||
/** Status of the operation */
|
||||
status: DeactivateUserStatus;
|
||||
user?: Maybe<User>;
|
||||
};
|
||||
|
||||
/** The status of the `deactivateUser` mutation. */
|
||||
export type DeactivateUserStatus =
|
||||
/** The user was deactivated. */
|
||||
| 'DEACTIVATED'
|
||||
/** The password was wrong. */
|
||||
| 'INCORRECT_PASSWORD';
|
||||
|
||||
/** The type of a user agent */
|
||||
export type DeviceType =
|
||||
/** A mobile phone. Can also sometimes be a tablet. */
|
||||
@@ -519,6 +551,13 @@ export type Mutation = {
|
||||
* Only available for administrators.
|
||||
*/
|
||||
createOauth2Session: CreateOAuth2SessionPayload;
|
||||
/**
|
||||
* Deactivate the current user account
|
||||
*
|
||||
* If the user has a password, it *must* be supplied in the `password`
|
||||
* field.
|
||||
*/
|
||||
deactivateUser: DeactivateUserPayload;
|
||||
endBrowserSession: EndBrowserSessionPayload;
|
||||
endCompatSession: EndCompatSessionPayload;
|
||||
endOauth2Session: EndOAuth2SessionPayload;
|
||||
@@ -596,6 +635,12 @@ export type MutationCreateOauth2SessionArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** The mutations root of the GraphQL interface. */
|
||||
export type MutationDeactivateUserArgs = {
|
||||
input: DeactivateUserInput;
|
||||
};
|
||||
|
||||
|
||||
/** The mutations root of the GraphQL interface. */
|
||||
export type MutationEndBrowserSessionArgs = {
|
||||
input: EndBrowserSessionInput;
|
||||
@@ -1161,6 +1206,8 @@ export type SetPrimaryEmailStatus =
|
||||
|
||||
export type SiteConfig = Node & {
|
||||
__typename?: 'SiteConfig';
|
||||
/** Whether users can delete their own account. */
|
||||
accountDeactivationAllowed: Scalars['Boolean']['output'];
|
||||
/** The configuration of CAPTCHA provider. */
|
||||
captchaConfig?: Maybe<CaptchaConfig>;
|
||||
/** Whether users can change their display name. */
|
||||
@@ -1579,6 +1626,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 = (
|
||||
@@ -1711,10 +1770,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<{
|
||||
@@ -1906,6 +1965,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
|
||||
@@ -2237,6 +2311,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 {
|
||||
@@ -2346,6 +2427,7 @@ export const UserProfileDocument = new TypedDocumentString(`
|
||||
user {
|
||||
...AddEmailForm_user
|
||||
...UserEmailList_user
|
||||
...AccountDeleteButton_user
|
||||
hasPassword
|
||||
emails(first: 0) {
|
||||
totalCount
|
||||
@@ -2356,12 +2438,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 {
|
||||
@@ -2816,6 +2911,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))
|
||||
|
||||
@@ -11,13 +11,15 @@ import {
|
||||
useNavigate,
|
||||
} from "@tanstack/react-router";
|
||||
import IconSignOut from "@vector-im/compound-design-tokens/assets/web/icons/sign-out";
|
||||
import { Button, Separator, Text } from "@vector-im/compound-web";
|
||||
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";
|
||||
import * as Dialog from "../components/Dialog";
|
||||
import LoadingSpinner from "../components/LoadingSpinner";
|
||||
import Separator from "../components/Separator";
|
||||
import { useEndBrowserSession } from "../components/Session/EndBrowserSessionButton";
|
||||
import AddEmailForm from "../components/UserProfile/AddEmailForm";
|
||||
import UserEmailList from "../components/UserProfile/UserEmailList";
|
||||
@@ -75,7 +77,7 @@ function Index(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Only display this section if the user can add email addresses to their
|
||||
account *or* if they have any existing email addresses */}
|
||||
{(siteConfig.emailChangeAllowed ||
|
||||
@@ -126,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { createLazyFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { H3, Separator } from "@vector-im/compound-web";
|
||||
import { H3 } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ButtonLink } from "../components/ButtonLink";
|
||||
@@ -17,6 +17,7 @@ import BrowserSessionsOverview from "../components/UserSessionsOverview/BrowserS
|
||||
import { usePages } from "../pagination";
|
||||
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import Separator from "../components/Separator";
|
||||
import { listQuery, query } from "./_account.sessions.index";
|
||||
|
||||
const PAGE_SIZE = 6;
|
||||
@@ -55,7 +56,7 @@ function Sessions(): React.ReactElement {
|
||||
<div className="flex flex-col gap-6">
|
||||
<H3>{t("frontend.user_sessions_overview.heading")}</H3>
|
||||
<BrowserSessionsOverview user={viewer} />
|
||||
<Separator />
|
||||
<Separator kind="section" />
|
||||
<div className="flex gap-2 justify-start items-center">
|
||||
<Filter
|
||||
to="/sessions"
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
useRouter,
|
||||
} from "@tanstack/react-router";
|
||||
import IconLockSolid from "@vector-im/compound-design-tokens/assets/web/icons/lock-solid";
|
||||
import { Alert, Form, Separator } from "@vector-im/compound-web";
|
||||
import { Alert, Form } from "@vector-im/compound-web";
|
||||
import { type FormEvent, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -20,6 +20,7 @@ import Layout from "../components/Layout";
|
||||
import LoadingSpinner from "../components/LoadingSpinner";
|
||||
import PageHeading from "../components/PageHeading";
|
||||
import PasswordCreationDoubleInput from "../components/PasswordCreationDoubleInput";
|
||||
import Separator from "../components/Separator";
|
||||
import { graphql } from "../gql";
|
||||
import { graphqlRequest } from "../graphql";
|
||||
import { translateSetPasswordError } from "../i18n/password_changes";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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 you’re 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 you’re 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"
|
||||
>
|
||||
@@ -400,7 +400,7 @@ exports[`Account home page > renders the page 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col gap-4"
|
||||
class="flex flex-col gap-6"
|
||||
>
|
||||
<section
|
||||
aria-labelledby=":r8:"
|
||||
@@ -518,9 +518,8 @@ exports[`Account home page > renders the page 1`] = `
|
||||
</article>
|
||||
</section>
|
||||
<div
|
||||
class="_separator_7ckbw_8"
|
||||
data-kind="section"
|
||||
data-orientation="horizontal"
|
||||
aria-orientation="horizontal"
|
||||
class="_separator_162edc _section_162edc"
|
||||
role="separator"
|
||||
/>
|
||||
<section
|
||||
@@ -614,9 +613,8 @@ exports[`Account home page > renders the page 1`] = `
|
||||
</article>
|
||||
</section>
|
||||
<div
|
||||
class="_separator_7ckbw_8"
|
||||
data-kind="section"
|
||||
data-orientation="horizontal"
|
||||
aria-orientation="horizontal"
|
||||
class="_separator_162edc _section_162edc"
|
||||
role="separator"
|
||||
/>
|
||||
<section
|
||||
@@ -668,38 +666,73 @@ exports[`Account home page > renders the page 1`] = `
|
||||
</header>
|
||||
</section>
|
||||
<div
|
||||
class="_separator_7ckbw_8"
|
||||
data-kind="section"
|
||||
data-orientation="horizontal"
|
||||
aria-orientation="horizontal"
|
||||
class="_separator_162edc _section_162edc"
|
||||
role="separator"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<footer
|
||||
class="_legalFooter_eb428f"
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user