diff --git a/crates/handlers/src/admin/model.rs b/crates/handlers/src/admin/model.rs index c3d7fd588..d47655f4c 100644 --- a/crates/handlers/src/admin/model.rs +++ b/crates/handlers/src/admin/model.rs @@ -15,6 +15,7 @@ use mas_data_model::{ session::{PersonalSession as DataModelPersonalSession, PersonalSessionOwner}, }, }; +use mas_router::UrlBuilder; use oauth2_types::requests::GrantType; use schemars::JsonSchema; use serde::Serialize; @@ -631,6 +632,14 @@ pub struct UserRegistrationToken { /// Whether the token is valid valid: bool, + /// A username imposed on the registering user. If null, the user can + /// choose their own username. + username: Option, + + /// An email address imposed on the registering user. If null, the user + /// can choose their own email. + email: Option, + /// Maximum number of times this token can be used usage_limit: Option, @@ -648,20 +657,32 @@ pub struct UserRegistrationToken { /// When the token was revoked. If null, the token is not revoked. revoked_at: Option>, + + /// URL to share with the user to register with this token + invite_url: Url, } impl UserRegistrationToken { - pub fn new(token: mas_data_model::UserRegistrationToken, now: DateTime) -> Self { + pub fn new( + token: mas_data_model::UserRegistrationToken, + now: DateTime, + url_builder: &UrlBuilder, + ) -> Self { + let invite_url = url_builder + .absolute_url_for(&mas_router::Register::default().with_token(token.token.clone())); Self { id: token.id, valid: token.is_valid(now), token: token.token, + username: token.username, + email: token.email, usage_limit: token.usage_limit, times_used: token.times_used, created_at: token.created_at, last_used_at: token.last_used_at, expires_at: token.expires_at, revoked_at: token.revoked_at, + invite_url, } } } @@ -683,23 +704,29 @@ impl UserRegistrationToken { id: Ulid::from_bytes([0x01; 16]), token: "abc123def456".to_owned(), valid: true, + username: None, + email: None, usage_limit: Some(10), times_used: 5, created_at: DateTime::default(), last_used_at: Some(DateTime::default()), expires_at: Some(DateTime::default() + chrono::Duration::days(30)), revoked_at: None, + invite_url: Url::parse("https://example.com/register?token=abc123def456").unwrap(), }, Self { id: Ulid::from_bytes([0x02; 16]), token: "xyz789abc012".to_owned(), valid: false, + username: None, + email: None, usage_limit: None, times_used: 0, created_at: DateTime::default(), last_used_at: None, expires_at: None, revoked_at: Some(DateTime::default()), + invite_url: Url::parse("https://example.com/register?token=xyz789abc012").unwrap(), }, ] } diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index 730403cda..b3e4a1540 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -15,6 +15,7 @@ use axum::extract::{FromRef, FromRequestParts}; use mas_data_model::{AppVersion, BoxRng, SiteConfig}; use mas_matrix::HomeserverConnection; use mas_policy::PolicyFactory; +use mas_router::UrlBuilder; use super::call_context::CallContext; use crate::passwords::PasswordManager; @@ -41,6 +42,7 @@ where SiteConfig: FromRef, AppVersion: FromRef, Arc: FromRef, + UrlBuilder: FromRef, BoxRng: FromRequestParts, CallContext: FromRequestParts, { diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/add.rs b/crates/handlers/src/admin/v1/user_registration_tokens/add.rs index 4b9c3cfe0..58812d520 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/add.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/add.rs @@ -6,11 +6,12 @@ // Please see LICENSE files in the repository root for full details. use aide::{NoApi, OperationIo, transform::TransformOperation}; -use axum::{Json, response::IntoResponse}; +use axum::{Json, extract::State, response::IntoResponse}; use chrono::{DateTime, Utc}; use hyper::StatusCode; use mas_axum_utils::record_error; use mas_data_model::BoxRng; +use mas_router::UrlBuilder; use rand::distr::{Alphanumeric, SampleString}; use schemars::JsonSchema; use serde::Deserialize; @@ -89,6 +90,7 @@ pub async fn handler( CallContext { mut repo, clock, .. }: CallContext, + State(url_builder): State, NoApi(mut rng): NoApi, Json(params): Json, ) -> Result<(StatusCode, Json>), RouteError> { @@ -123,6 +125,7 @@ pub async fn handler( Json(SingleResponse::new_canonical(UserRegistrationToken::new( registration_token, clock.now(), + &url_builder, ))), )) } @@ -159,12 +162,15 @@ mod tests { "attributes": { "token": "test_token_123", "valid": true, + "username": null, + "email": null, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=test_token_123" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" @@ -177,6 +183,27 @@ mod tests { "#); } + /// The token travels in the query string of the invite URL, so one with + /// reserved characters comes back percent-encoded + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_create_invite_url_encoding(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/user-registration-tokens") + .bearer(&token) + .json(serde_json::json!({ "token": "a token&more" })); + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let body: serde_json::Value = response.json(); + + assert_eq!( + body["data"]["attributes"]["invite_url"], + "https://example.com/register?token=a+token%26more" + ); + } + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_create_auto_token(pool: PgPool) { setup(); @@ -201,12 +228,15 @@ mod tests { "attributes": { "token": "42oTpLoieH5I", "valid": true, + "username": null, + "email": null, "usage_limit": 1, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=42oTpLoieH5I" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0QMGC989M0XSFVF2X" @@ -244,12 +274,15 @@ mod tests { "attributes": { "token": "test_token_123", "valid": true, + "username": null, + "email": null, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=test_token_123" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/get.rs b/crates/handlers/src/admin/v1/user_registration_tokens/get.rs index 828ad849f..d64cac079 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/get.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/get.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2025 New Vector Ltd. // Copyright 2025 The Matrix.org Foundation C.I.C. // @@ -5,9 +6,10 @@ // Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; -use axum::{Json, response::IntoResponse}; +use axum::{Json, extract::State, response::IntoResponse}; use hyper::StatusCode; use mas_axum_utils::record_error; +use mas_router::UrlBuilder; use ulid::Ulid; use crate::{ @@ -67,6 +69,7 @@ pub async fn handler( CallContext { mut repo, clock, .. }: CallContext, + State(url_builder): State, id: UlidPathParam, ) -> Result>, RouteError> { let token = repo @@ -76,7 +79,7 @@ pub async fn handler( .ok_or(RouteError::NotFound(*id))?; Ok(Json(SingleResponse::new_canonical( - UserRegistrationToken::new(token, clock.now()), + UserRegistrationToken::new(token, clock.now(), &url_builder), ))) } @@ -129,12 +132,15 @@ mod tests { "attributes": { "token": "test_token_123", "valid": true, + "username": null, + "email": null, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=test_token_123" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/list.rs b/crates/handlers/src/admin/v1/user_registration_tokens/list.rs index cbbd119fd..55ff3c306 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/list.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/list.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2025 New Vector Ltd. // Copyright 2025 The Matrix.org Foundation C.I.C. // @@ -5,11 +6,12 @@ // Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; -use axum::{Json, response::IntoResponse}; +use axum::{Json, extract::State, response::IntoResponse}; use axum_extra::extract::{Query, QueryRejection}; use axum_macros::FromRequestParts; use hyper::StatusCode; use mas_axum_utils::record_error; +use mas_router::UrlBuilder; use mas_storage::{Page, user::UserRegistrationTokenFilter}; use schemars::JsonSchema; use serde::Deserialize; @@ -135,6 +137,7 @@ pub async fn handler( CallContext { mut repo, clock, .. }: CallContext, + State(url_builder): State, Pagination(pagination, include_count): Pagination, params: FilterParams, ) -> Result>, RouteError> { @@ -165,7 +168,7 @@ pub async fn handler( .user_registration_token() .list(filter, pagination) .await? - .map(|token| UserRegistrationToken::new(token, now)); + .map(|token| UserRegistrationToken::new(token, now, &url_builder)); let count = repo.user_registration_token().count(filter).await?; PaginatedResponse::for_page(page, pagination, Some(count), &base) } @@ -174,7 +177,7 @@ pub async fn handler( .user_registration_token() .list(filter, pagination) .await? - .map(|token| UserRegistrationToken::new(token, now)); + .map(|token| UserRegistrationToken::new(token, now, &url_builder)); PaginatedResponse::for_page(page, pagination, None, &base) } IncludeCount::Only => { @@ -318,12 +321,15 @@ mod tests { "attributes": { "token": "token_expired", "valid": false, + "username": null, + "email": null, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": "2022-01-15T14:40:00Z", - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_expired" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" @@ -340,12 +346,15 @@ mod tests { "attributes": { "token": "token_used", "valid": true, + "username": null, + "email": null, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", "last_used_at": "2022-01-16T14:40:00Z", "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_used" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" @@ -362,12 +371,15 @@ mod tests { "attributes": { "token": "token_revoked", "valid": false, + "username": null, + "email": null, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": "2022-01-16T14:40:00Z" + "revoked_at": "2022-01-16T14:40:00Z", + "invite_url": "https://example.com/register?token=token_revoked" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" @@ -384,12 +396,15 @@ mod tests { "attributes": { "token": "token_unused", "valid": true, + "username": null, + "email": null, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_unused" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" @@ -406,12 +421,15 @@ mod tests { "attributes": { "token": "token_used_revoked", "valid": false, + "username": null, + "email": null, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", "last_used_at": "2022-01-16T14:40:00Z", "expires_at": null, - "revoked_at": "2022-01-16T14:40:00Z" + "revoked_at": "2022-01-16T14:40:00Z", + "invite_url": "https://example.com/register?token=token_used_revoked" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" @@ -459,12 +477,15 @@ mod tests { "attributes": { "token": "token_used", "valid": true, + "username": null, + "email": null, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", "last_used_at": "2022-01-16T14:40:00Z", "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_used" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" @@ -481,12 +502,15 @@ mod tests { "attributes": { "token": "token_used_revoked", "valid": false, + "username": null, + "email": null, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", "last_used_at": "2022-01-16T14:40:00Z", "expires_at": null, - "revoked_at": "2022-01-16T14:40:00Z" + "revoked_at": "2022-01-16T14:40:00Z", + "invite_url": "https://example.com/register?token=token_used_revoked" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" @@ -526,12 +550,15 @@ mod tests { "attributes": { "token": "token_expired", "valid": false, + "username": null, + "email": null, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": "2022-01-15T14:40:00Z", - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_expired" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" @@ -548,12 +575,15 @@ mod tests { "attributes": { "token": "token_revoked", "valid": false, + "username": null, + "email": null, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": "2022-01-16T14:40:00Z" + "revoked_at": "2022-01-16T14:40:00Z", + "invite_url": "https://example.com/register?token=token_revoked" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" @@ -570,12 +600,15 @@ mod tests { "attributes": { "token": "token_unused", "valid": true, + "username": null, + "email": null, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_unused" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" @@ -623,12 +656,15 @@ mod tests { "attributes": { "token": "token_revoked", "valid": false, + "username": null, + "email": null, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": "2022-01-16T14:40:00Z" + "revoked_at": "2022-01-16T14:40:00Z", + "invite_url": "https://example.com/register?token=token_revoked" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" @@ -645,12 +681,15 @@ mod tests { "attributes": { "token": "token_used_revoked", "valid": false, + "username": null, + "email": null, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", "last_used_at": "2022-01-16T14:40:00Z", "expires_at": null, - "revoked_at": "2022-01-16T14:40:00Z" + "revoked_at": "2022-01-16T14:40:00Z", + "invite_url": "https://example.com/register?token=token_used_revoked" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" @@ -690,12 +729,15 @@ mod tests { "attributes": { "token": "token_expired", "valid": false, + "username": null, + "email": null, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": "2022-01-15T14:40:00Z", - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_expired" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" @@ -712,12 +754,15 @@ mod tests { "attributes": { "token": "token_used", "valid": true, + "username": null, + "email": null, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", "last_used_at": "2022-01-16T14:40:00Z", "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_used" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" @@ -734,12 +779,15 @@ mod tests { "attributes": { "token": "token_unused", "valid": true, + "username": null, + "email": null, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_unused" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" @@ -787,12 +835,15 @@ mod tests { "attributes": { "token": "token_expired", "valid": false, + "username": null, + "email": null, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": "2022-01-15T14:40:00Z", - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_expired" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" @@ -832,12 +883,15 @@ mod tests { "attributes": { "token": "token_used", "valid": true, + "username": null, + "email": null, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", "last_used_at": "2022-01-16T14:40:00Z", "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_used" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" @@ -854,12 +908,15 @@ mod tests { "attributes": { "token": "token_revoked", "valid": false, + "username": null, + "email": null, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": "2022-01-16T14:40:00Z" + "revoked_at": "2022-01-16T14:40:00Z", + "invite_url": "https://example.com/register?token=token_revoked" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" @@ -876,12 +933,15 @@ mod tests { "attributes": { "token": "token_unused", "valid": true, + "username": null, + "email": null, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_unused" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" @@ -898,12 +958,15 @@ mod tests { "attributes": { "token": "token_used_revoked", "valid": false, + "username": null, + "email": null, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", "last_used_at": "2022-01-16T14:40:00Z", "expires_at": null, - "revoked_at": "2022-01-16T14:40:00Z" + "revoked_at": "2022-01-16T14:40:00Z", + "invite_url": "https://example.com/register?token=token_used_revoked" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" @@ -951,12 +1014,15 @@ mod tests { "attributes": { "token": "token_used", "valid": true, + "username": null, + "email": null, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", "last_used_at": "2022-01-16T14:40:00Z", "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_used" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" @@ -973,12 +1039,15 @@ mod tests { "attributes": { "token": "token_unused", "valid": true, + "username": null, + "email": null, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_unused" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" @@ -1018,12 +1087,15 @@ mod tests { "attributes": { "token": "token_expired", "valid": false, + "username": null, + "email": null, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": "2022-01-15T14:40:00Z", - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_expired" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" @@ -1040,12 +1112,15 @@ mod tests { "attributes": { "token": "token_revoked", "valid": false, + "username": null, + "email": null, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": "2022-01-16T14:40:00Z" + "revoked_at": "2022-01-16T14:40:00Z", + "invite_url": "https://example.com/register?token=token_revoked" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" @@ -1062,12 +1137,15 @@ mod tests { "attributes": { "token": "token_used_revoked", "valid": false, + "username": null, + "email": null, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", "last_used_at": "2022-01-16T14:40:00Z", "expires_at": null, - "revoked_at": "2022-01-16T14:40:00Z" + "revoked_at": "2022-01-16T14:40:00Z", + "invite_url": "https://example.com/register?token=token_used_revoked" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" @@ -1117,12 +1195,15 @@ mod tests { "attributes": { "token": "token_used_revoked", "valid": false, + "username": null, + "email": null, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", "last_used_at": "2022-01-16T14:40:00Z", "expires_at": null, - "revoked_at": "2022-01-16T14:40:00Z" + "revoked_at": "2022-01-16T14:40:00Z", + "invite_url": "https://example.com/register?token=token_used_revoked" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" @@ -1170,12 +1251,15 @@ mod tests { "attributes": { "token": "token_expired", "valid": false, + "username": null, + "email": null, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": "2022-01-15T14:40:00Z", - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_expired" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" @@ -1192,12 +1276,15 @@ mod tests { "attributes": { "token": "token_used", "valid": true, + "username": null, + "email": null, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", "last_used_at": "2022-01-16T14:40:00Z", "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_used" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" @@ -1238,12 +1325,15 @@ mod tests { "attributes": { "token": "token_revoked", "valid": false, + "username": null, + "email": null, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": "2022-01-16T14:40:00Z" + "revoked_at": "2022-01-16T14:40:00Z", + "invite_url": "https://example.com/register?token=token_revoked" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" @@ -1260,12 +1350,15 @@ mod tests { "attributes": { "token": "token_unused", "valid": true, + "username": null, + "email": null, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_unused" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" @@ -1306,12 +1399,15 @@ mod tests { "attributes": { "token": "token_used_revoked", "valid": false, + "username": null, + "email": null, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", "last_used_at": "2022-01-16T14:40:00Z", "expires_at": null, - "revoked_at": "2022-01-16T14:40:00Z" + "revoked_at": "2022-01-16T14:40:00Z", + "invite_url": "https://example.com/register?token=token_used_revoked" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" @@ -1378,12 +1474,15 @@ mod tests { "attributes": { "token": "token_expired", "valid": false, + "username": null, + "email": null, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": "2022-01-15T14:40:00Z", - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_expired" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG064K8BYZXSY5G511Z" @@ -1400,12 +1499,15 @@ mod tests { "attributes": { "token": "token_used", "valid": true, + "username": null, + "email": null, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", "last_used_at": "2022-01-16T14:40:00Z", "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_used" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" @@ -1422,12 +1524,15 @@ mod tests { "attributes": { "token": "token_revoked", "valid": false, + "username": null, + "email": null, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": "2022-01-16T14:40:00Z" + "revoked_at": "2022-01-16T14:40:00Z", + "invite_url": "https://example.com/register?token=token_revoked" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG09AVTNSQFMSR34AJC" @@ -1444,12 +1549,15 @@ mod tests { "attributes": { "token": "token_unused", "valid": true, + "username": null, + "email": null, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_unused" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" @@ -1466,12 +1574,15 @@ mod tests { "attributes": { "token": "token_used_revoked", "valid": false, + "username": null, + "email": null, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", "last_used_at": "2022-01-16T14:40:00Z", "expires_at": null, - "revoked_at": "2022-01-16T14:40:00Z" + "revoked_at": "2022-01-16T14:40:00Z", + "invite_url": "https://example.com/register?token=token_used_revoked" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0S3ZJD8CXQ7F11KXN" @@ -1526,12 +1637,15 @@ mod tests { "attributes": { "token": "token_used", "valid": true, + "username": null, + "email": null, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", "last_used_at": "2022-01-16T14:40:00Z", "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_used" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG07HNEZXNQM2KNBNF6" @@ -1548,12 +1662,15 @@ mod tests { "attributes": { "token": "token_unused", "valid": true, + "username": null, + "email": null, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=token_unused" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/revoke.rs b/crates/handlers/src/admin/v1/user_registration_tokens/revoke.rs index 8a23dbf58..e4324251b 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/revoke.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/revoke.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2025 New Vector Ltd. // Copyright 2025 The Matrix.org Foundation C.I.C. // @@ -5,9 +6,10 @@ // Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; -use axum::{Json, response::IntoResponse}; +use axum::{Json, extract::State, response::IntoResponse}; use hyper::StatusCode; use mas_axum_utils::record_error; +use mas_router::UrlBuilder; use ulid::Ulid; use crate::{ @@ -76,6 +78,7 @@ pub async fn handler( CallContext { mut repo, clock, .. }: CallContext, + State(url_builder): State, id: UlidPathParam, ) -> Result>, RouteError> { let id = *id; @@ -96,7 +99,7 @@ pub async fn handler( repo.save().await?; Ok(Json(SingleResponse::new( - UserRegistrationToken::new(token, clock.now()), + UserRegistrationToken::new(token, clock.now(), &url_builder), format!("/api/admin/v1/user-registration-tokens/{id}/revoke"), ))) } diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/unrevoke.rs b/crates/handlers/src/admin/v1/user_registration_tokens/unrevoke.rs index fb862b030..a6a222862 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/unrevoke.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/unrevoke.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2025 New Vector Ltd. // Copyright 2025 The Matrix.org Foundation C.I.C. // @@ -5,9 +6,10 @@ // Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; -use axum::{Json, response::IntoResponse}; +use axum::{Json, extract::State, response::IntoResponse}; use hyper::StatusCode; use mas_axum_utils::record_error; +use mas_router::UrlBuilder; use ulid::Ulid; use crate::{ @@ -76,6 +78,7 @@ pub async fn handler( CallContext { mut repo, clock, .. }: CallContext, + State(url_builder): State, id: UlidPathParam, ) -> Result>, RouteError> { let id = *id; @@ -96,7 +99,7 @@ pub async fn handler( repo.save().await?; Ok(Json(SingleResponse::new( - UserRegistrationToken::new(token, clock.now()), + UserRegistrationToken::new(token, clock.now(), &url_builder), format!("/api/admin/v1/user-registration-tokens/{id}/unrevoke"), ))) } @@ -160,12 +163,15 @@ mod tests { "attributes": { "token": "test_token_456", "valid": true, + "username": null, + "email": null, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=test_token_456" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" diff --git a/crates/handlers/src/admin/v1/user_registration_tokens/update.rs b/crates/handlers/src/admin/v1/user_registration_tokens/update.rs index f262fded9..c8cb28826 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/update.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/update.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2025 New Vector Ltd. // Copyright 2025 The Matrix.org Foundation C.I.C. // @@ -5,10 +6,11 @@ // Please see LICENSE files in the repository root for full details. use aide::{OperationIo, transform::TransformOperation}; -use axum::{Json, response::IntoResponse}; +use axum::{Json, extract::State, response::IntoResponse}; use chrono::{DateTime, Utc}; use hyper::StatusCode; use mas_axum_utils::record_error; +use mas_router::UrlBuilder; use schemars::JsonSchema; use serde::{Deserialize, Deserializer}; use ulid::Ulid; @@ -103,6 +105,7 @@ pub async fn handler( CallContext { mut repo, clock, .. }: CallContext, + State(url_builder): State, id: UlidPathParam, Json(request): Json, ) -> Result>, RouteError> { @@ -134,7 +137,7 @@ pub async fn handler( repo.save().await?; Ok(Json(SingleResponse::new( - UserRegistrationToken::new(token, clock.now()), + UserRegistrationToken::new(token, clock.now(), &url_builder), format!("/api/admin/v1/user-registration-tokens/{id}"), ))) } @@ -198,12 +201,15 @@ mod tests { "attributes": { "token": "test_update_expiry", "valid": true, + "username": null, + "email": null, "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 + "revoked_at": null, + "invite_url": "https://example.com/register?token=test_update_expiry" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" @@ -238,12 +244,15 @@ mod tests { "attributes": { "token": "test_update_expiry", "valid": true, + "username": null, + "email": null, "usage_limit": null, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=test_update_expiry" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" @@ -304,12 +313,15 @@ mod tests { "attributes": { "token": "test_update_limit", "valid": true, + "username": null, + "email": null, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=test_update_limit" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" @@ -344,12 +356,15 @@ mod tests { "attributes": { "token": "test_update_limit", "valid": true, + "username": null, + "email": null, "usage_limit": null, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": null + "revoked_at": null, + "invite_url": "https://example.com/register?token=test_update_limit" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" @@ -412,12 +427,15 @@ mod tests { "attributes": { "token": "test_update_multiple", "valid": true, + "username": null, + "email": null, "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 + "revoked_at": null, + "invite_url": "https://example.com/register?token=test_update_multiple" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" @@ -476,12 +494,15 @@ mod tests { "attributes": { "token": "test_update_none", "valid": true, + "username": null, + "email": null, "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 + "revoked_at": null, + "invite_url": "https://example.com/register?token=test_update_none" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01FSHN9AG0MZAA6S4AF7CTV32E" diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index 4eae0e803..11ee5ede9 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -274,6 +274,17 @@ impl SimpleRoute for Logout { /// `GET|POST /register` #[derive(Default, Debug, Clone)] pub struct Register { + query: RegisterQuery, +} + +/// The query parameters of the registration page +#[derive(Default, Debug, Clone, Serialize)] +pub struct RegisterQuery { + /// The registration token an invite link deep-links with + #[serde(skip_serializing_if = "Option::is_none")] + token: Option, + + #[serde(flatten)] post_auth_action: Option, } @@ -281,32 +292,38 @@ impl Register { #[must_use] pub fn and_then(action: PostAuthAction) -> Self { Self { - post_auth_action: Some(action), + query: RegisterQuery { + token: None, + post_auth_action: Some(action), + }, } } #[must_use] pub fn and_continue_grant(data: Ulid) -> Self { - Self { - post_auth_action: Some(PostAuthAction::continue_grant(data)), - } + Self::and_then(PostAuthAction::continue_grant(data)) } #[must_use] pub fn and_continue_compat_sso_login(data: Ulid) -> Self { - Self { - post_auth_action: Some(PostAuthAction::continue_compat_sso_login(data)), - } + Self::and_then(PostAuthAction::continue_compat_sso_login(data)) + } + + /// Deep-link to the registration page with a registration token + #[must_use] + pub fn with_token(mut self, token: String) -> Self { + self.query.token = Some(token); + self } /// Get a reference to the reauth's post auth action. #[must_use] pub fn post_auth_action(&self) -> Option<&PostAuthAction> { - self.post_auth_action.as_ref() + self.query.post_auth_action.as_ref() } pub fn go_next(&self, url_builder: &UrlBuilder) -> axum::response::Redirect { - match &self.post_auth_action { + match &self.query.post_auth_action { Some(action) => action.go_next(url_builder), None => url_builder.redirect(&Index), } @@ -314,20 +331,25 @@ impl Register { } impl Route for Register { - type Query = PostAuthAction; + type Query = RegisterQuery; fn route() -> &'static str { "/register" } fn query(&self) -> Option<&Self::Query> { - self.post_auth_action.as_ref() + Some(&self.query) } } impl From> for Register { fn from(post_auth_action: Option) -> Self { - Self { post_auth_action } + Self { + query: RegisterQuery { + token: None, + post_auth_action, + }, + } } } diff --git a/crates/router/src/lib.rs b/crates/router/src/lib.rs index 0308fdfcd..6505f02a6 100644 --- a/crates/router/src/lib.rs +++ b/crates/router/src/lib.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // @@ -32,6 +33,28 @@ mod tests { ); } + #[test] + fn test_register_urls() { + assert_eq!( + Register::default().path_and_query(), + Cow::Borrowed("/register") + ); + assert_eq!( + Register::default() + .with_token("a token&more".to_owned()) + .path_and_query(), + Cow::Borrowed("/register?token=a+token%26more") + ); + assert_eq!( + Register::and_continue_grant(Ulid::nil()) + .with_token("invite".to_owned()) + .path_and_query(), + Cow::Borrowed( + "/register?token=invite&kind=continue_authorization_grant&id=00000000000000000000000000" + ) + ); + } + #[test] fn test_absolute_urls() { let base = Url::try_from("https://example.com/").unwrap(); diff --git a/docs/api/spec.json b/docs/api/spec.json index ef2580265..3b7749c05 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -3797,12 +3797,15 @@ "attributes": { "token": "abc123def456", "valid": true, + "username": null, + "email": null, "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 + "revoked_at": null, + "invite_url": "https://example.com/register?token=abc123def456" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081" @@ -3819,12 +3822,15 @@ "attributes": { "token": "xyz789abc012", "valid": false, + "username": null, + "email": null, "usage_limit": null, "times_used": 0, "created_at": "1970-01-01T00:00:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": "1970-01-01T00:00:00Z" + "revoked_at": "1970-01-01T00:00:00Z", + "invite_url": "https://example.com/register?token=xyz789abc012" }, "links": { "self": "/api/admin/v1/user-registration-tokens/02081040G2081040G2081040G2" @@ -3879,12 +3885,15 @@ "attributes": { "token": "abc123def456", "valid": true, + "username": null, + "email": null, "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 + "revoked_at": null, + "invite_url": "https://example.com/register?token=abc123def456" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081" @@ -3934,12 +3943,15 @@ "attributes": { "token": "abc123def456", "valid": true, + "username": null, + "email": null, "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 + "revoked_at": null, + "invite_url": "https://example.com/register?token=abc123def456" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081" @@ -4015,12 +4027,15 @@ "attributes": { "token": "abc123def456", "valid": true, + "username": null, + "email": null, "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 + "revoked_at": null, + "invite_url": "https://example.com/register?token=abc123def456" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081" @@ -4088,12 +4103,15 @@ "attributes": { "token": "xyz789abc012", "valid": false, + "username": null, + "email": null, "usage_limit": null, "times_used": 0, "created_at": "1970-01-01T00:00:00Z", "last_used_at": null, "expires_at": null, - "revoked_at": "1970-01-01T00:00:00Z" + "revoked_at": "1970-01-01T00:00:00Z", + "invite_url": "https://example.com/register?token=xyz789abc012" }, "links": { "self": "/api/admin/v1/user-registration-tokens/02081040G2081040G2081040G2" @@ -4178,12 +4196,15 @@ "attributes": { "token": "abc123def456", "valid": true, + "username": null, + "email": null, "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 + "revoked_at": null, + "invite_url": "https://example.com/register?token=abc123def456" }, "links": { "self": "/api/admin/v1/user-registration-tokens/01040G2081040G2081040G2081" @@ -7310,6 +7331,20 @@ "description": "Whether the token is valid", "type": "boolean" }, + "username": { + "description": "A username imposed on the registering user. If null, the user can\nchoose their own username.", + "type": [ + "string", + "null" + ] + }, + "email": { + "description": "An email address imposed on the registering user. If null, the user\ncan choose their own email.", + "type": [ + "string", + "null" + ] + }, "usage_limit": { "description": "Maximum number of times this token can be used", "type": [ @@ -7353,13 +7388,19 @@ "null" ], "format": "date-time" + }, + "invite_url": { + "description": "URL to share with the user to register with this token", + "type": "string", + "format": "uri" } }, "required": [ "token", "valid", "times_used", - "created_at" + "created_at", + "invite_url" ] }, "AddUserRegistrationTokenRequest": {