diff --git a/crates/cli/src/commands/manage.rs b/crates/cli/src/commands/manage.rs index 97c019175..12560a9ac 100644 --- a/crates/cli/src/commands/manage.rs +++ b/crates/cli/src/commands/manage.rs @@ -149,6 +149,10 @@ enum Subcommand { UnlockUser { /// User to unlock username: String, + + /// Whether to reactivate the user if it had been deactivated + #[arg(long)] + reactivate: bool, }, /// Register a user @@ -527,8 +531,12 @@ impl Options { Ok(ExitCode::SUCCESS) } - SC::UnlockUser { username } => { - let _span = info_span!("cli.manage.lock_user", user.username = username).entered(); + SC::UnlockUser { + username, + reactivate, + } => { + let _span = + info_span!("cli.manage.unlock_user", user.username = username).entered(); let config = DatabaseConfig::extract_or_default(figment)?; let mut conn = database_connection_from_config(&config).await?; let txn = conn.begin().await?; @@ -540,10 +548,14 @@ impl Options { .await? .context("User not found")?; - warn!(%user.id, "User scheduling user reactivation"); - repo.queue_job() - .schedule_job(&mut rng, &clock, ReactivateUserJob::new(&user)) - .await?; + if reactivate { + warn!(%user.id, "Scheduling user reactivation"); + repo.queue_job() + .schedule_job(&mut rng, &clock, ReactivateUserJob::new(&user)) + .await?; + } else { + repo.user().unlock(user).await?; + } repo.into_inner().commit().await?; diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index c1d29aad5..af1951019 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -94,6 +94,10 @@ where "/users/{id}/deactivate", post_with(self::users::deactivate, self::users::deactivate_doc), ) + .api_route( + "/users/{id}/reactivate", + post_with(self::users::reactivate, self::users::reactivate_doc), + ) .api_route( "/users/{id}/lock", post_with(self::users::lock, self::users::lock_doc), diff --git a/crates/handlers/src/admin/v1/users/deactivate.rs b/crates/handlers/src/admin/v1/users/deactivate.rs index 87c2361a2..ac4943f93 100644 --- a/crates/handlers/src/admin/v1/users/deactivate.rs +++ b/crates/handlers/src/admin/v1/users/deactivate.rs @@ -53,14 +53,17 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { operation .id("deactivateUser") .summary("Deactivate a user") - .description("Calling this endpoint will lock and deactivate the user, preventing them from doing any action. -This invalidates any existing session, and will ask the homeserver to make them leave all rooms.") + .description( + "Calling this endpoint will deactivate the user, preventing them from doing any action. +This invalidates any existing session, and will ask the homeserver to make them leave all rooms.", + ) .tag("user") .response_with::<200, Json>, _>(|t| { // In the samples, the third user is the one locked let [_alice, _bob, charlie, ..] = User::samples(); let id = charlie.id(); - let response = SingleResponse::new(charlie, format!("/api/admin/v1/users/{id}/deactivate")); + let response = + SingleResponse::new(charlie, format!("/api/admin/v1/users/{id}/deactivate")); t.description("User was deactivated").example(response) }) .response_with::<404, RouteError, _>(|t| { @@ -78,15 +81,13 @@ pub async fn handler( id: UlidPathParam, ) -> Result>, RouteError> { let id = *id; - let mut user = repo + let user = repo .user() .lookup(id) .await? .ok_or(RouteError::NotFound(id))?; - if user.locked_at.is_none() { - user = repo.user().lock(&clock, user).await?; - } + let user = repo.user().deactivate(&clock, user).await?; info!(%user.id, "Scheduling deactivation of user"); repo.queue_job() @@ -132,12 +133,18 @@ mod tests { response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - // The locked_at timestamp should be the same as the current time + // The deactivated_at timestamp should be the same as the current time assert_eq!( - body["data"]["attributes"]["locked_at"], + body["data"]["attributes"]["deactivated_at"], serde_json::json!(state.clock.now()) ); + // Deactivating the user should not lock it + assert_eq!( + body["data"]["attributes"]["locked_at"], + serde_json::Value::Null + ); + // Make sure to run the jobs in the queue state.run_jobs_in_queue().await; @@ -156,7 +163,7 @@ mod tests { "attributes": { "username": "alice", "created_at": "2022-01-16T14:40:00Z", - "locked_at": "2022-01-16T14:40:00Z", + "locked_at": null, "deactivated_at": "2022-01-16T14:40:00Z", "admin": false }, @@ -196,10 +203,16 @@ mod tests { response.assert_status(StatusCode::OK); let body: serde_json::Value = response.json(); - // The locked_at timestamp should be different from the current time + // The deactivated_at timestamp should be the same as the current time + assert_eq!( + body["data"]["attributes"]["deactivated_at"], + serde_json::json!(state.clock.now()) + ); + + // The deactivated_at timestamp should be different from the locked_at timestamp assert_ne!( + body["data"]["attributes"]["deactivated_at"], body["data"]["attributes"]["locked_at"], - serde_json::json!(state.clock.now()) ); // Make sure to run the jobs in the queue diff --git a/crates/handlers/src/admin/v1/users/lock.rs b/crates/handlers/src/admin/v1/users/lock.rs index ed99b6a75..ec8159532 100644 --- a/crates/handlers/src/admin/v1/users/lock.rs +++ b/crates/handlers/src/admin/v1/users/lock.rs @@ -72,15 +72,13 @@ pub async fn handler( id: UlidPathParam, ) -> Result>, RouteError> { let id = *id; - let mut user = repo + let user = repo .user() .lookup(id) .await? .ok_or(RouteError::NotFound(id))?; - if user.locked_at.is_none() { - user = repo.user().lock(&clock, user).await?; - } + let user = repo.user().lock(&clock, user).await?; repo.save().await?; @@ -157,6 +155,10 @@ mod tests { body["data"]["attributes"]["locked_at"], serde_json::json!(state.clock.now()) ); + assert_ne!( + body["data"]["attributes"]["locked_at"], + serde_json::Value::Null + ); } #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] diff --git a/crates/handlers/src/admin/v1/users/mod.rs b/crates/handlers/src/admin/v1/users/mod.rs index b9c0b5ea6..37484b75b 100644 --- a/crates/handlers/src/admin/v1/users/mod.rs +++ b/crates/handlers/src/admin/v1/users/mod.rs @@ -10,6 +10,7 @@ mod deactivate; mod get; mod list; mod lock; +mod reactivate; mod set_admin; mod set_password; mod unlock; @@ -21,6 +22,7 @@ pub use self::{ get::{doc as get_doc, handler as get}, list::{doc as list_doc, handler as list}, lock::{doc as lock_doc, handler as lock}, + reactivate::{doc as reactivate_doc, handler as reactivate}, set_admin::{doc as set_admin_doc, handler as set_admin}, set_password::{doc as set_password_doc, handler as set_password}, unlock::{doc as unlock_doc, handler as unlock}, diff --git a/crates/handlers/src/admin/v1/users/reactivate.rs b/crates/handlers/src/admin/v1/users/reactivate.rs new file mode 100644 index 000000000..37b38c6b6 --- /dev/null +++ b/crates/handlers/src/admin/v1/users/reactivate.rs @@ -0,0 +1,220 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use std::sync::Arc; + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{Json, extract::State, response::IntoResponse}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use mas_matrix::HomeserverConnection; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{Resource, User}, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error(transparent)] + Homeserver(anyhow::Error), + + #[error("User ID {0} not found")] + NotFound(Ulid), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_) | Self::Homeserver(_)); + let status = match self { + Self::Internal(_) | Self::Homeserver(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound(_) => StatusCode::NOT_FOUND, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("reactivateUser") + .summary("Reactivate a user") + .description("Calling this endpoint will reactivate a deactivated user. +This DOES NOT unlock a locked user, which is still prevented from doing any action until it is explicitly unlocked.") + .tag("user") + .response_with::<200, Json>, _>(|t| { + // In the samples, the third user is the one locked + let [sample, ..] = User::samples(); + let id = sample.id(); + let response = SingleResponse::new(sample, format!("/api/admin/v1/users/{id}/reactivate")); + t.description("User was reactivated").example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("User ID not found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.users.reactivate", skip_all)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + State(homeserver): State>, + id: UlidPathParam, +) -> Result>, RouteError> { + let id = *id; + let user = repo + .user() + .lookup(id) + .await? + .ok_or(RouteError::NotFound(id))?; + + // Call the homeserver synchronously to reactivate the user + let mxid = homeserver.mxid(&user.username); + homeserver + .reactivate_user(&mxid) + .await + .map_err(RouteError::Homeserver)?; + + // Now reactivate the user in our database + let user = repo.user().reactivate(user).await?; + + repo.save().await?; + + Ok(Json(SingleResponse::new( + User::from(user), + format!("/api/admin/v1/users/{id}/reactivate"), + ))) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use mas_matrix::{HomeserverConnection, ProvisionRequest}; + use mas_storage::{Clock, RepositoryAccess, user::UserRepository}; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_reactivate_deactivated_user(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool.clone()).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut state.rng(), &state.clock, "alice".to_owned()) + .await + .unwrap(); + let user = repo.user().lock(&state.clock, user).await.unwrap(); + let user = repo.user().deactivate(&state.clock, user).await.unwrap(); + repo.save().await.unwrap(); + + // Provision and immediately deactivate the user on the homeserver, + // because this endpoint will try to reactivate it + let mxid = state.homeserver_connection.mxid(&user.username); + state + .homeserver_connection + .provision_user(&ProvisionRequest::new(&mxid, &user.sub)) + .await + .unwrap(); + state + .homeserver_connection + .delete_user(&mxid, true) + .await + .unwrap(); + + // The user should be deactivated on the homeserver + let mx_user = state.homeserver_connection.query_user(&mxid).await.unwrap(); + assert!(mx_user.deactivated); + + let request = Request::post(format!("/api/admin/v1/users/{}/reactivate", user.id)) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // The user should remain locked after being reactivated + assert_eq!( + body["data"]["attributes"]["locked_at"], + serde_json::json!(state.clock.now()) + ); + assert_eq!( + body["data"]["attributes"]["deactivated_at"], + serde_json::Value::Null, + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_reactivate_active_user(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool.clone()).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut state.rng(), &state.clock, "alice".to_owned()) + .await + .unwrap(); + repo.save().await.unwrap(); + + // Provision the user on the homeserver + let mxid = state.homeserver_connection.mxid(&user.username); + state + .homeserver_connection + .provision_user(&ProvisionRequest::new(&mxid, &user.sub)) + .await + .unwrap(); + + let request = Request::post(format!("/api/admin/v1/users/{}/reactivate", user.id)) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + assert_eq!( + body["data"]["attributes"]["locked_at"], + serde_json::Value::Null + ); + assert_eq!( + body["data"]["attributes"]["deactivated_at"], + serde_json::Value::Null + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_reactivate_unknown_user(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = Request::post("/api/admin/v1/users/01040G2081040G2081040G2081/reactivate") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json(); + assert_eq!( + body["errors"][0]["title"], + "User ID 01040G2081040G2081040G2081 not found" + ); + } +} diff --git a/crates/handlers/src/admin/v1/users/unlock.rs b/crates/handlers/src/admin/v1/users/unlock.rs index 6e0311eec..5584f4a69 100644 --- a/crates/handlers/src/admin/v1/users/unlock.rs +++ b/crates/handlers/src/admin/v1/users/unlock.rs @@ -4,13 +4,10 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. -use std::sync::Arc; - use aide::{OperationIo, transform::TransformOperation}; -use axum::{Json, extract::State, response::IntoResponse}; +use axum::{Json, response::IntoResponse}; use hyper::StatusCode; use mas_axum_utils::record_error; -use mas_matrix::HomeserverConnection; use ulid::Ulid; use crate::{ @@ -29,9 +26,6 @@ pub enum RouteError { #[error(transparent)] Internal(Box), - #[error(transparent)] - Homeserver(anyhow::Error), - #[error("User ID {0} not found")] NotFound(Ulid), } @@ -41,9 +35,9 @@ impl_from_error_for_route!(mas_storage::RepositoryError); impl IntoResponse for RouteError { fn into_response(self) -> axum::response::Response { let error = ErrorResponse::from_error(&self); - let sentry_event_id = record_error!(self, Self::Internal(_) | Self::Homeserver(_)); + let sentry_event_id = record_error!(self, Self::Internal(_)); let status = match self { - Self::Internal(_) | Self::Homeserver(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, Self::NotFound(_) => StatusCode::NOT_FOUND, }; (status, sentry_event_id, Json(error)).into_response() @@ -54,6 +48,8 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { operation .id("unlockUser") .summary("Unlock a user") + .description("Calling this endpoint will lift restrictions on user actions that had imposed by locking. +This DOES NOT reactivate a deactivated user, which will remain unavailable until it is explicitly reactivated.") .tag("user") .response_with::<200, Json>, _>(|t| { // In the samples, the third user is the one locked @@ -71,7 +67,6 @@ pub fn doc(operation: TransformOperation) -> TransformOperation { #[tracing::instrument(name = "handler.admin.v1.users.unlock", skip_all)] pub async fn handler( CallContext { mut repo, .. }: CallContext, - State(homeserver): State>, id: UlidPathParam, ) -> Result>, RouteError> { let id = *id; @@ -81,14 +76,6 @@ pub async fn handler( .await? .ok_or(RouteError::NotFound(id))?; - // Call the homeserver synchronously to unlock the user - let mxid = homeserver.mxid(&user.username); - homeserver - .reactivate_user(&mxid) - .await - .map_err(RouteError::Homeserver)?; - - // Now unlock the user in our database let user = repo.user().unlock(user).await?; repo.save().await?; @@ -103,7 +90,7 @@ pub async fn handler( mod tests { use hyper::{Request, StatusCode}; use mas_matrix::{HomeserverConnection, ProvisionRequest}; - use mas_storage::{RepositoryAccess, user::UserRepository}; + use mas_storage::{Clock, RepositoryAccess, user::UserRepository}; use sqlx::PgPool; use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; @@ -141,7 +128,7 @@ mod tests { assert_eq!( body["data"]["attributes"]["locked_at"], - serde_json::json!(null) + serde_json::Value::Null ); } @@ -158,6 +145,7 @@ mod tests { .await .unwrap(); let user = repo.user().lock(&state.clock, user).await.unwrap(); + let user = repo.user().deactivate(&state.clock, user).await.unwrap(); repo.save().await.unwrap(); // Provision the user on the homeserver @@ -187,11 +175,15 @@ mod tests { assert_eq!( body["data"]["attributes"]["locked_at"], - serde_json::json!(null) + serde_json::Value::Null + ); + // The user should remain deactivated + assert_eq!( + body["data"]["attributes"]["deactivated_at"], + serde_json::json!(state.clock.now()) ); - // The user should be reactivated on the homeserver let mx_user = state.homeserver_connection.query_user(&mxid).await.unwrap(); - assert!(!mx_user.deactivated); + assert!(mx_user.deactivated); } #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] diff --git a/crates/handlers/src/graphql/mutations/user.rs b/crates/handlers/src/graphql/mutations/user.rs index a403d95ce..26352db81 100644 --- a/crates/handlers/src/graphql/mutations/user.rs +++ b/crates/handlers/src/graphql/mutations/user.rs @@ -563,7 +563,7 @@ impl UserMutations { Ok(LockUserPayload::Locked(user)) } - /// Unlock a user. This is only available to administrators. + /// Unlock and reactivate a user. This is only available to administrators. async fn unlock_user( &self, ctx: &Context<'_>, @@ -585,11 +585,12 @@ impl UserMutations { return Ok(UnlockUserPayload::NotFound); }; - // Call the homeserver synchronously to unlock the user + // Call the homeserver synchronously to reactivate the user let mxid = matrix.mxid(&user.username); matrix.reactivate_user(&mxid).await?; - // Now unlock the user in our database + // Now reactivate & unlock the user in our database + let user = repo.user().reactivate(user).await?; let user = repo.user().unlock(user).await?; repo.save().await?; diff --git a/crates/storage-pg/.sqlx/query-98a5491eb5f10997ac1f3718c835903ac99d9bb8ca4d79c908b25a6d1209b9b1.json b/crates/storage-pg/.sqlx/query-98a5491eb5f10997ac1f3718c835903ac99d9bb8ca4d79c908b25a6d1209b9b1.json new file mode 100644 index 000000000..75f013b53 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-98a5491eb5f10997ac1f3718c835903ac99d9bb8ca4d79c908b25a6d1209b9b1.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET deactivated_at = NULL\n WHERE user_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "98a5491eb5f10997ac1f3718c835903ac99d9bb8ca4d79c908b25a6d1209b9b1" +} diff --git a/crates/storage-pg/src/user/mod.rs b/crates/storage-pg/src/user/mod.rs index 14957ba5f..6d03e9bf7 100644 --- a/crates/storage-pg/src/user/mod.rs +++ b/crates/storage-pg/src/user/mod.rs @@ -379,7 +379,40 @@ impl UserRepository for PgUserRepository<'_> { DatabaseError::ensure_affected_rows(&res, 1)?; - user.deactivated_at = Some(user.created_at); + user.deactivated_at = Some(deactivated_at); + + Ok(user) + } + + #[tracing::instrument( + name = "db.user.reactivate", + skip_all, + fields( + db.query.text, + %user.id, + ), + err, + )] + async fn reactivate(&mut self, mut user: User) -> Result { + if user.deactivated_at.is_none() { + return Ok(user); + } + + let res = sqlx::query!( + r#" + UPDATE users + SET deactivated_at = NULL + WHERE user_id = $1 + "#, + Uuid::from(user.id), + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + user.deactivated_at = None; Ok(user) } diff --git a/crates/storage/src/user/mod.rs b/crates/storage/src/user/mod.rs index 64b1d6d79..f864157b1 100644 --- a/crates/storage/src/user/mod.rs +++ b/crates/storage/src/user/mod.rs @@ -244,6 +244,19 @@ pub trait UserRepository: Send + Sync { /// Returns [`Self::Error`] if the underlying repository fails async fn deactivate(&mut self, clock: &dyn Clock, user: User) -> Result; + /// Reactivate a [`User`] + /// + /// Returns the reactivated [`User`] + /// + /// # Parameters + /// + /// * `user`: The [`User`] to reactivate + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn reactivate(&mut self, user: User) -> Result; + /// Set whether a [`User`] can request admin /// /// Returns the [`User`] with the new `can_request_admin` value @@ -315,6 +328,7 @@ repository_impl!(UserRepository: async fn lock(&mut self, clock: &dyn Clock, user: User) -> Result; async fn unlock(&mut self, user: User) -> Result; async fn deactivate(&mut self, clock: &dyn Clock, user: User) -> Result; + async fn reactivate(&mut self, user: User) -> Result; async fn set_can_request_admin( &mut self, user: User, diff --git a/crates/tasks/src/user.rs b/crates/tasks/src/user.rs index b5f64dd42..245733aa5 100644 --- a/crates/tasks/src/user.rs +++ b/crates/tasks/src/user.rs @@ -41,14 +41,7 @@ impl RunnableJob for DeactivateUserJob { .context("User not found") .map_err(JobError::fail)?; - // Let's first lock & deactivate the user - let user = repo - .user() - .lock(clock, user) - .await - .context("Failed to lock user") - .map_err(JobError::retry)?; - + // Let's first deactivate the user let user = repo .user() .deactivate(clock, user) @@ -137,9 +130,13 @@ impl RunnableJob for ReactivateUserJob { .await .map_err(JobError::retry)?; - // We want to unlock the user from our side only once it has been reactivated on - // the homeserver - let _user = repo.user().unlock(user).await.map_err(JobError::retry)?; + // We want to reactivate the user from our side only once it has been + // reactivated on the homeserver + let _user = repo + .user() + .reactivate(user) + .await + .map_err(JobError::retry)?; repo.save().await.map_err(JobError::retry)?; Ok(()) diff --git a/docs/api/spec.json b/docs/api/spec.json index 0082ea37c..b30155b96 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -1345,7 +1345,7 @@ "user" ], "summary": "Deactivate a user", - "description": "Calling this endpoint will lock and deactivate the user, preventing them from doing any action.\nThis invalidates any existing session, and will ask the homeserver to make them leave all rooms.", + "description": "Calling this endpoint will deactivate the user, preventing them from doing any action.\nThis invalidates any existing session, and will ask the homeserver to make them leave all rooms.", "operationId": "deactivateUser", "parameters": [ { @@ -1409,6 +1409,76 @@ } } }, + "/api/admin/v1/users/{id}/reactivate": { + "post": { + "tags": [ + "user" + ], + "summary": "Reactivate a user", + "description": "Calling this endpoint will reactivate a deactivated user.\nThis DOES NOT unlock a locked user, which is still prevented from doing any action until it is explicitly unlocked.", + "operationId": "reactivateUser", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "User was reactivated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_User" + }, + "example": { + "data": { + "type": "user", + "id": "01040G2081040G2081040G2081", + "attributes": { + "username": "alice", + "created_at": "1970-01-01T00:00:00Z", + "locked_at": null, + "deactivated_at": null, + "admin": false + }, + "links": { + "self": "/api/admin/v1/users/01040G2081040G2081040G2081" + } + }, + "links": { + "self": "/api/admin/v1/users/01040G2081040G2081040G2081/reactivate" + } + } + } + } + }, + "404": { + "description": "User ID not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, "/api/admin/v1/users/{id}/lock": { "post": { "tags": [ @@ -1485,6 +1555,7 @@ "user" ], "summary": "Unlock a user", + "description": "Calling this endpoint will lift restrictions on user actions that had imposed by locking.\nThis DOES NOT reactivate a deactivated user, which will remain unavailable until it is explicitly reactivated.", "operationId": "unlockUser", "parameters": [ { diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 0e71a519d..99da32010 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -886,7 +886,7 @@ type Mutation { """ lockUser(input: LockUserInput!): LockUserPayload! """ - Unlock a user. This is only available to administrators. + Unlock and reactivate a user. This is only available to administrators. """ unlockUser(input: UnlockUserInput!): UnlockUserPayload! """ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index ff482af6c..b6f357170 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -604,7 +604,7 @@ export type Mutation = { setPrimaryEmail: SetPrimaryEmailPayload; /** Start a new email authentication flow */ startEmailAuthentication: StartEmailAuthenticationPayload; - /** Unlock a user. This is only available to administrators. */ + /** Unlock and reactivate a user. This is only available to administrators. */ unlockUser: UnlockUserPayload; };