Skip to content
Merged
109 changes: 109 additions & 0 deletions crates/handlers/src/graphql/mutations/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -383,6 +384,54 @@ impl ResendRecoveryEmailPayload {
}
}

/// The input for the `deactivateUser` mutation.
#[derive(InputObject)]
pub struct DeactivateUserInput {
/// Whether to ask the homeserver to GDPR-erase the user
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()
Expand Down Expand Up @@ -868,4 +917,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))
}
}
46 changes: 46 additions & 0 deletions frontend/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,45 @@ 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
"""
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
"""
Expand Down Expand Up @@ -876,6 +915,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.
Expand Down
36 changes: 36 additions & 0 deletions frontend/src/gql/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,29 @@ 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 */
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. */
Expand Down Expand Up @@ -519,6 +542,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;
Expand Down Expand Up @@ -596,6 +626,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;
Expand Down