New admin API endpoint to reactivate users & align what lock/unlock/deactivate does in the admin API (#4768)

This commit is contained in:
Quentin Gliech
2025-07-17 09:11:32 +02:00
committed by GitHub
15 changed files with 439 additions and 64 deletions
+18 -6
View File
@@ -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?;
+4
View File
@@ -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),
@@ -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<SingleResponse<User>>, _>(|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<Json<SingleResponse<User>>, 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,10 +133,16 @@ 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"]["deactivated_at"],
serde_json::json!(state.clock.now())
);
// Deactivating the user should not lock it
assert_eq!(
body["data"]["attributes"]["locked_at"],
serde_json::json!(state.clock.now())
serde_json::Value::Null
);
// Make sure to run the jobs in the queue
@@ -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,12 +203,18 @@ 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
assert_ne!(
body["data"]["attributes"]["locked_at"],
// 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"],
);
// Make sure to run the jobs in the queue
state.run_jobs_in_queue().await;
+6 -4
View File
@@ -72,15 +72,13 @@ pub async fn handler(
id: UlidPathParam,
) -> Result<Json<SingleResponse<User>>, 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")]
@@ -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},
@@ -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<ErrorResponse>")]
pub enum RouteError {
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
#[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<SingleResponse<User>>, _>(|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<Arc<dyn HomeserverConnection>>,
id: UlidPathParam,
) -> Result<Json<SingleResponse<User>>, 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"
);
}
}
+15 -23
View File
@@ -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<dyn std::error::Error + Send + Sync + 'static>),
#[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<SingleResponse<User>>, _>(|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<Arc<dyn HomeserverConnection>>,
id: UlidPathParam,
) -> Result<Json<SingleResponse<User>>, 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")]
@@ -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?;
@@ -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"
}
+34 -1
View File
@@ -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<User, Self::Error> {
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)
}
+14
View File
@@ -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<User, Self::Error>;
/// 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<User, Self::Error>;
/// 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<User, Self::Error>;
async fn unlock(&mut self, user: User) -> Result<User, Self::Error>;
async fn deactivate(&mut self, clock: &dyn Clock, user: User) -> Result<User, Self::Error>;
async fn reactivate(&mut self, user: User) -> Result<User, Self::Error>;
async fn set_can_request_admin(
&mut self,
user: User,
+8 -11
View File
@@ -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(())
+72 -1
View File
@@ -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": [
{
+1 -1
View File
@@ -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!
"""
+1 -1
View File
@@ -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;
};