diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index 737d58af0..19a81f098 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -48,6 +48,6 @@ pub use self::{ users::{ Authentication, AuthenticationMethod, BrowserSession, Password, User, UserEmail, UserEmailAuthentication, UserEmailAuthenticationCode, UserRecoverySession, - UserRecoveryTicket, + UserRecoveryTicket, UserRegistration, UserRegistrationPassword, }, }; diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs index 88afae0b8..97fe0714e 100644 --- a/crates/data-model/src/users.rs +++ b/crates/data-model/src/users.rs @@ -10,6 +10,7 @@ use chrono::{DateTime, Utc}; use rand::Rng; use serde::Serialize; use ulid::Ulid; +use url::Url; use crate::UserAgent; @@ -112,6 +113,7 @@ impl UserRecoveryTicket { pub struct UserEmailAuthentication { pub id: Ulid, pub user_session_id: Option, + pub user_registration_id: Option, pub email: String, pub created_at: DateTime, pub completed_at: Option>, @@ -192,3 +194,23 @@ impl UserEmail { ] } } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct UserRegistrationPassword { + pub hashed_password: String, + pub version: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct UserRegistration { + pub id: Ulid, + pub username: Option, + pub display_name: Option, + pub terms_url: Option, + pub email_authentication_id: Option, + pub password: Option, + pub ip_address: Option, + pub user_agent: Option, + pub created_at: DateTime, + pub completed_at: Option>, +} diff --git a/crates/storage-pg/.sqlx/query-0e1bce56e15751d82a622d532b279bfc50e22cb12ddf7495c7b0fedca61f9421.json b/crates/storage-pg/.sqlx/query-0e1bce56e15751d82a622d532b279bfc50e22cb12ddf7495c7b0fedca61f9421.json new file mode 100644 index 000000000..afd0835bb --- /dev/null +++ b/crates/storage-pg/.sqlx/query-0e1bce56e15751d82a622d532b279bfc50e22cb12ddf7495c7b0fedca61f9421.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_email_authentications\n ( user_email_authentication_id\n , user_registration_id\n , email\n , created_at\n )\n VALUES ($1, $2, $3, $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "0e1bce56e15751d82a622d532b279bfc50e22cb12ddf7495c7b0fedca61f9421" +} diff --git a/crates/storage-pg/.sqlx/query-188a4aeef5a8b4bf3230c7176ded64d52804848df378dc74f8f54ec4404e094e.json b/crates/storage-pg/.sqlx/query-188a4aeef5a8b4bf3230c7176ded64d52804848df378dc74f8f54ec4404e094e.json new file mode 100644 index 000000000..e6c0970c2 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-188a4aeef5a8b4bf3230c7176ded64d52804848df378dc74f8f54ec4404e094e.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_registrations\n SET terms_url = $2\n WHERE user_registration_id = $1 AND completed_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "188a4aeef5a8b4bf3230c7176ded64d52804848df378dc74f8f54ec4404e094e" +} diff --git a/crates/storage-pg/.sqlx/query-4968c60adef69c7215a7efe2021baffb050b2f475ae106155c2e2f210a81191a.json b/crates/storage-pg/.sqlx/query-4968c60adef69c7215a7efe2021baffb050b2f475ae106155c2e2f210a81191a.json new file mode 100644 index 000000000..ae85c032d --- /dev/null +++ b/crates/storage-pg/.sqlx/query-4968c60adef69c7215a7efe2021baffb050b2f475ae106155c2e2f210a81191a.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_registrations\n SET email_authentication_id = $2\n WHERE user_registration_id = $1 AND completed_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "4968c60adef69c7215a7efe2021baffb050b2f475ae106155c2e2f210a81191a" +} diff --git a/crates/storage-pg/.sqlx/query-7246c8ce575b97b349f281457d83866362043a7740b09f3754fa8f583d93a819.json b/crates/storage-pg/.sqlx/query-7246c8ce575b97b349f281457d83866362043a7740b09f3754fa8f583d93a819.json new file mode 100644 index 000000000..caa13dfe9 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-7246c8ce575b97b349f281457d83866362043a7740b09f3754fa8f583d93a819.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_registration_id\n , ip_address as \"ip_address: IpAddr\"\n , user_agent\n , username\n , display_name\n , terms_url\n , email_authentication_id\n , hashed_password\n , hashed_password_version\n , created_at\n , completed_at\n FROM user_registrations\n WHERE user_registration_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_registration_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "ip_address: IpAddr", + "type_info": "Inet" + }, + { + "ordinal": 2, + "name": "user_agent", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "terms_url", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "email_authentication_id", + "type_info": "Uuid" + }, + { + "ordinal": 7, + "name": "hashed_password", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "hashed_password_version", + "type_info": "Int4" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "completed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true + ] + }, + "hash": "7246c8ce575b97b349f281457d83866362043a7740b09f3754fa8f583d93a819" +} diff --git a/crates/storage-pg/.sqlx/query-7fd19dac2c15091e7f8bd85531d2b99d8a42cc89fe7bb6e9411a886f68e38628.json b/crates/storage-pg/.sqlx/query-7e367e416d18fcf9b227bf053421410b4b7b4af441f0a138c5421d1111cb9f79.json similarity index 66% rename from crates/storage-pg/.sqlx/query-7fd19dac2c15091e7f8bd85531d2b99d8a42cc89fe7bb6e9411a886f68e38628.json rename to crates/storage-pg/.sqlx/query-7e367e416d18fcf9b227bf053421410b4b7b4af441f0a138c5421d1111cb9f79.json index f85b4d689..a6a02b326 100644 --- a/crates/storage-pg/.sqlx/query-7fd19dac2c15091e7f8bd85531d2b99d8a42cc89fe7bb6e9411a886f68e38628.json +++ b/crates/storage-pg/.sqlx/query-7e367e416d18fcf9b227bf053421410b4b7b4af441f0a138c5421d1111cb9f79.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_email_authentication_id\n , user_session_id\n , email\n , created_at\n , completed_at\n FROM user_email_authentications\n WHERE user_email_authentication_id = $1\n ", + "query": "\n SELECT user_email_authentication_id\n , user_session_id\n , user_registration_id\n , email\n , created_at\n , completed_at\n FROM user_email_authentications\n WHERE user_email_authentication_id = $1\n ", "describe": { "columns": [ { @@ -15,16 +15,21 @@ }, { "ordinal": 2, + "name": "user_registration_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, "name": "email", "type_info": "Text" }, { - "ordinal": 3, + "ordinal": 4, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 4, + "ordinal": 5, "name": "completed_at", "type_info": "Timestamptz" } @@ -37,10 +42,11 @@ "nullable": [ false, true, + true, false, false, true ] }, - "hash": "7fd19dac2c15091e7f8bd85531d2b99d8a42cc89fe7bb6e9411a886f68e38628" + "hash": "7e367e416d18fcf9b227bf053421410b4b7b4af441f0a138c5421d1111cb9f79" } diff --git a/crates/storage-pg/.sqlx/query-83d1b0720dfde3209d77f1142aa19359913b8a934ca8a642b7bb43c9a7a58a6d.json b/crates/storage-pg/.sqlx/query-83d1b0720dfde3209d77f1142aa19359913b8a934ca8a642b7bb43c9a7a58a6d.json new file mode 100644 index 000000000..a5899aa2f --- /dev/null +++ b/crates/storage-pg/.sqlx/query-83d1b0720dfde3209d77f1142aa19359913b8a934ca8a642b7bb43c9a7a58a6d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_registrations\n SET completed_at = $2\n WHERE user_registration_id = $1 AND completed_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "83d1b0720dfde3209d77f1142aa19359913b8a934ca8a642b7bb43c9a7a58a6d" +} diff --git a/crates/storage-pg/.sqlx/query-86cef19d7fc2ca255f0e58d3f68ac3e7cc3ce1f676f7b744d4a187a935a459ab.json b/crates/storage-pg/.sqlx/query-86cef19d7fc2ca255f0e58d3f68ac3e7cc3ce1f676f7b744d4a187a935a459ab.json new file mode 100644 index 000000000..bea85e697 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-86cef19d7fc2ca255f0e58d3f68ac3e7cc3ce1f676f7b744d4a187a935a459ab.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_registrations\n SET username = $2\n WHERE user_registration_id = $1 AND completed_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "86cef19d7fc2ca255f0e58d3f68ac3e7cc3ce1f676f7b744d4a187a935a459ab" +} diff --git a/crates/storage-pg/.sqlx/query-8f5ce493e8b8473ba03d5263915a8b231f9e7c211ab83487536008e48316c269.json b/crates/storage-pg/.sqlx/query-8f5ce493e8b8473ba03d5263915a8b231f9e7c211ab83487536008e48316c269.json new file mode 100644 index 000000000..969748044 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-8f5ce493e8b8473ba03d5263915a8b231f9e7c211ab83487536008e48316c269.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_registrations\n SET display_name = $2\n WHERE user_registration_id = $1 AND completed_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "8f5ce493e8b8473ba03d5263915a8b231f9e7c211ab83487536008e48316c269" +} diff --git a/crates/storage-pg/.sqlx/query-b60d34f4d250c12f75dba10491c1337d69aebad12be6fbfbdde91e34083ba4ed.json b/crates/storage-pg/.sqlx/query-b60d34f4d250c12f75dba10491c1337d69aebad12be6fbfbdde91e34083ba4ed.json new file mode 100644 index 000000000..5b4d6fb5f --- /dev/null +++ b/crates/storage-pg/.sqlx/query-b60d34f4d250c12f75dba10491c1337d69aebad12be6fbfbdde91e34083ba4ed.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_registrations\n SET hashed_password = $2, hashed_password_version = $3\n WHERE user_registration_id = $1 AND completed_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "b60d34f4d250c12f75dba10491c1337d69aebad12be6fbfbdde91e34083ba4ed" +} diff --git a/crates/storage-pg/.sqlx/query-de6cb87ee8e603d485e13fbea453ef111ac9df726e305e88c87697b6062ed2df.json b/crates/storage-pg/.sqlx/query-de6cb87ee8e603d485e13fbea453ef111ac9df726e305e88c87697b6062ed2df.json new file mode 100644 index 000000000..91ffddd17 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-de6cb87ee8e603d485e13fbea453ef111ac9df726e305e88c87697b6062ed2df.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_registrations\n ( user_registration_id\n , ip_address\n , user_agent\n , created_at\n )\n VALUES ($1, $2, $3, $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Inet", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "de6cb87ee8e603d485e13fbea453ef111ac9df726e305e88c87697b6062ed2df" +} diff --git a/crates/storage-pg/migrations/20250113102144_user_registrations.sql b/crates/storage-pg/migrations/20250113102144_user_registrations.sql new file mode 100644 index 000000000..03de08487 --- /dev/null +++ b/crates/storage-pg/migrations/20250113102144_user_registrations.sql @@ -0,0 +1,27 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +-- Add a table for storing user registrations +CREATE TABLE "user_registrations" ( + "user_registration_id" UUID PRIMARY KEY, + "ip_address" INET, + "user_agent" TEXT, + "username" TEXT, + "display_name" TEXT, + "terms_url" TEXT, + "email_authentication_id" UUID + REFERENCES "user_email_authentications" ("user_email_authentication_id") + ON DELETE SET NULL, + "hashed_password" TEXT, + "hashed_password_version" INTEGER, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "completed_at" TIMESTAMP WITH TIME ZONE +); + +-- Allow using user email authentications for user registrations +ALTER TABLE "user_email_authentications" + ADD COLUMN "user_registration_id" UUID + REFERENCES "user_registrations" ("user_registration_id") + ON DELETE CASCADE; diff --git a/crates/storage-pg/src/repository.rs b/crates/storage-pg/src/repository.rs index 923221742..fde0f13e3 100644 --- a/crates/storage-pg/src/repository.rs +++ b/crates/storage-pg/src/repository.rs @@ -49,7 +49,8 @@ use crate::{ }, user::{ PgBrowserSessionRepository, PgUserEmailRepository, PgUserPasswordRepository, - PgUserRecoveryRepository, PgUserRepository, PgUserTermsRepository, + PgUserRecoveryRepository, PgUserRegistrationRepository, PgUserRepository, + PgUserTermsRepository, }, DatabaseError, }; @@ -191,6 +192,12 @@ where Box::new(PgUserTermsRepository::new(self.conn.as_mut())) } + fn user_registration<'c>( + &'c mut self, + ) -> Box + 'c> { + Box::new(PgUserRegistrationRepository::new(self.conn.as_mut())) + } + fn browser_session<'c>( &'c mut self, ) -> Box + 'c> { diff --git a/crates/storage-pg/src/user/email.rs b/crates/storage-pg/src/user/email.rs index cd09e8668..091714f4a 100644 --- a/crates/storage-pg/src/user/email.rs +++ b/crates/storage-pg/src/user/email.rs @@ -8,6 +8,7 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use mas_data_model::{ BrowserSession, User, UserEmail, UserEmailAuthentication, UserEmailAuthenticationCode, + UserRegistration, }; use mas_storage::{ user::{UserEmailFilter, UserEmailRepository}, @@ -66,6 +67,7 @@ impl From for UserEmail { struct UserEmailAuthenticationLookup { user_email_authentication_id: Uuid, user_session_id: Option, + user_registration_id: Option, email: String, created_at: DateTime, completed_at: Option>, @@ -76,6 +78,7 @@ impl From for UserEmailAuthentication { UserEmailAuthentication { id: value.user_email_authentication_id.into(), user_session_id: value.user_session_id.map(Ulid::from), + user_registration_id: value.user_registration_id.map(Ulid::from), email: value.email, created_at: value.created_at, completed_at: value.completed_at, @@ -427,6 +430,59 @@ impl UserEmailRepository for PgUserEmailRepository<'_> { Ok(UserEmailAuthentication { id, user_session_id: Some(session.id), + user_registration_id: None, + email, + created_at, + completed_at: None, + }) + } + + #[tracing::instrument( + name = "db.user_email.add_authentication_for_registration", + skip_all, + fields( + db.query.text, + %user_registration.id, + user_email_authentication.id, + user_email_authentication.email = email, + ), + err, + )] + async fn add_authentication_for_registration( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + email: String, + user_registration: &UserRegistration, + ) -> Result { + let created_at = clock.now(); + let id = Ulid::from_datetime_with_source(created_at.into(), rng); + tracing::Span::current() + .record("user_email_authentication.id", tracing::field::display(id)); + + sqlx::query!( + r#" + INSERT INTO user_email_authentications + ( user_email_authentication_id + , user_registration_id + , email + , created_at + ) + VALUES ($1, $2, $3, $4) + "#, + Uuid::from(id), + Uuid::from(user_registration.id), + &email, + created_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + Ok(UserEmailAuthentication { + id, + user_session_id: None, + user_registration_id: Some(user_registration.id), email, created_at, completed_at: None, @@ -509,6 +565,7 @@ impl UserEmailRepository for PgUserEmailRepository<'_> { r#" SELECT user_email_authentication_id , user_session_id + , user_registration_id , email , created_at , completed_at diff --git a/crates/storage-pg/src/user/mod.rs b/crates/storage-pg/src/user/mod.rs index ae7b999d1..ff0119c2a 100644 --- a/crates/storage-pg/src/user/mod.rs +++ b/crates/storage-pg/src/user/mod.rs @@ -31,6 +31,7 @@ use crate::{ mod email; mod password; mod recovery; +mod registration; mod session; mod terms; @@ -39,8 +40,8 @@ mod tests; pub use self::{ email::PgUserEmailRepository, password::PgUserPasswordRepository, - recovery::PgUserRecoveryRepository, session::PgBrowserSessionRepository, - terms::PgUserTermsRepository, + recovery::PgUserRecoveryRepository, registration::PgUserRegistrationRepository, + session::PgBrowserSessionRepository, terms::PgUserTermsRepository, }; /// An implementation of [`UserRepository`] for a PostgreSQL connection diff --git a/crates/storage-pg/src/user/registration.rs b/crates/storage-pg/src/user/registration.rs new file mode 100644 index 000000000..d50aca1ca --- /dev/null +++ b/crates/storage-pg/src/user/registration.rs @@ -0,0 +1,897 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use std::net::IpAddr; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use mas_data_model::{ + UserAgent, UserEmailAuthentication, UserRegistration, UserRegistrationPassword, +}; +use mas_storage::{user::UserRegistrationRepository, Clock}; +use rand::RngCore; +use sqlx::PgConnection; +use ulid::Ulid; +use url::Url; +use uuid::Uuid; + +use crate::{DatabaseError, DatabaseInconsistencyError, ExecuteExt as _}; + +/// An implementation of [`UserRegistrationRepository`] for a PostgreSQL +/// connection +pub struct PgUserRegistrationRepository<'c> { + conn: &'c mut PgConnection, +} + +impl<'c> PgUserRegistrationRepository<'c> { + /// Create a new [`PgUserRegistrationRepository`] from an active PostgreSQL + /// connection + pub fn new(conn: &'c mut PgConnection) -> Self { + Self { conn } + } +} + +struct UserRegistrationLookup { + user_registration_id: Uuid, + ip_address: Option, + user_agent: Option, + username: Option, + display_name: Option, + terms_url: Option, + email_authentication_id: Option, + hashed_password: Option, + hashed_password_version: Option, + created_at: DateTime, + completed_at: Option>, +} + +impl TryFrom for UserRegistration { + type Error = DatabaseInconsistencyError; + + fn try_from(value: UserRegistrationLookup) -> Result { + let id = Ulid::from(value.user_registration_id); + let user_agent = value.user_agent.map(UserAgent::parse); + + let password = match (value.hashed_password, value.hashed_password_version) { + (Some(hashed_password), Some(version)) => { + let version = version.try_into().map_err(|e| { + DatabaseInconsistencyError::on("user_registrations") + .column("hashed_password_version") + .row(id) + .source(e) + })?; + + Some(UserRegistrationPassword { + hashed_password, + version, + }) + } + (None, None) => None, + _ => { + return Err(DatabaseInconsistencyError::on("user_registrations") + .column("hashed_password") + .row(id)); + } + }; + + let terms_url = value + .terms_url + .map(|u| u.parse()) + .transpose() + .map_err(|e| { + DatabaseInconsistencyError::on("user_registrations") + .column("terms_url") + .row(id) + .source(e) + })?; + + Ok(UserRegistration { + id, + ip_address: value.ip_address, + user_agent, + username: value.username, + display_name: value.display_name, + terms_url, + email_authentication_id: value.email_authentication_id.map(Ulid::from), + password, + created_at: value.created_at, + completed_at: value.completed_at, + }) + } +} + +#[async_trait] +impl UserRegistrationRepository for PgUserRegistrationRepository<'_> { + type Error = DatabaseError; + + #[tracing::instrument( + name = "db.user_registration.lookup", + skip_all, + fields( + db.query.text, + user_registration.id = %id, + ), + err, + )] + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error> { + let res = sqlx::query_as!( + UserRegistrationLookup, + r#" + SELECT user_registration_id + , ip_address as "ip_address: IpAddr" + , user_agent + , username + , display_name + , terms_url + , email_authentication_id + , hashed_password + , hashed_password_version + , created_at + , completed_at + FROM user_registrations + WHERE user_registration_id = $1 + "#, + Uuid::from(id), + ) + .traced() + .fetch_optional(&mut *self.conn) + .await?; + + let Some(res) = res else { return Ok(None) }; + + Ok(Some(res.try_into()?)) + } + + #[tracing::instrument( + name = "db.user_registration.add", + skip_all, + fields( + db.query.text, + user_registration.id, + ), + err, + )] + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + ip_address: Option, + user_agent: Option, + ) -> Result { + let created_at = clock.now(); + let id = Ulid::from_datetime_with_source(created_at.into(), rng); + tracing::Span::current().record("user_registration.id", tracing::field::display(id)); + + sqlx::query!( + r#" + INSERT INTO user_registrations + ( user_registration_id + , ip_address + , user_agent + , created_at + ) + VALUES ($1, $2, $3, $4) + "#, + Uuid::from(id), + ip_address as Option, + user_agent.as_deref(), + created_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + Ok(UserRegistration { + id, + ip_address, + user_agent, + created_at, + completed_at: None, + username: None, + display_name: None, + terms_url: None, + email_authentication_id: None, + password: None, + }) + } + + #[tracing::instrument( + name = "db.user_registration.set_username", + skip_all, + fields( + db.query.text, + user_registration.id = %user_registration.id, + user_registration.username = username, + ), + err, + )] + async fn set_username( + &mut self, + mut user_registration: UserRegistration, + username: String, + ) -> Result { + let res = sqlx::query!( + r#" + UPDATE user_registrations + SET username = $2 + WHERE user_registration_id = $1 AND completed_at IS NULL + "#, + Uuid::from(user_registration.id), + username, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + user_registration.username = Some(username); + + Ok(user_registration) + } + + #[tracing::instrument( + name = "db.user_registration.set_display_name", + skip_all, + fields( + db.query.text, + user_registration.id = %user_registration.id, + user_registration.display_name = display_name, + ), + err, + )] + async fn set_display_name( + &mut self, + mut user_registration: UserRegistration, + display_name: String, + ) -> Result { + let res = sqlx::query!( + r#" + UPDATE user_registrations + SET display_name = $2 + WHERE user_registration_id = $1 AND completed_at IS NULL + "#, + Uuid::from(user_registration.id), + display_name, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + user_registration.display_name = Some(display_name); + + Ok(user_registration) + } + + #[tracing::instrument( + name = "db.user_registration.set_terms_url", + skip_all, + fields( + db.query.text, + user_registration.id = %user_registration.id, + user_registration.terms_url = %terms_url, + ), + err, + )] + async fn set_terms_url( + &mut self, + mut user_registration: UserRegistration, + terms_url: Url, + ) -> Result { + let res = sqlx::query!( + r#" + UPDATE user_registrations + SET terms_url = $2 + WHERE user_registration_id = $1 AND completed_at IS NULL + "#, + Uuid::from(user_registration.id), + terms_url.as_str(), + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + user_registration.terms_url = Some(terms_url); + + Ok(user_registration) + } + + #[tracing::instrument( + name = "db.user_registration.set_email_authentication", + skip_all, + fields( + db.query.text, + %user_registration.id, + %user_email_authentication.id, + %user_email_authentication.email, + ), + err, + )] + async fn set_email_authentication( + &mut self, + mut user_registration: UserRegistration, + user_email_authentication: &UserEmailAuthentication, + ) -> Result { + let res = sqlx::query!( + r#" + UPDATE user_registrations + SET email_authentication_id = $2 + WHERE user_registration_id = $1 AND completed_at IS NULL + "#, + Uuid::from(user_registration.id), + Uuid::from(user_email_authentication.id), + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + user_registration.email_authentication_id = Some(user_email_authentication.id); + + Ok(user_registration) + } + + #[tracing::instrument( + name = "db.user_registration.set_password", + skip_all, + fields( + db.query.text, + user_registration.id = %user_registration.id, + user_registration.hashed_password = hashed_password, + user_registration.hashed_password_version = version, + ), + err, + )] + async fn set_password( + &mut self, + mut user_registration: UserRegistration, + hashed_password: String, + version: u16, + ) -> Result { + let res = sqlx::query!( + r#" + UPDATE user_registrations + SET hashed_password = $2, hashed_password_version = $3 + WHERE user_registration_id = $1 AND completed_at IS NULL + "#, + Uuid::from(user_registration.id), + hashed_password, + i32::from(version), + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + user_registration.password = Some(UserRegistrationPassword { + hashed_password, + version, + }); + + Ok(user_registration) + } + + #[tracing::instrument( + name = "db.user_registration.complete", + skip_all, + fields( + db.query.text, + user_registration.id = %user_registration.id, + ), + err, + )] + async fn complete( + &mut self, + clock: &dyn Clock, + mut user_registration: UserRegistration, + ) -> Result { + let completed_at = clock.now(); + let res = sqlx::query!( + r#" + UPDATE user_registrations + SET completed_at = $2 + WHERE user_registration_id = $1 AND completed_at IS NULL + "#, + Uuid::from(user_registration.id), + completed_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + DatabaseError::ensure_affected_rows(&res, 1)?; + + user_registration.completed_at = Some(completed_at); + + Ok(user_registration) + } +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr}; + + use mas_data_model::{UserAgent, UserRegistrationPassword}; + use mas_storage::{clock::MockClock, Clock}; + use rand::SeedableRng; + use rand_chacha::ChaChaRng; + use sqlx::PgPool; + + use crate::PgRepository; + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_create_lookup_complete(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + let registration = repo + .user_registration() + .add(&mut rng, &clock, None, None) + .await + .unwrap(); + + assert_eq!(registration.created_at, clock.now()); + assert_eq!(registration.completed_at, None); + assert_eq!(registration.username, None); + assert_eq!(registration.display_name, None); + assert_eq!(registration.terms_url, None); + assert_eq!(registration.email_authentication_id, None); + assert_eq!(registration.password, None); + assert_eq!(registration.user_agent, None); + assert_eq!(registration.ip_address, None); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!(lookup.id, registration.id); + assert_eq!(lookup.created_at, registration.created_at); + assert_eq!(lookup.completed_at, registration.completed_at); + assert_eq!(lookup.username, registration.username); + assert_eq!(lookup.display_name, registration.display_name); + assert_eq!(lookup.terms_url, registration.terms_url); + assert_eq!( + lookup.email_authentication_id, + registration.email_authentication_id + ); + assert_eq!(lookup.password, registration.password); + assert_eq!(lookup.user_agent, registration.user_agent); + assert_eq!(lookup.ip_address, registration.ip_address); + + // Mark the registration as completed + let registration = repo + .user_registration() + .complete(&clock, registration) + .await + .unwrap(); + assert_eq!(registration.completed_at, Some(clock.now())); + + // Lookup the registration again + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + assert_eq!(lookup.completed_at, registration.completed_at); + + // Do it again, it should fail + let res = repo + .user_registration() + .complete(&clock, registration) + .await; + assert!(res.is_err()); + } + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_create_useragent_ipaddress(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + let registration = repo + .user_registration() + .add( + &mut rng, + &clock, + Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))), + Some(UserAgent::parse("Mozilla/5.0".to_owned())), + ) + .await + .unwrap(); + + assert_eq!( + registration.user_agent, + Some(UserAgent::parse("Mozilla/5.0".to_owned())) + ); + assert_eq!( + registration.ip_address, + Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))) + ); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!(lookup.user_agent, registration.user_agent); + assert_eq!(lookup.ip_address, registration.ip_address); + } + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_set_username(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + let registration = repo + .user_registration() + .add(&mut rng, &clock, None, None) + .await + .unwrap(); + + assert_eq!(registration.username, None); + + let registration = repo + .user_registration() + .set_username(registration, "alice".to_owned()) + .await + .unwrap(); + + assert_eq!(registration.username, Some("alice".to_owned())); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!(lookup.username, registration.username); + + // Setting it again should work + let registration = repo + .user_registration() + .set_username(registration, "bob".to_owned()) + .await + .unwrap(); + + assert_eq!(registration.username, Some("bob".to_owned())); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!(lookup.username, registration.username); + + // Can't set it once completed + let registration = repo + .user_registration() + .complete(&clock, registration) + .await + .unwrap(); + + let res = repo + .user_registration() + .set_username(registration, "charlie".to_owned()) + .await; + assert!(res.is_err()); + } + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_set_display_name(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + let registration = repo + .user_registration() + .add(&mut rng, &clock, None, None) + .await + .unwrap(); + + assert_eq!(registration.display_name, None); + + let registration = repo + .user_registration() + .set_display_name(registration, "Alice".to_owned()) + .await + .unwrap(); + + assert_eq!(registration.display_name, Some("Alice".to_owned())); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!(lookup.display_name, registration.display_name); + + // Setting it again should work + let registration = repo + .user_registration() + .set_display_name(registration, "Bob".to_owned()) + .await + .unwrap(); + + assert_eq!(registration.display_name, Some("Bob".to_owned())); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!(lookup.display_name, registration.display_name); + + // Can't set it once completed + let registration = repo + .user_registration() + .complete(&clock, registration) + .await + .unwrap(); + + let res = repo + .user_registration() + .set_display_name(registration, "Charlie".to_owned()) + .await; + assert!(res.is_err()); + } + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_set_terms_url(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + let registration = repo + .user_registration() + .add(&mut rng, &clock, None, None) + .await + .unwrap(); + + assert_eq!(registration.terms_url, None); + + let registration = repo + .user_registration() + .set_terms_url(registration, "https://example.com/terms".parse().unwrap()) + .await + .unwrap(); + + assert_eq!( + registration.terms_url, + Some("https://example.com/terms".parse().unwrap()) + ); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!(lookup.terms_url, registration.terms_url); + + // Setting it again should work + let registration = repo + .user_registration() + .set_terms_url(registration, "https://example.com/terms2".parse().unwrap()) + .await + .unwrap(); + + assert_eq!( + registration.terms_url, + Some("https://example.com/terms2".parse().unwrap()) + ); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!(lookup.terms_url, registration.terms_url); + + // Can't set it once completed + let registration = repo + .user_registration() + .complete(&clock, registration) + .await + .unwrap(); + + let res = repo + .user_registration() + .set_terms_url(registration, "https://example.com/terms3".parse().unwrap()) + .await; + assert!(res.is_err()); + } + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_set_email_authentication(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + let registration = repo + .user_registration() + .add(&mut rng, &clock, None, None) + .await + .unwrap(); + + assert_eq!(registration.email_authentication_id, None); + + let authentication = repo + .user_email() + .add_authentication_for_registration( + &mut rng, + &clock, + "alice@example.com".to_owned(), + ®istration, + ) + .await + .unwrap(); + + let registration = repo + .user_registration() + .set_email_authentication(registration, &authentication) + .await + .unwrap(); + + assert_eq!( + registration.email_authentication_id, + Some(authentication.id) + ); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!( + lookup.email_authentication_id, + registration.email_authentication_id + ); + + // Setting it again should work + let registration = repo + .user_registration() + .set_email_authentication(registration, &authentication) + .await + .unwrap(); + + assert_eq!( + registration.email_authentication_id, + Some(authentication.id) + ); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!( + lookup.email_authentication_id, + registration.email_authentication_id + ); + + // Can't set it once completed + let registration = repo + .user_registration() + .complete(&clock, registration) + .await + .unwrap(); + + let res = repo + .user_registration() + .set_email_authentication(registration, &authentication) + .await; + assert!(res.is_err()); + } + + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_set_password(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + let registration = repo + .user_registration() + .add(&mut rng, &clock, None, None) + .await + .unwrap(); + + assert_eq!(registration.password, None); + + let registration = repo + .user_registration() + .set_password(registration, "fakehashedpassword".to_owned(), 1) + .await + .unwrap(); + + assert_eq!( + registration.password, + Some(UserRegistrationPassword { + hashed_password: "fakehashedpassword".to_owned(), + version: 1, + }) + ); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!(lookup.password, registration.password); + + // Setting it again should work + let registration = repo + .user_registration() + .set_password(registration, "fakehashedpassword2".to_owned(), 2) + .await + .unwrap(); + + assert_eq!( + registration.password, + Some(UserRegistrationPassword { + hashed_password: "fakehashedpassword2".to_owned(), + version: 2, + }) + ); + + let lookup = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + + assert_eq!(lookup.password, registration.password); + + // Can't set it once completed + let registration = repo + .user_registration() + .complete(&clock, registration) + .await + .unwrap(); + + let res = repo + .user_registration() + .set_password(registration, "fakehashedpassword3".to_owned(), 3) + .await; + assert!(res.is_err()); + } +} diff --git a/crates/storage/src/repository.rs b/crates/storage/src/repository.rs index ab70a287a..4ee86093d 100644 --- a/crates/storage/src/repository.rs +++ b/crates/storage/src/repository.rs @@ -24,7 +24,7 @@ use crate::{ }, user::{ BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, - UserRecoveryRepository, UserRepository, UserTermsRepository, + UserRecoveryRepository, UserRegistrationRepository, UserRepository, UserTermsRepository, }, }; @@ -129,6 +129,11 @@ pub trait RepositoryAccess: Send { fn user_recovery<'c>(&'c mut self) -> Box + 'c>; + /// Get an [`UserRegistrationRepository`] + fn user_registration<'c>( + &'c mut self, + ) -> Box + 'c>; + /// Get an [`UserTermsRepository`] fn user_terms<'c>(&'c mut self) -> Box + 'c>; @@ -224,8 +229,8 @@ mod impls { UpstreamOAuthSessionRepository, }, user::{ - BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository, - UserTermsRepository, + BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, + UserRegistrationRepository, UserRepository, UserTermsRepository, }, MapErr, Repository, RepositoryTransaction, }; @@ -316,6 +321,15 @@ mod impls { Box::new(MapErr::new(self.inner.user_recovery(), &mut self.mapper)) } + fn user_registration<'c>( + &'c mut self, + ) -> Box + 'c> { + Box::new(MapErr::new( + self.inner.user_registration(), + &mut self.mapper, + )) + } + fn user_terms<'c>(&'c mut self) -> Box + 'c> { Box::new(MapErr::new(self.inner.user_terms(), &mut self.mapper)) } @@ -468,6 +482,12 @@ mod impls { (**self).user_recovery() } + fn user_registration<'c>( + &'c mut self, + ) -> Box + 'c> { + (**self).user_registration() + } + fn user_terms<'c>(&'c mut self) -> Box + 'c> { (**self).user_terms() } diff --git a/crates/storage/src/user/email.rs b/crates/storage/src/user/email.rs index 903b7cd4b..695876a92 100644 --- a/crates/storage/src/user/email.rs +++ b/crates/storage/src/user/email.rs @@ -7,6 +7,7 @@ use async_trait::async_trait; use mas_data_model::{ BrowserSession, User, UserEmail, UserEmailAuthentication, UserEmailAuthenticationCode, + UserRegistration, }; use rand_core::RngCore; use ulid::Ulid; @@ -184,6 +185,27 @@ pub trait UserEmailRepository: Send + Sync { session: &BrowserSession, ) -> Result; + /// Add a new [`UserEmailAuthentication`] for a [`UserRegistration`] + /// + /// # Parameters + /// + /// * `rng`: The random number generator to use + /// * `clock`: The clock to use + /// * `email`: The email address to add + /// * `registration`: The [`UserRegistration`] for which to add the + /// [`UserEmailAuthentication`] + /// + /// # Errors + /// + /// Returns an error if the underlying repository fails + async fn add_authentication_for_registration( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + email: String, + registration: &UserRegistration, + ) -> Result; + /// Add a new [`UserEmailAuthenticationCode`] for a /// [`UserEmailAuthentication`] /// @@ -289,6 +311,14 @@ repository_impl!(UserEmailRepository: session: &BrowserSession, ) -> Result; + async fn add_authentication_for_registration( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + email: String, + registration: &UserRegistration, + ) -> Result; + async fn add_authentication_code( &mut self, rng: &mut (dyn RngCore + Send), diff --git a/crates/storage/src/user/mod.rs b/crates/storage/src/user/mod.rs index fcd1381d3..d8d288eb8 100644 --- a/crates/storage/src/user/mod.rs +++ b/crates/storage/src/user/mod.rs @@ -16,6 +16,7 @@ use crate::{repository_impl, Clock, Page, Pagination}; mod email; mod password; mod recovery; +mod registration; mod session; mod terms; @@ -23,6 +24,7 @@ pub use self::{ email::{UserEmailFilter, UserEmailRepository}, password::UserPasswordRepository, recovery::UserRecoveryRepository, + registration::UserRegistrationRepository, session::{BrowserSessionFilter, BrowserSessionRepository}, terms::UserTermsRepository, }; diff --git a/crates/storage/src/user/registration.rs b/crates/storage/src/user/registration.rs new file mode 100644 index 000000000..f10afd75b --- /dev/null +++ b/crates/storage/src/user/registration.rs @@ -0,0 +1,202 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use std::net::IpAddr; + +use async_trait::async_trait; +use mas_data_model::{UserAgent, UserEmailAuthentication, UserRegistration}; +use rand_core::RngCore; +use ulid::Ulid; +use url::Url; + +use crate::{repository_impl, Clock}; + +/// A [`UserRegistrationRepository`] helps interacting with [`UserRegistration`] +/// saved in the storage backend +#[async_trait] +pub trait UserRegistrationRepository: Send + Sync { + /// The error type returned by the repository + type Error; + + /// Lookup a [`UserRegistration`] by its ID + /// + /// Returns `None` if no [`UserRegistration`] was found + /// + /// # Parameters + /// + /// * `id`: The ID of the [`UserRegistration`] to lookup + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; + + /// Create a new [`UserRegistration`] session + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + ip_address: Option, + user_agent: Option, + ) -> Result; + + /// Set the username of a [`UserRegistration`] + /// + /// Returns the updated [`UserRegistration`] + /// + /// # Parameters + /// + /// * `user_registration`: The [`UserRegistration`] to update + /// * `username`: The username to set + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails or if the + /// registration is already completed + async fn set_username( + &mut self, + user_registration: UserRegistration, + username: String, + ) -> Result; + + /// Set the display name of a [`UserRegistration`] + /// + /// Returns the updated [`UserRegistration`] + /// + /// # Parameters + /// + /// * `user_registration`: The [`UserRegistration`] to update + /// * `display_name`: The display name to set + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails or if the + /// registration is already completed + async fn set_display_name( + &mut self, + user_registration: UserRegistration, + display_name: String, + ) -> Result; + + /// Set the terms URL of a [`UserRegistration`] + /// + /// Returns the updated [`UserRegistration`] + /// + /// # Parameters + /// + /// * `user_registration`: The [`UserRegistration`] to update + /// * `terms_url`: The terms URL to set + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails or if the + /// registration is already completed + async fn set_terms_url( + &mut self, + user_registration: UserRegistration, + terms_url: Url, + ) -> Result; + + /// Set the email authentication code of a [`UserRegistration`] + /// + /// Returns the updated [`UserRegistration`] + /// + /// # Parameters + /// + /// * `user_registration`: The [`UserRegistration`] to update + /// * `email_authentication`: The [`UserEmailAuthentication`] to set + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails or if the + /// registration is already completed + async fn set_email_authentication( + &mut self, + user_registration: UserRegistration, + email_authentication: &UserEmailAuthentication, + ) -> Result; + + /// Set the password of a [`UserRegistration`] + /// + /// Returns the updated [`UserRegistration`] + /// + /// # Parameters + /// + /// * `user_registration`: The [`UserRegistration`] to update + /// * `hashed_password`: The hashed password to set + /// * `version`: The version of the hashing scheme + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails or if the + /// registration is already completed + async fn set_password( + &mut self, + user_registration: UserRegistration, + hashed_password: String, + version: u16, + ) -> Result; + + /// Complete a [`UserRegistration`] + /// + /// Returns the updated [`UserRegistration`] + /// + /// # Parameters + /// + /// * `clock`: The clock used to generate timestamps + /// * `user_registration`: The [`UserRegistration`] to complete + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails or if the + /// registration is already completed + async fn complete( + &mut self, + clock: &dyn Clock, + user_registration: UserRegistration, + ) -> Result; +} + +repository_impl!(UserRegistrationRepository: + async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + ip_address: Option, + user_agent: Option, + ) -> Result; + async fn set_username( + &mut self, + user_registration: UserRegistration, + username: String, + ) -> Result; + async fn set_display_name( + &mut self, + user_registration: UserRegistration, + display_name: String, + ) -> Result; + async fn set_terms_url( + &mut self, + user_registration: UserRegistration, + terms_url: Url, + ) -> Result; + async fn set_email_authentication( + &mut self, + user_registration: UserRegistration, + email_authentication: &UserEmailAuthentication, + ) -> Result; + async fn set_password( + &mut self, + user_registration: UserRegistration, + hashed_password: String, + version: u16, + ) -> Result; + async fn complete( + &mut self, + clock: &dyn Clock, + user_registration: UserRegistration, + ) -> Result; +);