diff --git a/crates/cli/src/commands/manage.rs b/crates/cli/src/commands/manage.rs index e66a41c0e..4d7ed623a 100644 --- a/crates/cli/src/commands/manage.rs +++ b/crates/cli/src/commands/manage.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // @@ -515,6 +516,7 @@ impl Options { expires_at, None, None, + false, ) .await?; diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs index f85cfed48..c786f4232 100644 --- a/crates/data-model/src/users.rs +++ b/crates/data-model/src/users.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // @@ -231,6 +232,7 @@ pub struct UserRegistrationToken { pub token: String, pub username: Option, pub email: Option, + pub passwordless: bool, pub usage_limit: Option, pub times_used: u32, pub created_at: DateTime, diff --git a/crates/handlers/src/admin/model.rs b/crates/handlers/src/admin/model.rs index d9520a930..b3706e994 100644 --- a/crates/handlers/src/admin/model.rs +++ b/crates/handlers/src/admin/model.rs @@ -640,6 +640,10 @@ pub struct UserRegistrationToken { /// can choose their own email. email: Option, + /// Whether registering with this token skips the password step. The user's + /// identity is then established by verifying their email address. + passwordless: bool, + /// Maximum number of times this token can be used usage_limit: Option, @@ -676,6 +680,7 @@ impl UserRegistrationToken { token: token.token, username: token.username, email: token.email, + passwordless: token.passwordless, usage_limit: token.usage_limit, times_used: token.times_used, created_at: token.created_at, @@ -706,6 +711,7 @@ impl UserRegistrationToken { valid: true, username: None, email: None, + passwordless: false, usage_limit: Some(10), times_used: 5, created_at: DateTime::default(), @@ -720,6 +726,7 @@ impl UserRegistrationToken { valid: false, username: None, email: None, + passwordless: false, usage_limit: None, times_used: 0, created_at: DateTime::default(), 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 2426c6d22..c8f4e9ec1 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/add.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/add.rs @@ -70,6 +70,11 @@ pub struct Request { /// An email address to impose on the registering user. If set, the user /// cannot choose their own email. email: Option, + + /// Whether registering with this token skips the password step. The user's + /// identity is then established by verifying their email address. + #[serde(default)] + passwordless: bool, } pub fn doc(operation: TransformOperation) -> TransformOperation { @@ -115,6 +120,7 @@ pub async fn handler( params.expires_at, params.username, params.email, + params.passwordless, ) .await?; @@ -164,6 +170,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -209,6 +216,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 1, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -228,6 +236,54 @@ mod tests { "#); } + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_create_passwordless(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": "invite_alice", + "username": "alice", + "email": "alice@example.com", + "passwordless": true, + })); + let response = state.request(request).await; + response.assert_status(StatusCode::CREATED); + let body: serde_json::Value = response.json(); + + assert_json_snapshot!(body, @r#" + { + "data": { + "type": "user-registration_token", + "id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "attributes": { + "token": "invite_alice", + "valid": true, + "username": "alice", + "email": "alice@example.com", + "passwordless": true, + "usage_limit": null, + "times_used": 0, + "created_at": "2022-01-16T14:40:00Z", + "last_used_at": null, + "expires_at": null, + "revoked_at": null, + "invite_url": "https://example.com/invite/invite_alice" + }, + "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_create_conflict(pool: PgPool) { setup(); @@ -255,6 +311,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", 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 e7edc64d3..48f7f8fd3 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/get.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/get.rs @@ -109,6 +109,7 @@ mod tests { None, None, None, + false, ) .await .unwrap(); @@ -134,6 +135,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", 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 8ae0da17f..085621e03 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/list.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/list.rs @@ -211,6 +211,7 @@ mod tests { None, None, None, + false, ) .await .unwrap(); @@ -226,6 +227,7 @@ mod tests { None, None, None, + false, ) .await .unwrap(); @@ -245,6 +247,7 @@ mod tests { None, None, None, + false, ) .await .unwrap(); @@ -264,6 +267,7 @@ mod tests { None, None, None, + false, ) .await .unwrap(); @@ -288,6 +292,7 @@ mod tests { Some(expires_at), None, None, + false, ) .await .unwrap(); @@ -323,6 +328,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -348,6 +354,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", @@ -373,6 +380,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -398,6 +406,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -423,6 +432,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", @@ -479,6 +489,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", @@ -504,6 +515,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", @@ -552,6 +564,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -577,6 +590,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -602,6 +616,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -658,6 +673,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -683,6 +699,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", @@ -731,6 +748,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -756,6 +774,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", @@ -781,6 +800,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -837,6 +857,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -885,6 +906,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", @@ -910,6 +932,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -935,6 +958,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -960,6 +984,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", @@ -1016,6 +1041,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", @@ -1041,6 +1067,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -1089,6 +1116,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -1114,6 +1142,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -1139,6 +1168,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", @@ -1197,6 +1227,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", @@ -1253,6 +1284,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -1278,6 +1310,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", @@ -1327,6 +1360,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -1352,6 +1386,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -1401,6 +1436,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", @@ -1476,6 +1512,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -1501,6 +1538,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", @@ -1526,6 +1564,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -1551,6 +1590,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -1576,6 +1616,7 @@ mod tests { "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", @@ -1639,6 +1680,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 1, "created_at": "2022-01-16T14:40:00Z", @@ -1664,6 +1706,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", 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 e4324251b..d8a4c7f7e 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/revoke.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/revoke.rs @@ -130,6 +130,7 @@ mod tests { None, None, None, + false, ) .await .unwrap(); @@ -169,6 +170,7 @@ mod tests { None, None, None, + false, ) .await .unwrap(); 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 86ed95360..cc5de3037 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/unrevoke.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/unrevoke.rs @@ -130,6 +130,7 @@ mod tests { None, None, None, + false, ) .await .unwrap(); @@ -165,6 +166,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -201,6 +203,7 @@ mod tests { None, None, None, + false, ) .await .unwrap(); 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 a38a3ce8e..b068ba4f5 100644 --- a/crates/handlers/src/admin/v1/user_registration_tokens/update.rs +++ b/crates/handlers/src/admin/v1/user_registration_tokens/update.rs @@ -171,6 +171,7 @@ mod tests { None, None, None, + false, ) .await .unwrap(); @@ -203,6 +204,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": null, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -246,6 +248,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": null, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -284,6 +287,7 @@ mod tests { None, None, None, + false, ) .await .unwrap(); @@ -315,6 +319,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -358,6 +363,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": null, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -396,6 +402,7 @@ mod tests { None, None, None, + false, ) .await .unwrap(); @@ -429,6 +436,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 20, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", @@ -467,6 +475,7 @@ mod tests { Some(state.clock.now() + Duration::days(30)), None, None, + false, ) .await .unwrap(); @@ -496,6 +505,7 @@ mod tests { "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 5, "times_used": 0, "created_at": "2022-01-16T14:40:00Z", diff --git a/crates/handlers/src/graphql/query/user.rs b/crates/handlers/src/graphql/query/user.rs index 98d575560..9e0846b47 100644 --- a/crates/handlers/src/graphql/query/user.rs +++ b/crates/handlers/src/graphql/query/user.rs @@ -38,6 +38,8 @@ struct RegistrationTokenInfo { username: Option, /// An email imposed by this token, if any. email: Option, + /// Whether registering with this token skips the password step. + passwordless: bool, } /// The result of a username availability check. @@ -206,6 +208,7 @@ impl UserQuery { valid: registration_token.is_valid(state.clock().now()), username: registration_token.username, email: registration_token.email, + passwordless: registration_token.passwordless, })) } diff --git a/crates/handlers/src/views/invite.rs b/crates/handlers/src/views/invite.rs index 1e3d1e0b5..0ac7fa656 100644 --- a/crates/handlers/src/views/invite.rs +++ b/crates/handlers/src/views/invite.rs @@ -61,6 +61,7 @@ mod tests { None, None, None, + false, ) .await .unwrap(); diff --git a/crates/storage-pg/.sqlx/query-1ba7c2d8929e3c7d9af7afe92015d3a07eb4609827d97b3477b020059c8a2d4f.json b/crates/storage-pg/.sqlx/query-47233b98ede94299e4a119d8accdc9dbfe25790570d3bc2ea07287bf6631bcbd.json similarity index 71% rename from crates/storage-pg/.sqlx/query-1ba7c2d8929e3c7d9af7afe92015d3a07eb4609827d97b3477b020059c8a2d4f.json rename to crates/storage-pg/.sqlx/query-47233b98ede94299e4a119d8accdc9dbfe25790570d3bc2ea07287bf6631bcbd.json index 4d4086abd..ee7f005de 100644 --- a/crates/storage-pg/.sqlx/query-1ba7c2d8929e3c7d9af7afe92015d3a07eb4609827d97b3477b020059c8a2d4f.json +++ b/crates/storage-pg/.sqlx/query-47233b98ede94299e4a119d8accdc9dbfe25790570d3bc2ea07287bf6631bcbd.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_registration_token_id,\n token,\n username,\n email,\n usage_limit,\n times_used,\n created_at,\n last_used_at,\n expires_at,\n revoked_at\n FROM user_registration_tokens\n WHERE token = $1\n ", + "query": "\n SELECT user_registration_token_id,\n token,\n username,\n email,\n passwordless,\n usage_limit,\n times_used,\n created_at,\n last_used_at,\n expires_at,\n revoked_at\n FROM user_registration_tokens\n WHERE token = $1\n ", "describe": { "columns": [ { @@ -25,31 +25,36 @@ }, { "ordinal": 4, + "name": "passwordless", + "type_info": "Bool" + }, + { + "ordinal": 5, "name": "usage_limit", "type_info": "Int4" }, { - "ordinal": 5, + "ordinal": 6, "name": "times_used", "type_info": "Int4" }, { - "ordinal": 6, + "ordinal": 7, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 7, + "ordinal": 8, "name": "last_used_at", "type_info": "Timestamptz" }, { - "ordinal": 8, + "ordinal": 9, "name": "expires_at", "type_info": "Timestamptz" }, { - "ordinal": 9, + "ordinal": 10, "name": "revoked_at", "type_info": "Timestamptz" } @@ -64,6 +69,7 @@ false, true, true, + false, true, false, false, @@ -72,5 +78,5 @@ true ] }, - "hash": "1ba7c2d8929e3c7d9af7afe92015d3a07eb4609827d97b3477b020059c8a2d4f" + "hash": "47233b98ede94299e4a119d8accdc9dbfe25790570d3bc2ea07287bf6631bcbd" } diff --git a/crates/storage-pg/.sqlx/query-8e001a260d28c537f525e35f70e5cc4ea771124820e2f6f8cfc5b958ab21eecc.json b/crates/storage-pg/.sqlx/query-9d38f660331b79c0eaf03ec74c3d90357408c573ba300d1bfe95c9bcd7bd7870.json similarity index 60% rename from crates/storage-pg/.sqlx/query-8e001a260d28c537f525e35f70e5cc4ea771124820e2f6f8cfc5b958ab21eecc.json rename to crates/storage-pg/.sqlx/query-9d38f660331b79c0eaf03ec74c3d90357408c573ba300d1bfe95c9bcd7bd7870.json index b0f713498..9e80cc86d 100644 --- a/crates/storage-pg/.sqlx/query-8e001a260d28c537f525e35f70e5cc4ea771124820e2f6f8cfc5b958ab21eecc.json +++ b/crates/storage-pg/.sqlx/query-9d38f660331b79c0eaf03ec74c3d90357408c573ba300d1bfe95c9bcd7bd7870.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO user_registration_tokens\n (user_registration_token_id, token, usage_limit, created_at, expires_at, username, email)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ", + "query": "\n INSERT INTO user_registration_tokens\n (user_registration_token_id, token, usage_limit, created_at, expires_at, username, email, passwordless)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ", "describe": { "columns": [], "parameters": { @@ -11,10 +11,11 @@ "Timestamptz", "Timestamptz", "Text", - "Text" + "Text", + "Bool" ] }, "nullable": [] }, - "hash": "8e001a260d28c537f525e35f70e5cc4ea771124820e2f6f8cfc5b958ab21eecc" + "hash": "9d38f660331b79c0eaf03ec74c3d90357408c573ba300d1bfe95c9bcd7bd7870" } diff --git a/crates/storage-pg/.sqlx/query-4cebc83f7699b61dd5704b82f25553eff02d3fb708071a60d2d5f55a35945218.json b/crates/storage-pg/.sqlx/query-a3e55b75ae98f025db44d3d0dfd88382ce4f53810a86d43d2eeddad758ccb870.json similarity index 70% rename from crates/storage-pg/.sqlx/query-4cebc83f7699b61dd5704b82f25553eff02d3fb708071a60d2d5f55a35945218.json rename to crates/storage-pg/.sqlx/query-a3e55b75ae98f025db44d3d0dfd88382ce4f53810a86d43d2eeddad758ccb870.json index e3f531461..7774e00b9 100644 --- a/crates/storage-pg/.sqlx/query-4cebc83f7699b61dd5704b82f25553eff02d3fb708071a60d2d5f55a35945218.json +++ b/crates/storage-pg/.sqlx/query-a3e55b75ae98f025db44d3d0dfd88382ce4f53810a86d43d2eeddad758ccb870.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_registration_token_id,\n token,\n username,\n email,\n usage_limit,\n times_used,\n created_at,\n last_used_at,\n expires_at,\n revoked_at\n FROM user_registration_tokens\n WHERE user_registration_token_id = $1\n ", + "query": "\n SELECT user_registration_token_id,\n token,\n username,\n email,\n passwordless,\n usage_limit,\n times_used,\n created_at,\n last_used_at,\n expires_at,\n revoked_at\n FROM user_registration_tokens\n WHERE user_registration_token_id = $1\n ", "describe": { "columns": [ { @@ -25,31 +25,36 @@ }, { "ordinal": 4, + "name": "passwordless", + "type_info": "Bool" + }, + { + "ordinal": 5, "name": "usage_limit", "type_info": "Int4" }, { - "ordinal": 5, + "ordinal": 6, "name": "times_used", "type_info": "Int4" }, { - "ordinal": 6, + "ordinal": 7, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 7, + "ordinal": 8, "name": "last_used_at", "type_info": "Timestamptz" }, { - "ordinal": 8, + "ordinal": 9, "name": "expires_at", "type_info": "Timestamptz" }, { - "ordinal": 9, + "ordinal": 10, "name": "revoked_at", "type_info": "Timestamptz" } @@ -64,6 +69,7 @@ false, true, true, + false, true, false, false, @@ -72,5 +78,5 @@ true ] }, - "hash": "4cebc83f7699b61dd5704b82f25553eff02d3fb708071a60d2d5f55a35945218" + "hash": "a3e55b75ae98f025db44d3d0dfd88382ce4f53810a86d43d2eeddad758ccb870" } diff --git a/crates/storage-pg/migrations/20260807130000_registration_token_passwordless.sql b/crates/storage-pg/migrations/20260807130000_registration_token_passwordless.sql new file mode 100644 index 000000000..19f0a8113 --- /dev/null +++ b/crates/storage-pg/migrations/20260807130000_registration_token_passwordless.sql @@ -0,0 +1,8 @@ +-- Copyright 2026 Element Creations Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +-- Please see LICENSE files in the repository root for full details. + +-- Whether registrations using this token skip the password step entirely +ALTER TABLE user_registration_tokens + ADD COLUMN passwordless BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/crates/storage-pg/src/iden.rs b/crates/storage-pg/src/iden.rs index ec522a2d9..16178e425 100644 --- a/crates/storage-pg/src/iden.rs +++ b/crates/storage-pg/src/iden.rs @@ -212,6 +212,7 @@ pub enum UserRegistrationTokens { Token, Username, Email, + Passwordless, UsageLimit, TimesUsed, CreatedAt, diff --git a/crates/storage-pg/src/user/registration_token.rs b/crates/storage-pg/src/user/registration_token.rs index f157e07f7..6968776cd 100644 --- a/crates/storage-pg/src/user/registration_token.rs +++ b/crates/storage-pg/src/user/registration_token.rs @@ -49,6 +49,7 @@ struct UserRegistrationTokenLookup { token: String, username: Option, email: Option, + passwordless: bool, usage_limit: Option, times_used: i32, created_at: DateTime, @@ -215,6 +216,7 @@ impl TryFrom for UserRegistrationToken { token: res.token, username: res.username, email: res.email, + passwordless: res.passwordless, usage_limit, times_used, created_at: res.created_at, @@ -265,6 +267,13 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> { Expr::col((UserRegistrationTokens::Table, UserRegistrationTokens::Email)), UserRegistrationTokenLookupIden::Email, ) + .expr_as( + Expr::col(( + UserRegistrationTokens::Table, + UserRegistrationTokens::Passwordless, + )), + UserRegistrationTokenLookupIden::Passwordless, + ) .expr_as( Expr::col(( UserRegistrationTokens::Table, @@ -379,6 +388,7 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> { token, username, email, + passwordless, usage_limit, times_used, created_at, @@ -421,6 +431,7 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> { token, username, email, + passwordless, usage_limit, times_used, created_at, @@ -461,6 +472,7 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> { expires_at: Option>, username: Option, email: Option, + passwordless: bool, ) -> Result { let created_at = clock.now(); let id = Ulid::from_datetime_with_rng(created_at, rng); @@ -473,8 +485,8 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> { sqlx::query!( r#" INSERT INTO user_registration_tokens - (user_registration_token_id, token, usage_limit, created_at, expires_at, username, email) - VALUES ($1, $2, $3, $4, $5, $6, $7) + (user_registration_token_id, token, usage_limit, created_at, expires_at, username, email, passwordless) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) "#, Uuid::from(id), &token, @@ -483,6 +495,7 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> { expires_at, username.as_deref(), email.as_deref(), + passwordless, ) .traced() .execute(&mut *self.conn) @@ -493,6 +506,7 @@ impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> { token, username, email, + passwordless, usage_limit, times_used: 0, created_at, @@ -781,6 +795,7 @@ mod tests { None, None, None, + false, ) .await .unwrap(); @@ -834,6 +849,7 @@ mod tests { None, None, None, + false, ) .await .unwrap(); @@ -881,6 +897,7 @@ mod tests { None, None, None, + false, ) .await .unwrap(); @@ -938,6 +955,7 @@ mod tests { None, None, None, + false, ) .await .unwrap(); @@ -953,6 +971,7 @@ mod tests { None, None, None, + false, ) .await .unwrap(); @@ -974,6 +993,7 @@ mod tests { Some(past_time), None, None, + false, ) .await .unwrap(); @@ -989,6 +1009,7 @@ mod tests { None, None, None, + false, ) .await .unwrap(); diff --git a/crates/storage/src/user/registration_token.rs b/crates/storage/src/user/registration_token.rs index 96512a16e..34bc5179a 100644 --- a/crates/storage/src/user/registration_token.rs +++ b/crates/storage/src/user/registration_token.rs @@ -152,6 +152,8 @@ pub trait UserRegistrationTokenRepository: Send + Sync { /// * `expires_at`: Optional expiration time for the token /// * `username`: Optional username to impose on the registering user /// * `email`: Optional email to impose on the registering user + /// * `passwordless`: Whether registrations using this token skip the + /// password step /// /// # Errors /// @@ -166,6 +168,7 @@ pub trait UserRegistrationTokenRepository: Send + Sync { expires_at: Option>, username: Option, email: Option, + passwordless: bool, ) -> Result; /// Increment the usage count of a [`UserRegistrationToken`] @@ -325,6 +328,7 @@ repository_impl!(UserRegistrationTokenRepository: expires_at: Option>, username: Option, email: Option, + passwordless: bool, ) -> Result; async fn use_token( &mut self, diff --git a/docs/api/spec.json b/docs/api/spec.json index bbe7476dc..5f4842659 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -3799,6 +3799,7 @@ "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 5, "created_at": "1970-01-01T00:00:00Z", @@ -3824,6 +3825,7 @@ "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": null, "times_used": 0, "created_at": "1970-01-01T00:00:00Z", @@ -3887,6 +3889,7 @@ "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 5, "created_at": "1970-01-01T00:00:00Z", @@ -3945,6 +3948,7 @@ "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 5, "created_at": "1970-01-01T00:00:00Z", @@ -4029,6 +4033,7 @@ "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 5, "created_at": "1970-01-01T00:00:00Z", @@ -4105,6 +4110,7 @@ "valid": false, "username": null, "email": null, + "passwordless": false, "usage_limit": null, "times_used": 0, "created_at": "1970-01-01T00:00:00Z", @@ -4198,6 +4204,7 @@ "valid": true, "username": null, "email": null, + "passwordless": false, "usage_limit": 10, "times_used": 5, "created_at": "1970-01-01T00:00:00Z", @@ -7345,6 +7352,10 @@ "null" ] }, + "passwordless": { + "description": "Whether registering with this token skips the password step. The user's\nidentity is then established by verifying their email address.", + "type": "boolean" + }, "usage_limit": { "description": "Maximum number of times this token can be used", "type": [ @@ -7398,6 +7409,7 @@ "required": [ "token", "valid", + "passwordless", "times_used", "created_at", "invite_url" @@ -7444,6 +7456,11 @@ "string", "null" ] + }, + "passwordless": { + "description": "Whether registering with this token skips the password step. The user's\nidentity is then established by verifying their email address.", + "type": "boolean", + "default": false } } }, diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 7a16b0157..d7ac23f10 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -1298,6 +1298,10 @@ type RegistrationTokenInfo { An email imposed by this token, if any. """ email: String + """ + Whether registering with this token skips the password step. + """ + passwordless: Boolean! } """