diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index be3f1922f..02586368b 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -136,6 +136,10 @@ where get_with( self::user_registration_tokens::get, self::user_registration_tokens::get_doc, + ) + .put_with( + self::user_registration_tokens::update, + self::user_registration_tokens::update_doc, ), ) .api_route( diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs b/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs index 89df0c416..3d61e10e6 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/mod.rs @@ -8,6 +8,7 @@ mod get; mod list; mod revoke; mod unrevoke; +mod update; pub use self::{ add::{doc as add_doc, handler as add}, @@ -15,4 +16,5 @@ pub use self::{ list::{doc as list_doc, handler as list}, revoke::{doc as revoke_doc, handler as revoke}, unrevoke::{doc as unrevoke_doc, handler as unrevoke}, + update::{doc as update_doc, handler as update}, }; diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/update.rs b/crates/handlers/src/admin/v1/user_registration_tokens/update.rs new file mode 100644 index 000000000..444c7ae6b --- /dev/null +++ b/crates/handlers/src/admin/v1/user_registration_tokens/update.rs @@ -0,0 +1,511 @@ +// Copyright 2025 The Matrix.org Foundation C.I.C. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use aide::{OperationIo, transform::TransformOperation}; +use axum::{Json, response::IntoResponse}; +use chrono::{DateTime, Utc}; +use hyper::StatusCode; +use mas_axum_utils::record_error; +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer}; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{Resource, UserRegistrationToken}, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +// Any value that is present is considered Some value, including null. +fn deserialize_some<'de, T, D>(deserializer: D) -> Result, D::Error> +where + T: Deserialize<'de>, + D: Deserializer<'de>, +{ + Deserialize::deserialize(deserializer).map(Some) +} + +/// # JSON payload for the `PUT /api/admin/v1/user-registration-tokens/{id}` endpoint +#[derive(Deserialize, JsonSchema)] +#[serde(rename = "EditUserRegistrationTokenRequest")] +pub struct Request { + /// New expiration date for the token, or null to remove expiration + #[serde( + skip_serializing_if = "Option::is_none", + default, + deserialize_with = "deserialize_some" + )] + #[expect(clippy::option_option)] + expires_at: Option>>, + + /// New usage limit for the token, or null to remove the limit + #[expect(clippy::option_option)] + #[serde( + skip_serializing_if = "Option::is_none", + default, + deserialize_with = "deserialize_some" + )] + usage_limit: Option>, +} + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("Registration token with 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(_)); + let status = match self { + Self::Internal(_) => 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("updateUserRegistrationToken") + .summary("Update a user registration token") + .description("Update properties of a user registration token such as expiration and usage limit. To set a field to null (removing the limit/expiration), include the field with a null value. To leave a field unchanged, omit it from the request body.") + .tag("user-registration-token") + .response_with::<200, Json>, _>(|t| { + // Get the valid token sample + let [valid_token, _] = UserRegistrationToken::samples(); + let id = valid_token.id(); + let response = SingleResponse::new(valid_token, format!("/api/admin/v1/user-registration-tokens/{id}")); + t.description("Registration token was updated").example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("Registration token was not found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.user_registration_tokens.update", skip_all)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + id: UlidPathParam, + Json(request): Json, +) -> Result>, RouteError> { + let id = *id; + + // Get the token + let mut token = repo + .user_registration_token() + .lookup(id) + .await? + .ok_or(RouteError::NotFound(id))?; + + // Update expiration if present in the request + if let Some(expires_at) = request.expires_at { + token = repo + .user_registration_token() + .set_expiry(token, expires_at) + .await?; + } + + // Update usage limit if present in the request + if let Some(usage_limit) = request.usage_limit { + token = repo + .user_registration_token() + .set_usage_limit(token, usage_limit) + .await?; + } + + repo.save().await?; + + Ok(Json(SingleResponse::new( + UserRegistrationToken::new(token, clock.now()), + format!("/api/admin/v1/user-registration-tokens/{id}"), + ))) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use hyper::{Request, StatusCode}; + use mas_storage::Clock as _; + use serde_json::json; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_update_expiry(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut repo = state.repository().await.unwrap(); + + // Create a token without expiry + let registration_token = repo + .user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "test_update_expiry".to_owned(), + None, + None, + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Update with an expiry date + let future_date = state.clock.now() + Duration::days(30); + let request = Request::put(format!( + "/api/admin/v1/user-registration-tokens/{}", + registration_token.id + )) + .bearer(&token) + .json(json!({ + "expires_at": future_date + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // Verify expiry was updated + insta::assert_json_snapshot!(body, @r#" + { + "data": { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "test_update_expiry", + "valid": true, + "usage_limit": null, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": "2022-02-15T14:40:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + "#); + + // Now remove the expiry + let request = Request::put(format!( + "/api/admin/v1/user-registration-tokens/{}", + registration_token.id + )) + .bearer(&token) + .json(json!({ + "expires_at": null + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // Verify expiry was removed + insta::assert_json_snapshot!(body, @r#" + { + "data": { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "test_update_expiry", + "valid": true, + "usage_limit": null, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_update_usage_limit(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut repo = state.repository().await.unwrap(); + + // Create a token with usage limit + let registration_token = repo + .user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "test_update_limit".to_owned(), + Some(5), + None, + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Update the usage limit + let request = Request::put(format!( + "/api/admin/v1/user-registration-tokens/{}", + registration_token.id + )) + .bearer(&token) + .json(json!({ + "usage_limit": 10 + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // Verify usage limit was updated + insta::assert_json_snapshot!(body, @r#" + { + "data": { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "test_update_limit", + "valid": true, + "usage_limit": 10, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + "#); + + // Now remove the usage limit + let request = Request::put(format!( + "/api/admin/v1/user-registration-tokens/{}", + registration_token.id + )) + .bearer(&token) + .json(json!({ + "usage_limit": null + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // Verify usage limit was removed + insta::assert_json_snapshot!(body, @r#" + { + "data": { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "test_update_limit", + "valid": true, + "usage_limit": null, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_update_multiple_fields(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut repo = state.repository().await.unwrap(); + + // Create a token + let registration_token = repo + .user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "test_update_multiple".to_owned(), + None, + None, + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Update both fields + let future_date = state.clock.now() + Duration::days(30); + let request = Request::put(format!( + "/api/admin/v1/user-registration-tokens/{}", + registration_token.id + )) + .bearer(&token) + .json(json!({ + "expires_at": future_date, + "usage_limit": 20 + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // Both fields were updated + insta::assert_json_snapshot!(body, @r#" + { + "data": { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "test_update_multiple", + "valid": true, + "usage_limit": 20, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": "2022-02-15T14:40:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_update_no_fields(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let mut repo = state.repository().await.unwrap(); + + // Create a token + let registration_token = repo + .user_registration_token() + .add( + &mut state.rng(), + &state.clock, + "test_update_none".to_owned(), + Some(5), + Some(state.clock.now() + Duration::days(30)), + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + // Send empty update + let request = Request::put(format!( + "/api/admin/v1/user-registration-tokens/{}", + registration_token.id + )) + .bearer(&token) + .json(json!({})); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + + // It shouldn't have updated the token + insta::assert_json_snapshot!(body, @r#" + { + "data": { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "test_update_none", + "valid": true, + "usage_limit": 5, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": "2022-02-15T14:40:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" + } + } + "#); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_update_unknown_token(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + // Try to update a non-existent token + let request = + Request::put("/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081") + .bearer(&token) + .json(json!({ + "usage_limit": 5 + })); + + 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"], + "Registration token with ID 01040G2081040G2081040G2081 not found" + ); + } +} diff --git a/crates/storage-pg/.sqlx/query-21b9e39ffd89de288305765c339a991d2471667cf2981770447cde6fd025fbb7.json b/crates/storage-pg/.sqlx/query-21b9e39ffd89de288305765c339a991d2471667cf2981770447cde6fd025fbb7.json new file mode 100644 index 000000000..3b3f65b29 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-21b9e39ffd89de288305765c339a991d2471667cf2981770447cde6fd025fbb7.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_registration_tokens\n SET expires_at = $2\n WHERE user_registration_token_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "21b9e39ffd89de288305765c339a991d2471667cf2981770447cde6fd025fbb7" +} diff --git a/crates/storage-pg/.sqlx/query-7e414c29745cf5c85fa4e7cb5d661b07f43ab168956470d120166ed7eab631d9.json b/crates/storage-pg/.sqlx/query-7e414c29745cf5c85fa4e7cb5d661b07f43ab168956470d120166ed7eab631d9.json new file mode 100644 index 000000000..275a08952 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-7e414c29745cf5c85fa4e7cb5d661b07f43ab168956470d120166ed7eab631d9.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_registration_tokens\n SET usage_limit = $2\n WHERE user_registration_token_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "7e414c29745cf5c85fa4e7cb5d661b07f43ab168956470d120166ed7eab631d9" +} diff --git a/crates/storage-pg/src/user/registration_token.rs b/crates/storage-pg/src/user/registration_token.rs index 1f5a7231c..f7a9ab54e 100644 --- a/crates/storage-pg/src/user/registration_token.rs +++ b/crates/storage-pg/src/user/registration_token.rs @@ -578,6 +578,79 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> { Ok(token) } + + #[tracing::instrument( + name = "db.user_registration_token.set_expiry", + skip_all, + fields( + db.query.text, + user_registration_token.id = %token.id, + ), + err, + )] + async fn set_expiry( + &mut self, + mut token: UserRegistrationToken, + expires_at: Option>, + ) -> Result { + let res = sqlx::query!( + r#" + UPDATE user_registration_tokens + SET expires_at = $2 + WHERE user_registration_token_id = $1 + "#, + Uuid::from(token.id), + expires_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + token.expires_at = expires_at; + + Ok(token) + } + + #[tracing::instrument( + name = "db.user_registration_token.set_usage_limit", + skip_all, + fields( + db.query.text, + user_registration_token.id = %token.id, + ), + err, + )] + async fn set_usage_limit( + &mut self, + mut token: UserRegistrationToken, + usage_limit: Option, + ) -> Result { + let usage_limit_i32 = usage_limit + .map(i32::try_from) + .transpose() + .map_err(DatabaseError::to_invalid_operation)?; + + let res = sqlx::query!( + r#" + UPDATE user_registration_tokens + SET usage_limit = $2 + WHERE user_registration_token_id = $1 + "#, + Uuid::from(token.id), + usage_limit_i32, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + token.usage_limit = usage_limit; + + Ok(token) + } } #[cfg(test)] @@ -637,6 +710,93 @@ mod tests { assert!(page.edges.iter().any(|t| t.id == unrevoked_token.id)); } + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_set_expiry(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + // Create a token without expiry + let token = repo + .user_registration_token() + .add(&mut rng, &clock, "test_token_expiry".to_owned(), None, None) + .await + .unwrap(); + + // Verify it has no expiration + assert!(token.expires_at.is_none()); + + // Set an expiration + let future_time = clock.now() + Duration::days(30); + let updated_token = repo + .user_registration_token() + .set_expiry(token, Some(future_time)) + .await + .unwrap(); + + // Verify expiration is set + assert_eq!(updated_token.expires_at, Some(future_time)); + + // Remove the expiration + let final_token = repo + .user_registration_token() + .set_expiry(updated_token, None) + .await + .unwrap(); + + // Verify expiration is removed + assert!(final_token.expires_at.is_none()); + } + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_set_usage_limit(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + // Create a token without usage limit + let token = repo + .user_registration_token() + .add(&mut rng, &clock, "test_token_limit".to_owned(), None, None) + .await + .unwrap(); + + // Verify it has no usage limit + assert!(token.usage_limit.is_none()); + + // Set a usage limit + let updated_token = repo + .user_registration_token() + .set_usage_limit(token, Some(5)) + .await + .unwrap(); + + // Verify usage limit is set + assert_eq!(updated_token.usage_limit, Some(5)); + + // Change the usage limit + let changed_token = repo + .user_registration_token() + .set_usage_limit(updated_token, Some(10)) + .await + .unwrap(); + + // Verify usage limit is changed + assert_eq!(changed_token.usage_limit, Some(10)); + + // Remove the usage limit + let final_token = repo + .user_registration_token() + .set_usage_limit(changed_token, None) + .await + .unwrap(); + + // Verify usage limit is removed + assert!(final_token.usage_limit.is_none()); + } + #[sqlx::test(migrator = "crate::MIGRATOR")] async fn test_list_and_count(pool: PgPool) { let mut rng = ChaChaRng::seed_from_u64(42); diff --git a/crates/storage/src/user/registration_token.rs b/crates/storage/src/user/registration_token.rs index 196a2238b..e3913b5d8 100644 --- a/crates/storage/src/user/registration_token.rs +++ b/crates/storage/src/user/registration_token.rs @@ -210,6 +210,39 @@ pub trait UserRegistrationTokenRepository: Send + Sync { token: UserRegistrationToken, ) -> Result; + /// Set the expiration time of a [`UserRegistrationToken`] + /// + /// # Parameters + /// + /// * `token`: The [`UserRegistrationToken`] to update + /// * `expires_at`: The new expiration time, or `None` to remove the + /// expiration + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn set_expiry( + &mut self, + token: UserRegistrationToken, + expires_at: Option>, + ) -> Result; + + /// Set the usage limit of a [`UserRegistrationToken`] + /// + /// # Parameters + /// + /// * `token`: The [`UserRegistrationToken`] to update + /// * `usage_limit`: The new usage limit, or `None` to remove the limit + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn set_usage_limit( + &mut self, + token: UserRegistrationToken, + usage_limit: Option, + ) -> Result; + /// List [`UserRegistrationToken`]s based on the provided filter /// /// Returns a list of matching [`UserRegistrationToken`]s @@ -267,6 +300,16 @@ repository_impl!(UserRegistrationTokenRepository: &mut self, token: UserRegistrationToken, ) -> Result; + async fn set_expiry( + &mut self, + token: UserRegistrationToken, + expires_at: Option>, + ) -> Result; + async fn set_usage_limit( + &mut self, + token: UserRegistrationToken, + usage_limit: Option, + ) -> Result; async fn list( &mut self, filter: UserRegistrationTokenFilter, diff --git a/docs/api/spec.json b/docs/api/spec.json index ccea5a4c9..0082ea37c 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -2415,6 +2415,87 @@ } } } + }, + "put": { + "tags": [ + "user-registration-token" + ], + "summary": "Update a user registration token", + "description": "Update properties of a user registration token such as expiration and usage limit. To set a field to null (removing the limit/expiration), include the field with a null value. To leave a field unchanged, omit it from the request body.", + "operationId": "updateUserRegistrationToken", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditUserRegistrationTokenRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Registration token was updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_UserRegistrationToken" + }, + "example": { + "data": { + "type": "user-registration_token", + "id": "01040G2081040G2081040G2081", + "attributes": { + "token": "abc123def456", + "valid": true, + "usage_limit": 10, + "times_used": 5, + "created_at": "1970-01-01T00:00:00Z", + "last_used_at": "1970-01-01T00:00:00Z", + "expires_at": "1970-01-31T00:00:00Z", + "revoked_at": null + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081" + } + }, + "links": { + "self": "/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081" + } + } + } + } + }, + "404": { + "description": "Registration token was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Registration token with ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } } }, "/api/admin/v1/user-registration-tokens/{id}/revoke": { @@ -4228,6 +4309,25 @@ } } }, + "EditUserRegistrationTokenRequest": { + "title": "JSON payload for the `PUT /api/admin/v1/user-registration-tokens/{id}` endpoint", + "type": "object", + "properties": { + "expires_at": { + "description": "New expiration date for the token, or null to remove expiration", + "type": "string", + "format": "date-time", + "nullable": true + }, + "usage_limit": { + "description": "New usage limit for the token, or null to remove the limit", + "type": "integer", + "format": "uint32", + "minimum": 0.0, + "nullable": true + } + } + }, "UpstreamOAuthLinkFilter": { "type": "object", "properties": {