diff --git a/biome.json b/biome.json index f89818139..5b3843b4e 100644 --- a/biome.json +++ b/biome.json @@ -18,7 +18,7 @@ "frontend/src/gql/**", "frontend/src/routeTree.gen.ts", "frontend/.storybook/locales.ts", - "frontend/.storybook/mockServiceWorker.js", + "frontend/.storybook/public/mockServiceWorker.js", "frontend/locales/*.json", "**/coverage/**", "**/dist/**" diff --git a/crates/axum-utils/src/cookies.rs b/crates/axum-utils/src/cookies.rs index 9371d5422..1c9e0eb09 100644 --- a/crates/axum-utils/src/cookies.rs +++ b/crates/axum-utils/src/cookies.rs @@ -138,6 +138,13 @@ impl CookieJar { self } + /// Remove a cookie from the jar + #[must_use] + pub fn remove(mut self, key: &str) -> Self { + self.inner = self.inner.remove(key.to_owned()); + self + } + /// Load and deserialize a cookie from the jar /// /// Returns `None` if the cookie is not present diff --git a/crates/cli/src/commands/manage.rs b/crates/cli/src/commands/manage.rs index da5a7f98e..b96e309b3 100644 --- a/crates/cli/src/commands/manage.rs +++ b/crates/cli/src/commands/manage.rs @@ -65,7 +65,7 @@ enum Subcommand { /// Add an email address to the specified user AddEmail { username: String, email: String }, - /// Mark email address as verified + /// [DEPRECATED] Mark email address as verified VerifyEmail { username: String, email: String }, /// Set a user password @@ -252,15 +252,8 @@ impl Options { .await? }; - let email = repo.user_email().mark_as_verified(&clock, email).await?; - - // If the user has no primary email, set this one as primary. - if user.primary_user_email_id.is_none() { - repo.user_email().set_as_primary(&email).await?; - } - repo.into_inner().commit().await?; - info!(?email, "Email added and marked as verified"); + info!(?email, "Email added"); Ok(ExitCode::SUCCESS) } @@ -273,31 +266,7 @@ impl Options { ) .entered(); - let database_config = DatabaseConfig::extract_or_default(figment)?; - let mut conn = database_connection_from_config(&database_config).await?; - let txn = conn.begin().await?; - let mut repo = PgRepository::from_conn(txn); - - let user = repo - .user() - .find_by_username(&username) - .await? - .context("User not found")?; - - let email = repo - .user_email() - .find(&user, &email) - .await? - .context("Email not found")?; - let email = repo.user_email().mark_as_verified(&clock, email).await?; - - // If the user has no primary email, set this one as primary. - if user.primary_user_email_id.is_none() { - repo.user_email().set_as_primary(&email).await?; - } - - repo.into_inner().commit().await?; - info!(?email, "Email marked as verified"); + tracing::warn!("The 'verify-email' command is deprecated and will be removed in a future version. Use 'add-email' instead."); Ok(ExitCode::SUCCESS) } @@ -943,20 +912,9 @@ impl UserCreationRequest<'_> { } for email in emails { - let user_email = repo - .user_email() + repo.user_email() .add(rng, clock, &user, email.to_string()) .await?; - - let user_email = repo - .user_email() - .mark_as_verified(clock, user_email) - .await?; - - if user.primary_user_email_id.is_none() { - repo.user_email().set_as_primary(&user_email).await?; - user.primary_user_email_id = Some(user_email.id); - } } for (provider, subject) in upstream_provider_mappings { diff --git a/crates/cli/src/sync.rs b/crates/cli/src/sync.rs index b61cc6935..9cc9b1dd2 100644 --- a/crates/cli/src/sync.rs +++ b/crates/cli/src/sync.rs @@ -56,17 +56,6 @@ fn map_claims_imports( action: map_import_action(config.email.action), template: config.email.template.clone(), }, - verify_email: match config.email.set_email_verification { - mas_config::UpstreamOAuth2SetEmailVerification::Always => { - mas_data_model::UpsreamOAuthProviderSetEmailVerification::Always - } - mas_config::UpstreamOAuth2SetEmailVerification::Never => { - mas_data_model::UpsreamOAuthProviderSetEmailVerification::Never - } - mas_config::UpstreamOAuth2SetEmailVerification::Import => { - mas_data_model::UpsreamOAuthProviderSetEmailVerification::Import - } - }, account_name: mas_data_model::UpstreamOAuthProviderSubjectPreference { template: config.account_name.template.clone(), }, diff --git a/crates/config/src/sections/mod.rs b/crates/config/src/sections/mod.rs index faf0b0087..df95ee820 100644 --- a/crates/config/src/sections/mod.rs +++ b/crates/config/src/sections/mod.rs @@ -52,7 +52,6 @@ pub use self::{ EmailImportPreference as UpstreamOAuth2EmailImportPreference, ImportAction as UpstreamOAuth2ImportAction, PkceMethod as UpstreamOAuth2PkceMethod, ResponseMode as UpstreamOAuth2ResponseMode, - SetEmailVerification as UpstreamOAuth2SetEmailVerification, TokenAuthMethod as UpstreamOAuth2TokenAuthMethod, UpstreamOAuth2Config, }, }; diff --git a/crates/config/src/sections/rate_limiting.rs b/crates/config/src/sections/rate_limiting.rs index e2be8c057..9ee12fd15 100644 --- a/crates/config/src/sections/rate_limiting.rs +++ b/crates/config/src/sections/rate_limiting.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -18,13 +18,19 @@ pub struct RateLimitingConfig { /// Account Recovery-specific rate limits #[serde(default)] pub account_recovery: AccountRecoveryRateLimitingConfig, + /// Login-specific rate limits #[serde(default)] pub login: LoginRateLimitingConfig, + /// Controls how many registrations attempts are permitted /// based on source address. #[serde(default = "default_registration")] pub registration: RateLimiterConfiguration, + + /// Email authentication-specific rate limits + #[serde(default)] + pub email_authentication: EmailauthenticationRateLimitingConfig, } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] @@ -37,6 +43,7 @@ pub struct LoginRateLimitingConfig { /// change their own password. #[serde(default = "default_login_per_ip")] pub per_ip: RateLimiterConfiguration, + /// Controls how many login attempts are permitted /// based on the account that is being attempted to be logged into. /// This can protect against a distributed brute force attack @@ -58,6 +65,7 @@ pub struct AccountRecoveryRateLimitingConfig { /// Note: this limit also applies to re-sends. #[serde(default = "default_account_recovery_per_ip")] pub per_ip: RateLimiterConfiguration, + /// Controls how many account recovery attempts are permitted /// based on the e-mail address entered into the recovery form. /// This can protect against causing e-mail spam to one target. @@ -67,6 +75,35 @@ pub struct AccountRecoveryRateLimitingConfig { pub per_address: RateLimiterConfiguration, } +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] +pub struct EmailauthenticationRateLimitingConfig { + /// Controls how many email authentication attempts are permitted + /// based on the source IP address. + /// This can protect against causing e-mail spam to many targets. + #[serde(default = "default_email_authentication_per_ip")] + pub per_ip: RateLimiterConfiguration, + + /// Controls how many email authentication attempts are permitted + /// based on the e-mail address entered into the authentication form. + /// This can protect against causing e-mail spam to one target. + /// + /// Note: this limit also applies to re-sends. + #[serde(default = "default_email_authentication_per_address")] + pub per_address: RateLimiterConfiguration, + + /// Controls how many authentication emails are permitted to be sent per + /// authentication session. This ensures not too many authentication codes + /// are created for the same authentication session. + #[serde(default = "default_email_authentication_emails_per_session")] + pub emails_per_session: RateLimiterConfiguration, + + /// Controls how many code authentication attempts are permitted per + /// authentication session. This can protect against brute-forcing the + /// code. + #[serde(default = "default_email_authentication_attempt_per_session")] + pub attempt_per_session: RateLimiterConfiguration, +} + #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] pub struct RateLimiterConfiguration { /// A one-off burst of actions that the user can perform @@ -193,12 +230,41 @@ fn default_account_recovery_per_address() -> RateLimiterConfiguration { } } +fn default_email_authentication_per_ip() -> RateLimiterConfiguration { + RateLimiterConfiguration { + burst: NonZeroU32::new(5).unwrap(), + per_second: 1.0 / 60.0, + } +} + +fn default_email_authentication_per_address() -> RateLimiterConfiguration { + RateLimiterConfiguration { + burst: NonZeroU32::new(3).unwrap(), + per_second: 1.0 / 3600.0, + } +} + +fn default_email_authentication_emails_per_session() -> RateLimiterConfiguration { + RateLimiterConfiguration { + burst: NonZeroU32::new(2).unwrap(), + per_second: 1.0 / 300.0, + } +} + +fn default_email_authentication_attempt_per_session() -> RateLimiterConfiguration { + RateLimiterConfiguration { + burst: NonZeroU32::new(10).unwrap(), + per_second: 1.0 / 60.0, + } +} + impl Default for RateLimitingConfig { fn default() -> Self { RateLimitingConfig { login: LoginRateLimitingConfig::default(), registration: default_registration(), account_recovery: AccountRecoveryRateLimitingConfig::default(), + email_authentication: EmailauthenticationRateLimitingConfig::default(), } } } @@ -220,3 +286,14 @@ impl Default for AccountRecoveryRateLimitingConfig { } } } + +impl Default for EmailauthenticationRateLimitingConfig { + fn default() -> Self { + EmailauthenticationRateLimitingConfig { + per_ip: default_email_authentication_per_ip(), + per_address: default_email_authentication_per_address(), + emails_per_session: default_email_authentication_emails_per_session(), + attempt_per_session: default_email_authentication_attempt_per_session(), + } + } +} diff --git a/crates/config/src/sections/upstream_oauth2.rs b/crates/config/src/sections/upstream_oauth2.rs index 6fc47f1e2..1801aa1f2 100644 --- a/crates/config/src/sections/upstream_oauth2.rs +++ b/crates/config/src/sections/upstream_oauth2.rs @@ -180,29 +180,6 @@ impl ImportAction { } } -/// Should the email address be marked as verified -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)] -#[serde(rename_all = "lowercase")] -pub enum SetEmailVerification { - /// Mark the email address as verified - Always, - - /// Don't mark the email address as verified - Never, - - /// Mark the email address as verified if the upstream provider says it is - /// through the `email_verified` claim - #[default] - Import, -} - -impl SetEmailVerification { - #[allow(clippy::trivially_copy_pass_by_ref)] - const fn is_default(&self) -> bool { - matches!(self, SetEmailVerification::Import) - } -} - /// What should be done for the subject attribute #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)] pub struct SubjectImportPreference { @@ -271,17 +248,11 @@ pub struct EmailImportPreference { /// If not provided, the default template is `{{ user.email }}` #[serde(default, skip_serializing_if = "Option::is_none")] pub template: Option, - - /// Should the email address be marked as verified - #[serde(default, skip_serializing_if = "SetEmailVerification::is_default")] - pub set_email_verification: SetEmailVerification, } impl EmailImportPreference { const fn is_default(&self) -> bool { - self.action.is_default() - && self.template.is_none() - && self.set_email_verification.is_default() + self.action.is_default() && self.template.is_none() } } diff --git a/crates/data-model/src/lib.rs b/crates/data-model/src/lib.rs index 19d7f4469..19a81f098 100644 --- a/crates/data-model/src/lib.rs +++ b/crates/data-model/src/lib.rs @@ -37,16 +37,17 @@ pub use self::{ AccessToken, AccessTokenState, RefreshToken, RefreshTokenState, TokenFormatError, TokenType, }, upstream_oauth2::{ - UpsreamOAuthProviderSetEmailVerification, UpstreamOAuthAuthorizationSession, - UpstreamOAuthAuthorizationSessionState, UpstreamOAuthLink, UpstreamOAuthProvider, - UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, - UpstreamOAuthProviderImportAction, UpstreamOAuthProviderImportPreference, - UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderResponseMode, - UpstreamOAuthProviderSubjectPreference, UpstreamOAuthProviderTokenAuthMethod, + UpstreamOAuthAuthorizationSession, UpstreamOAuthAuthorizationSessionState, + UpstreamOAuthLink, UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports, + UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderImportAction, + UpstreamOAuthProviderImportPreference, UpstreamOAuthProviderPkceMode, + UpstreamOAuthProviderResponseMode, UpstreamOAuthProviderSubjectPreference, + UpstreamOAuthProviderTokenAuthMethod, }, user_agent::{DeviceType, UserAgent}, users::{ Authentication, AuthenticationMethod, BrowserSession, Password, User, UserEmail, - UserEmailVerification, UserEmailVerificationState, UserRecoverySession, UserRecoveryTicket, + UserEmailAuthentication, UserEmailAuthenticationCode, UserRecoverySession, + UserRecoveryTicket, UserRegistration, UserRegistrationPassword, }, }; diff --git a/crates/data-model/src/upstream_oauth2/mod.rs b/crates/data-model/src/upstream_oauth2/mod.rs index bede13cb6..8f4228839 100644 --- a/crates/data-model/src/upstream_oauth2/mod.rs +++ b/crates/data-model/src/upstream_oauth2/mod.rs @@ -17,7 +17,6 @@ pub use self::{ ImportPreference as UpstreamOAuthProviderImportPreference, PkceMode as UpstreamOAuthProviderPkceMode, ResponseMode as UpstreamOAuthProviderResponseMode, - SetEmailVerification as UpsreamOAuthProviderSetEmailVerification, SubjectPreference as UpstreamOAuthProviderSubjectPreference, TokenAuthMethod as UpstreamOAuthProviderTokenAuthMethod, UpstreamOAuthProvider, }, diff --git a/crates/data-model/src/upstream_oauth2/provider.rs b/crates/data-model/src/upstream_oauth2/provider.rs index 414c25c58..b81704661 100644 --- a/crates/data-model/src/upstream_oauth2/provider.rs +++ b/crates/data-model/src/upstream_oauth2/provider.rs @@ -263,32 +263,6 @@ impl UpstreamOAuthProvider { } } -/// Whether to set the email as verified when importing it from the upstream -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum SetEmailVerification { - /// Set the email as verified - Always, - - /// Never set the email as verified - Never, - - /// Set the email as verified if the upstream provider claims it is verified - #[default] - Import, -} - -impl SetEmailVerification { - #[must_use] - pub fn should_mark_as_verified(&self, upstream_verified: bool) -> bool { - match self { - Self::Always => true, - Self::Never => false, - Self::Import => upstream_verified, - } - } -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct ClaimsImports { #[serde(default)] @@ -305,9 +279,6 @@ pub struct ClaimsImports { #[serde(default)] pub account_name: SubjectPreference, - - #[serde(default)] - pub verify_email: SetEmailVerification, } // XXX: this should have another name diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs index fe277d775..c020fa720 100644 --- a/crates/data-model/src/users.rs +++ b/crates/data-model/src/users.rs @@ -4,12 +4,13 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. -use std::{net::IpAddr, ops::Deref}; +use std::net::IpAddr; -use chrono::{DateTime, Duration, Utc}; -use rand::{Rng, SeedableRng}; +use chrono::{DateTime, Utc}; +use rand::Rng; use serde::Serialize; use ulid::Ulid; +use url::Url; use crate::UserAgent; @@ -18,7 +19,6 @@ pub struct User { pub id: Ulid, pub username: String, pub sub: String, - pub primary_user_email_id: Option, pub created_at: DateTime, pub locked_at: Option>, pub can_request_admin: bool, @@ -40,7 +40,6 @@ impl User { id: Ulid::from_datetime_with_source(now.into(), rng), username: "john".to_owned(), sub: "123-456".to_owned(), - primary_user_email_id: None, created_at: now, locked_at: None, can_request_admin: false, @@ -109,6 +108,27 @@ impl UserRecoveryTicket { } } +/// A user email authentication session +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +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>, +} + +/// A user email authentication code +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct UserEmailAuthenticationCode { + pub id: Ulid, + pub user_email_authentication_id: Ulid, + pub code: String, + pub created_at: DateTime, + pub expires_at: DateTime, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct BrowserSession { pub id: Ulid, @@ -153,7 +173,6 @@ pub struct UserEmail { pub user_id: Ulid, pub email: String, pub created_at: DateTime, - pub confirmed_at: Option>, } impl UserEmail { @@ -165,79 +184,34 @@ impl UserEmail { user_id: Ulid::from_datetime_with_source(now.into(), rng), email: "alice@example.com".to_owned(), created_at: now, - confirmed_at: Some(now), }, Self { id: Ulid::from_datetime_with_source(now.into(), rng), user_id: Ulid::from_datetime_with_source(now.into(), rng), email: "bob@example.com".to_owned(), created_at: now, - confirmed_at: None, }, ] } } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub enum UserEmailVerificationState { - AlreadyUsed { when: DateTime }, - Expired { when: DateTime }, - Valid, -} - -impl UserEmailVerificationState { - #[must_use] - pub fn is_valid(&self) -> bool { - matches!(self, Self::Valid) - } +pub struct UserRegistrationPassword { + pub hashed_password: String, + pub version: u16, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct UserEmailVerification { +pub struct UserRegistration { pub id: Ulid, - pub user_email_id: Ulid, - pub code: String, + pub username: String, + pub display_name: Option, + pub terms_url: Option, + pub email_authentication_id: Option, + pub password: Option, + pub post_auth_action: Option, + pub ip_address: Option, + pub user_agent: Option, pub created_at: DateTime, - pub state: UserEmailVerificationState, -} - -impl Deref for UserEmailVerification { - type Target = UserEmailVerificationState; - - fn deref(&self) -> &Self::Target { - &self.state - } -} - -impl UserEmailVerification { - #[doc(hidden)] - #[must_use] - pub fn samples(now: chrono::DateTime, rng: &mut impl Rng) -> Vec { - let states = [ - UserEmailVerificationState::AlreadyUsed { - when: now - Duration::microseconds(5 * 60 * 1000 * 1000), - }, - UserEmailVerificationState::Expired { - when: now - Duration::microseconds(5 * 60 * 1000 * 1000), - }, - UserEmailVerificationState::Valid, - ]; - - states - .into_iter() - .flat_map(move |state| { - let mut rng = - rand_chacha::ChaChaRng::from_rng(&mut *rng).expect("could not seed rng"); - UserEmail::samples(now, &mut rng) - .into_iter() - .map(move |email| Self { - id: Ulid::from_datetime_with_source(now.into(), &mut rng), - user_email_id: email.id, - code: "123456".to_owned(), - created_at: now - Duration::microseconds(10 * 60 * 1000 * 1000), - state: state.clone(), - }) - }) - .collect() - } + pub completed_at: Option>, } diff --git a/crates/email/src/mailer.rs b/crates/email/src/mailer.rs index 7d2fd43c4..57cf3d385 100644 --- a/crates/email/src/mailer.rs +++ b/crates/email/src/mailer.rs @@ -110,9 +110,6 @@ impl Mailer { fields( email.to = %to, email.language = %context.language(), - user.id = %context.user().id, - user_email_verification.id = %context.verification().id, - user_email_verification.code = context.verification().code, ), err, )] diff --git a/crates/handlers/src/compat/login_sso_complete.rs b/crates/handlers/src/compat/login_sso_complete.rs index c344614a6..8ebbaf73c 100644 --- a/crates/handlers/src/compat/login_sso_complete.rs +++ b/crates/handlers/src/compat/login_sso_complete.rs @@ -19,7 +19,7 @@ use mas_axum_utils::{ }; use mas_data_model::Device; use mas_matrix::BoxHomeserverConnection; -use mas_router::{CompatLoginSsoAction, PostAuthAction, UrlBuilder}; +use mas_router::{CompatLoginSsoAction, UrlBuilder}; use mas_storage::{ compat::{CompatSessionRepository, CompatSsoLoginRepository}, BoxClock, BoxRepository, BoxRng, Clock, RepositoryAccess, @@ -80,13 +80,6 @@ pub async fn get( return Ok((cookie_jar, url).into_response()); }; - // TODO: make that more generic, check that the email has been confirmed - if session.user.primary_user_email_id.is_none() { - let destination = mas_router::AccountAddEmail::default() - .and_then(PostAuthAction::continue_compat_sso_login(id)); - return Ok((cookie_jar, url_builder.redirect(&destination)).into_response()); - } - let login = repo .compat_sso_login() .lookup(id) @@ -152,13 +145,6 @@ pub async fn post( return Ok((cookie_jar, url).into_response()); }; - // TODO: make that more generic - if session.user.primary_user_email_id.is_none() { - let destination = mas_router::AccountAddEmail::default() - .and_then(PostAuthAction::continue_compat_sso_login(id)); - return Ok((cookie_jar, url_builder.redirect(&destination)).into_response()); - } - let login = repo .compat_sso_login() .lookup(id) diff --git a/crates/handlers/src/graphql/model/mod.rs b/crates/handlers/src/graphql/model/mod.rs index be1fb346c..54bddb2a4 100644 --- a/crates/handlers/src/graphql/model/mod.rs +++ b/crates/handlers/src/graphql/model/mod.rs @@ -26,7 +26,7 @@ pub use self::{ oauth::{OAuth2Client, OAuth2Session}, site_config::{SiteConfig, SITE_CONFIG_ID}, upstream_oauth::{UpstreamOAuth2Link, UpstreamOAuth2Provider}, - users::{AppSession, User, UserEmail, UserRecoveryTicket}, + users::{AppSession, User, UserEmail, UserEmailAuthentication, UserRecoveryTicket}, viewer::{Anonymous, Viewer, ViewerSession}, }; @@ -42,6 +42,7 @@ pub enum CreationEvent { CompatSession(Box), BrowserSession(Box), UserEmail(Box), + UserEmailAuthentication(Box), UserRecoveryTicket(Box), UpstreamOAuth2Provider(Box), UpstreamOAuth2Link(Box), diff --git a/crates/handlers/src/graphql/model/node.rs b/crates/handlers/src/graphql/model/node.rs index 4e899638b..592aae2c7 100644 --- a/crates/handlers/src/graphql/model/node.rs +++ b/crates/handlers/src/graphql/model/node.rs @@ -12,7 +12,7 @@ use ulid::Ulid; use super::{ Anonymous, Authentication, BrowserSession, CompatSession, CompatSsoLogin, OAuth2Client, OAuth2Session, SiteConfig, UpstreamOAuth2Link, UpstreamOAuth2Provider, User, UserEmail, - UserRecoveryTicket, + UserEmailAuthentication, UserRecoveryTicket, }; #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -27,6 +27,7 @@ pub enum NodeType { UpstreamOAuth2Link, User, UserEmail, + UserEmailAuthentication, UserRecoveryTicket, } @@ -52,6 +53,7 @@ impl NodeType { NodeType::UpstreamOAuth2Link => "upstream_oauth2_link", NodeType::User => "user", NodeType::UserEmail => "user_email", + NodeType::UserEmailAuthentication => "user_email_authentication", NodeType::UserRecoveryTicket => "user_recovery_ticket", } } @@ -68,6 +70,7 @@ impl NodeType { "upstream_oauth2_link" => Some(NodeType::UpstreamOAuth2Link), "user" => Some(NodeType::User), "user_email" => Some(NodeType::UserEmail), + "user_email_authentication" => Some(NodeType::UserEmailAuthentication), "user_recovery_ticket" => Some(NodeType::UserRecoveryTicket), _ => None, } @@ -124,5 +127,6 @@ pub enum Node { UpstreamOAuth2Link(Box), User(Box), UserEmail(Box), + UserEmailAuthentication(Box), UserRecoveryTicket(Box), } diff --git a/crates/handlers/src/graphql/model/users.rs b/crates/handlers/src/graphql/model/users.rs index 77606a1d8..15cd95206 100644 --- a/crates/handlers/src/graphql/model/users.rs +++ b/crates/handlers/src/graphql/model/users.rs @@ -78,19 +78,6 @@ impl User { Ok(MatrixUser::load(conn, &self.0.username).await?) } - /// Primary email address of the user. - async fn primary_email( - &self, - ctx: &Context<'_>, - ) -> Result, async_graphql::Error> { - let state = ctx.state(); - let mut repo = state.repository().await?; - - let user_email = repo.user_email().get_primary(&self.0).await?.map(UserEmail); - repo.cancel().await?; - Ok(user_email) - } - /// Get the list of compatibility SSO logins, chronologically sorted async fn compat_sso_logins( &self, @@ -336,7 +323,11 @@ impl User { &self, ctx: &Context<'_>, - #[graphql(name = "state", desc = "List only emails in the given state.")] + #[graphql( + deprecation = "Emails are always confirmed, and have only one state", + name = "state", + desc = "List only emails in the given state." + )] state_param: Option, #[graphql(desc = "Returns the elements in the list that come after the cursor.")] @@ -348,6 +339,7 @@ impl User { ) -> Result, async_graphql::Error> { let state = ctx.state(); let mut repo = state.repository().await?; + let _ = state_param; query( after, @@ -365,12 +357,6 @@ impl User { let filter = UserEmailFilter::new().for_user(&self.0); - let filter = match state_param { - Some(UserEmailState::Pending) => filter.pending_only(), - Some(UserEmailState::Confirmed) => filter.verified_only(), - None => filter, - }; - let page = repo.user_email().list(filter, pagination).await?; // Preload the total count if requested @@ -751,8 +737,9 @@ impl UserEmail { /// When the email address was confirmed. Is `null` if the email was never /// verified by the user. + #[graphql(deprecation = "Emails are always confirmed now.")] async fn confirmed_at(&self) -> Option> { - self.0.confirmed_at + Some(self.0.created_at) } } @@ -863,3 +850,30 @@ impl UserRecoveryTicket { Ok(user_email.email) } } + +/// A email authentication session +#[derive(Description)] +pub struct UserEmailAuthentication(pub mas_data_model::UserEmailAuthentication); + +#[Object(use_type_description)] +impl UserEmailAuthentication { + /// ID of the object. + pub async fn id(&self) -> ID { + NodeType::UserEmailAuthentication.id(self.0.id) + } + + /// When the object was created. + pub async fn created_at(&self) -> DateTime { + self.0.created_at + } + + /// When the object was last updated. + pub async fn completed_at(&self) -> Option> { + self.0.completed_at + } + + /// The email address associated with this session + pub async fn email(&self) -> &str { + &self.0.email + } +} diff --git a/crates/handlers/src/graphql/mutations/user.rs b/crates/handlers/src/graphql/mutations/user.rs index 7bfb4c3d6..52c661b05 100644 --- a/crates/handlers/src/graphql/mutations/user.rs +++ b/crates/handlers/src/graphql/mutations/user.rs @@ -778,8 +778,6 @@ impl UserMutations { .user_email() .lookup(ticket.user_email_id) .await? - // Only allow confirmed email addresses - .filter(|email| email.confirmed_at.is_some()) .context("Unknown email address")?; let user = repo diff --git a/crates/handlers/src/graphql/mutations/user_email.rs b/crates/handlers/src/graphql/mutations/user_email.rs index 08604f652..38048d39a 100644 --- a/crates/handlers/src/graphql/mutations/user_email.rs +++ b/crates/handlers/src/graphql/mutations/user_email.rs @@ -6,16 +6,16 @@ use anyhow::Context as _; use async_graphql::{Context, Description, Enum, InputObject, Object, ID}; +use mas_i18n::DataLocale; use mas_storage::{ - queue::{ProvisionUserJob, QueueJobRepositoryExt as _, VerifyEmailJob}, - user::{UserEmailRepository, UserRepository}, + queue::{ProvisionUserJob, QueueJobRepositoryExt as _, SendEmailAuthenticationCodeJob}, + user::{UserEmailFilter, UserEmailRepository, UserRepository}, RepositoryAccess, }; use crate::graphql::{ - model::{NodeType, User, UserEmail}, + model::{NodeType, User, UserEmail, UserEmailAuthentication}, state::ContextExt, - UserId, }; #[derive(Default)] @@ -115,140 +115,6 @@ impl AddEmailPayload { } } -/// The input for the `sendVerificationEmail` mutation -#[derive(InputObject)] -struct SendVerificationEmailInput { - /// The ID of the email address to verify - user_email_id: ID, -} - -/// The status of the `sendVerificationEmail` mutation -#[derive(Enum, Copy, Clone, Eq, PartialEq)] -enum SendVerificationEmailStatus { - /// The verification email was sent - Sent, - /// The email address is already verified - AlreadyVerified, -} - -/// The payload of the `sendVerificationEmail` mutation -#[derive(Description)] -enum SendVerificationEmailPayload { - Sent(mas_data_model::UserEmail), - AlreadyVerified(mas_data_model::UserEmail), -} - -#[Object(use_type_description)] -impl SendVerificationEmailPayload { - /// Status of the operation - async fn status(&self) -> SendVerificationEmailStatus { - match self { - SendVerificationEmailPayload::Sent(_) => SendVerificationEmailStatus::Sent, - SendVerificationEmailPayload::AlreadyVerified(_) => { - SendVerificationEmailStatus::AlreadyVerified - } - } - } - - /// The email address to which the verification email was sent - async fn email(&self) -> UserEmail { - match self { - SendVerificationEmailPayload::Sent(email) - | SendVerificationEmailPayload::AlreadyVerified(email) => UserEmail(email.clone()), - } - } - - /// The user to whom the email address belongs - async fn user(&self, ctx: &Context<'_>) -> Result { - let state = ctx.state(); - let mut repo = state.repository().await?; - - let user_id = match self { - SendVerificationEmailPayload::Sent(email) - | SendVerificationEmailPayload::AlreadyVerified(email) => email.user_id, - }; - - let user = repo - .user() - .lookup(user_id) - .await? - .context("User not found")?; - - Ok(User(user)) - } -} - -/// The input for the `verifyEmail` mutation -#[derive(InputObject)] -struct VerifyEmailInput { - /// The ID of the email address to verify - user_email_id: ID, - /// The verification code - code: String, -} - -/// The status of the `verifyEmail` mutation -#[derive(Enum, Copy, Clone, Eq, PartialEq)] -enum VerifyEmailStatus { - /// The email address was just verified - Verified, - /// The email address was already verified before - AlreadyVerified, - /// The verification code is invalid - InvalidCode, -} - -/// The payload of the `verifyEmail` mutation -#[derive(Description)] -enum VerifyEmailPayload { - Verified(mas_data_model::UserEmail), - AlreadyVerified(mas_data_model::UserEmail), - InvalidCode, -} - -#[Object(use_type_description)] -impl VerifyEmailPayload { - /// Status of the operation - async fn status(&self) -> VerifyEmailStatus { - match self { - VerifyEmailPayload::Verified(_) => VerifyEmailStatus::Verified, - VerifyEmailPayload::AlreadyVerified(_) => VerifyEmailStatus::AlreadyVerified, - VerifyEmailPayload::InvalidCode => VerifyEmailStatus::InvalidCode, - } - } - - /// The email address that was verified - async fn email(&self) -> Option { - match self { - VerifyEmailPayload::Verified(email) | VerifyEmailPayload::AlreadyVerified(email) => { - Some(UserEmail(email.clone())) - } - VerifyEmailPayload::InvalidCode => None, - } - } - - /// The user to whom the email address belongs - async fn user(&self, ctx: &Context<'_>) -> Result, async_graphql::Error> { - let state = ctx.state(); - let mut repo = state.repository().await?; - - let user_id = match self { - VerifyEmailPayload::Verified(email) | VerifyEmailPayload::AlreadyVerified(email) => { - email.user_id - } - VerifyEmailPayload::InvalidCode => return Ok(None), - }; - - let user = repo - .user() - .lookup(user_id) - .await? - .context("User not found")?; - - Ok(Some(User(user))) - } -} - /// The input for the `removeEmail` mutation #[derive(InputObject)] struct RemoveEmailInput { @@ -262,9 +128,6 @@ enum RemoveEmailStatus { /// The email address was removed Removed, - /// Can't remove the primary email address - Primary, - /// The email address was not found NotFound, } @@ -273,7 +136,6 @@ enum RemoveEmailStatus { #[derive(Description)] enum RemoveEmailPayload { Removed(mas_data_model::UserEmail), - Primary(mas_data_model::UserEmail), NotFound, } @@ -283,7 +145,6 @@ impl RemoveEmailPayload { async fn status(&self) -> RemoveEmailStatus { match self { RemoveEmailPayload::Removed(_) => RemoveEmailStatus::Removed, - RemoveEmailPayload::Primary(_) => RemoveEmailStatus::Primary, RemoveEmailPayload::NotFound => RemoveEmailStatus::NotFound, } } @@ -291,9 +152,7 @@ impl RemoveEmailPayload { /// The email address that was removed async fn email(&self) -> Option { match self { - RemoveEmailPayload::Removed(email) | RemoveEmailPayload::Primary(email) => { - Some(UserEmail(email.clone())) - } + RemoveEmailPayload::Removed(email) => Some(UserEmail(email.clone())), RemoveEmailPayload::NotFound => None, } } @@ -304,9 +163,7 @@ impl RemoveEmailPayload { let mut repo = state.repository().await?; let user_id = match self { - RemoveEmailPayload::Removed(email) | RemoveEmailPayload::Primary(email) => { - email.user_id - } + RemoveEmailPayload::Removed(email) => email.user_id, RemoveEmailPayload::NotFound => return Ok(None), }; @@ -343,7 +200,6 @@ enum SetPrimaryEmailStatus { enum SetPrimaryEmailPayload { Set(mas_data_model::User), NotFound, - Unverified, } #[Object(use_type_description)] @@ -352,7 +208,6 @@ impl SetPrimaryEmailPayload { match self { SetPrimaryEmailPayload::Set(_) => SetPrimaryEmailStatus::Set, SetPrimaryEmailPayload::NotFound => SetPrimaryEmailStatus::NotFound, - SetPrimaryEmailPayload::Unverified => SetPrimaryEmailStatus::Unverified, } } @@ -360,7 +215,173 @@ impl SetPrimaryEmailPayload { async fn user(&self) -> Option { match self { SetPrimaryEmailPayload::Set(user) => Some(User(user.clone())), - SetPrimaryEmailPayload::NotFound | SetPrimaryEmailPayload::Unverified => None, + SetPrimaryEmailPayload::NotFound => None, + } + } +} + +/// The input for the `startEmailAuthentication` mutation +#[derive(InputObject)] +struct StartEmailAuthenticationInput { + /// The email address to add to the account + email: String, + + /// The language to use for the email + #[graphql(default = "en")] + language: String, +} + +/// The status of the `startEmailAuthentication` mutation +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +enum StartEmailAuthenticationStatus { + /// The email address was started + Started, + /// The email address is invalid + InvalidEmailAddress, + /// Too many attempts to start an email authentication + RateLimited, + /// The email address isn't allowed by the policy + Denied, + /// The email address is already in use on this account + InUse, +} + +/// The payload of the `startEmailAuthentication` mutation +#[derive(Description)] +enum StartEmailAuthenticationPayload { + Started(UserEmailAuthentication), + InvalidEmailAddress, + RateLimited, + Denied { + violations: Vec, + }, + InUse, +} + +#[Object(use_type_description)] +impl StartEmailAuthenticationPayload { + /// Status of the operation + async fn status(&self) -> StartEmailAuthenticationStatus { + match self { + Self::Started(_) => StartEmailAuthenticationStatus::Started, + Self::InvalidEmailAddress => StartEmailAuthenticationStatus::InvalidEmailAddress, + Self::RateLimited => StartEmailAuthenticationStatus::RateLimited, + Self::Denied { .. } => StartEmailAuthenticationStatus::Denied, + Self::InUse => StartEmailAuthenticationStatus::InUse, + } + } + + /// The email authentication session that was started + async fn authentication(&self) -> Option<&UserEmailAuthentication> { + match self { + Self::Started(authentication) => Some(authentication), + Self::InvalidEmailAddress | Self::RateLimited | Self::Denied { .. } | Self::InUse => { + None + } + } + } + + /// The list of policy violations if the email address was denied + async fn violations(&self) -> Option> { + let Self::Denied { violations } = self else { + return None; + }; + + let messages = violations.iter().map(|v| v.msg.clone()).collect(); + Some(messages) + } +} + +/// The input for the `completeEmailAuthentication` mutation +#[derive(InputObject)] +struct CompleteEmailAuthenticationInput { + /// The authentication code to use + code: String, + + /// The ID of the authentication session to complete + id: ID, +} + +/// The payload of the `completeEmailAuthentication` mutation +#[derive(Description)] +enum CompleteEmailAuthenticationPayload { + Completed, + InvalidCode, + CodeExpired, + InUse, + RateLimited, +} + +/// The status of the `completeEmailAuthentication` mutation +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +enum CompleteEmailAuthenticationStatus { + /// The authentication was completed + Completed, + /// The authentication code is invalid + InvalidCode, + /// The authentication code has expired + CodeExpired, + /// Too many attempts to complete an email authentication + RateLimited, + /// The email address is already in use + InUse, +} + +#[Object(use_type_description)] +impl CompleteEmailAuthenticationPayload { + /// Status of the operation + async fn status(&self) -> CompleteEmailAuthenticationStatus { + match self { + Self::Completed => CompleteEmailAuthenticationStatus::Completed, + Self::InvalidCode => CompleteEmailAuthenticationStatus::InvalidCode, + Self::CodeExpired => CompleteEmailAuthenticationStatus::CodeExpired, + Self::InUse => CompleteEmailAuthenticationStatus::InUse, + Self::RateLimited => CompleteEmailAuthenticationStatus::RateLimited, + } + } +} + +/// The input for the `resendEmailAuthenticationCode` mutation +#[derive(InputObject)] +struct ResendEmailAuthenticationCodeInput { + /// The ID of the authentication session to resend the code for + id: ID, + + /// The language to use for the email + #[graphql(default = "en")] + language: String, +} + +/// The payload of the `resendEmailAuthenticationCode` mutation +#[derive(Description)] +enum ResendEmailAuthenticationCodePayload { + /// The email was resent + Resent, + /// The email authentication session is already completed + Completed, + /// Too many attempts to resend an email authentication code + RateLimited, +} + +/// The status of the `resendEmailAuthenticationCode` mutation +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +enum ResendEmailAuthenticationCodeStatus { + /// The email was resent + Resent, + /// The email authentication session is already completed + Completed, + /// Too many attempts to resend an email authentication code + RateLimited, +} + +#[Object(use_type_description)] +impl ResendEmailAuthenticationCodePayload { + /// Status of the operation + async fn status(&self) -> ResendEmailAuthenticationCodeStatus { + match self { + Self::Resent => ResendEmailAuthenticationCodeStatus::Resent, + Self::Completed => ResendEmailAuthenticationCodeStatus::Completed, + Self::RateLimited => ResendEmailAuthenticationCodeStatus::RateLimited, } } } @@ -368,6 +389,7 @@ impl SetPrimaryEmailPayload { #[Object] impl UserEmailMutations { /// Add an email address to the specified user + #[graphql(deprecation = "Use `startEmailAuthentication` instead.")] async fn add_email( &self, ctx: &Context<'_>, @@ -379,23 +401,12 @@ impl UserEmailMutations { let clock = state.clock(); let mut rng = state.rng(); - if !requester.is_owner_or_admin(&UserId(id)) { + // Only allow admin to call this mutation + if !requester.is_admin() { return Err(async_graphql::Error::new("Unauthorized")); } - // Allow non-admins to change their email address if the site config allows it - if !requester.is_admin() && !state.site_config().email_change_allowed { - return Err(async_graphql::Error::new("Unauthorized")); - } - - // Only admins can skip validation - if (input.skip_verification.is_some() || input.skip_policy_check.is_some()) - && !requester.is_admin() - { - return Err(async_graphql::Error::new("Unauthorized")); - } - - let skip_verification = input.skip_verification.unwrap_or(false); + let _skip_verification = input.skip_verification.unwrap_or(false); let skip_policy_check = input.skip_policy_check.unwrap_or(false); let mut repo = state.repository().await?; @@ -406,9 +417,6 @@ impl UserEmailMutations { .await? .context("Failed to load user")?; - // XXX: this logic should be extracted somewhere else, since most of it is - // duplicated in mas_handlers - // Validate the email address if input.email.parse::().is_err() { return Ok(AddEmailPayload::Invalid); @@ -426,7 +434,7 @@ impl UserEmailMutations { // Find an existing email address let existing_user_email = repo.user_email().find(&user, &input.email).await?; - let (added, mut user_email) = if let Some(user_email) = existing_user_email { + let (added, user_email) = if let Some(user_email) = existing_user_email { (false, user_email) } else { let user_email = repo @@ -437,21 +445,6 @@ impl UserEmailMutations { (true, user_email) }; - // Schedule a job to verify the email address if needed - if user_email.confirmed_at.is_none() { - if skip_verification { - user_email = repo - .user_email() - .mark_as_verified(&state.clock(), user_email) - .await?; - } else { - // TODO: figure out the locale - repo.queue_job() - .schedule_job(&mut rng, &clock, VerifyEmailJob::new(&user_email)) - .await?; - } - } - repo.save().await?; let payload = if added { @@ -462,215 +455,333 @@ impl UserEmailMutations { Ok(payload) } - /// Send a verification code for an email address - async fn send_verification_email( + /// Remove an email address + async fn remove_email( &self, ctx: &Context<'_>, - input: SendVerificationEmailInput, - ) -> Result { + input: RemoveEmailInput, + ) -> Result { let state = ctx.state(); - let clock = state.clock(); - let mut rng = state.rng(); let user_email_id = NodeType::UserEmail.extract_ulid(&input.user_email_id)?; let requester = ctx.requester(); + let mut rng = state.rng(); + let clock = state.clock(); let mut repo = state.repository().await?; - let user_email = repo - .user_email() - .lookup(user_email_id) - .await? - .context("User email not found")?; + let user_email = repo.user_email().lookup(user_email_id).await?; + let Some(user_email) = user_email else { + return Ok(RemoveEmailPayload::NotFound); + }; if !requester.is_owner_or_admin(&user_email) { - return Err(async_graphql::Error::new("User email not found")); + return Ok(RemoveEmailPayload::NotFound); } - // Schedule a job to verify the email address if needed - let needs_verification = user_email.confirmed_at.is_none(); - if needs_verification { - // TODO: figure out the locale - repo.queue_job() - .schedule_job(&mut rng, &clock, VerifyEmailJob::new(&user_email)) - .await?; + // Allow non-admins to remove their email address if the site config allows it + if !requester.is_admin() && !state.site_config().email_change_allowed { + return Err(async_graphql::Error::new("Unauthorized")); } + let user = repo + .user() + .lookup(user_email.user_id) + .await? + .context("Failed to load user")?; + + // TODO: don't allow removing the last email address + + repo.user_email().remove(user_email.clone()).await?; + + // Schedule a job to update the user + repo.queue_job() + .schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user)) + .await?; + repo.save().await?; - let payload = if needs_verification { - SendVerificationEmailPayload::Sent(user_email) - } else { - SendVerificationEmailPayload::AlreadyVerified(user_email) - }; - Ok(payload) + Ok(RemoveEmailPayload::Removed(user_email)) } - /// Submit a verification code for an email address - async fn verify_email( + /// Set an email address as primary + #[graphql( + deprecation = "This doesn't do anything anymore, but is kept to avoid breaking existing queries" + )] + async fn set_primary_email( &self, ctx: &Context<'_>, - input: VerifyEmailInput, - ) -> Result { + input: SetPrimaryEmailInput, + ) -> Result { let state = ctx.state(); let user_email_id = NodeType::UserEmail.extract_ulid(&input.user_email_id)?; let requester = ctx.requester(); - let clock = state.clock(); - let mut rng = state.rng(); let mut repo = state.repository().await?; - let user_email = repo - .user_email() - .lookup(user_email_id) - .await? - .context("User email not found")?; + let user_email = repo.user_email().lookup(user_email_id).await?; + let Some(user_email) = user_email else { + return Ok(SetPrimaryEmailPayload::NotFound); + }; if !requester.is_owner_or_admin(&user_email) { - return Err(async_graphql::Error::new("User email not found")); + return Err(async_graphql::Error::new("Unauthorized")); } - if user_email.confirmed_at.is_some() { - // Just return the email address if it's already verified - // XXX: should we return an error instead? - return Ok(VerifyEmailPayload::AlreadyVerified(user_email)); + // Allow non-admins to change their primary email address if the site config + // allows it + if !requester.is_admin() && !state.site_config().email_change_allowed { + return Err(async_graphql::Error::new("Unauthorized")); } - // XXX: this logic should be extracted somewhere else, since most of it is - // duplicated in mas_handlers - - // Find the verification code - let verification = repo - .user_email() - .find_verification_code(&clock, &user_email, &input.code) - .await? - .filter(|v| v.is_valid()); - - let Some(verification) = verification else { - return Ok(VerifyEmailPayload::InvalidCode); - }; - - repo.user_email() - .consume_verification_code(&clock, verification) - .await?; - + // The user primary email should already be up to date let user = repo .user() .lookup(user_email.user_id) .await? .context("Failed to load user")?; - // XXX: is this the right place to do this? - if user.primary_user_email_id.is_none() { - repo.user_email().set_as_primary(&user_email).await?; + repo.save().await?; + + Ok(SetPrimaryEmailPayload::Set(user)) + } + + /// Start a new email authentication flow + async fn start_email_authentication( + &self, + ctx: &Context<'_>, + input: StartEmailAuthenticationInput, + ) -> Result { + let state = ctx.state(); + let mut rng = state.rng(); + let clock = state.clock(); + let requester = ctx.requester(); + let limiter = state.limiter(); + + // Only allow calling this if the requester is a browser session + let Some(browser_session) = requester.browser_session() else { + return Err(async_graphql::Error::new("Unauthorized")); + }; + + // Allow to starting the email authentication flow if the site config allows it + if !state.site_config().email_change_allowed { + return Err(async_graphql::Error::new( + "Email changes are not allowed on this server", + )); + } + + if !state.site_config().email_change_allowed { + return Err(async_graphql::Error::new( + "Email authentication is not allowed on this server", + )); + } + + // Check if the locale is valid + let _: DataLocale = input.language.parse()?; + + // Check if the email address is valid + if input.email.parse::().is_err() { + return Ok(StartEmailAuthenticationPayload::InvalidEmailAddress); + } + + if let Err(e) = + limiter.check_email_authentication_email(ctx.requester_fingerprint(), &input.email) + { + tracing::warn!(error = &e as &dyn std::error::Error); + return Ok(StartEmailAuthenticationPayload::RateLimited); } - let user_email = repo + let mut repo = state.repository().await?; + + // Check if the email address is already in use by the same user + // We don't report here if the email address is already in use by another user, + // because we don't want to leak information about other users. We will do that + // only when the user enters the right verification code + let count = repo .user_email() - .mark_as_verified(&clock, user_email) + .count( + UserEmailFilter::new() + .for_email(&input.email) + .for_user(&browser_session.user), + ) + .await?; + if count > 0 { + return Ok(StartEmailAuthenticationPayload::InUse); + } + + // Check if the email address is allowed by the policy + let mut policy = state.policy().await?; + let res = policy.evaluate_email(&input.email).await?; + if !res.valid() { + return Ok(StartEmailAuthenticationPayload::Denied { + violations: res.violations, + }); + } + + // Create a new authentication session + let authentication = repo + .user_email() + .add_authentication_for_session(&mut rng, &clock, input.email, browser_session) .await?; repo.queue_job() - .schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user)) + .schedule_job( + &mut rng, + &clock, + SendEmailAuthenticationCodeJob::new(&authentication, input.language), + ) .await?; repo.save().await?; - Ok(VerifyEmailPayload::Verified(user_email)) + Ok(StartEmailAuthenticationPayload::Started( + UserEmailAuthentication(authentication), + )) } - /// Remove an email address - async fn remove_email( + /// Resend the email authentication code + async fn resend_email_authentication_code( &self, ctx: &Context<'_>, - input: RemoveEmailInput, - ) -> Result { + input: ResendEmailAuthenticationCodeInput, + ) -> Result { let state = ctx.state(); - let user_email_id = NodeType::UserEmail.extract_ulid(&input.user_email_id)?; - let requester = ctx.requester(); - let mut rng = state.rng(); let clock = state.clock(); - let mut repo = state.repository().await?; + let limiter = state.limiter(); - let user_email = repo.user_email().lookup(user_email_id).await?; - let Some(user_email) = user_email else { - return Ok(RemoveEmailPayload::NotFound); + let id = NodeType::UserEmailAuthentication.extract_ulid(&input.id)?; + let Some(browser_session) = ctx.requester().browser_session() else { + return Err(async_graphql::Error::new("Unauthorized")); }; - if !requester.is_owner_or_admin(&user_email) { - return Ok(RemoveEmailPayload::NotFound); + // Allow to completing the email authentication flow if the site config allows + // it + if !state.site_config().email_change_allowed { + return Err(async_graphql::Error::new( + "Email changes are not allowed on this server", + )); } - // Allow non-admins to remove their email address if the site config allows it - if !requester.is_admin() && !state.site_config().email_change_allowed { + // Check if the locale is valid + let _: DataLocale = input.language.parse()?; + + let mut repo = state.repository().await?; + + let Some(authentication) = repo.user_email().lookup_authentication(id).await? else { + return Ok(ResendEmailAuthenticationCodePayload::Completed); + }; + + // Make sure this authentication belongs to the requester + if authentication.user_session_id != Some(browser_session.id) { return Err(async_graphql::Error::new("Unauthorized")); } - let user = repo - .user() - .lookup(user_email.user_id) - .await? - .context("Failed to load user")?; - - if user.primary_user_email_id == Some(user_email.id) { - // Prevent removing the primary email address - return Ok(RemoveEmailPayload::Primary(user_email)); + if authentication.completed_at.is_some() { + return Ok(ResendEmailAuthenticationCodePayload::Completed); } - repo.user_email().remove(user_email.clone()).await?; + if let Err(e) = limiter + .check_email_authentication_send_code(ctx.requester_fingerprint(), &authentication) + { + tracing::warn!(error = &e as &dyn std::error::Error); + return Ok(ResendEmailAuthenticationCodePayload::RateLimited); + } - // Schedule a job to update the user repo.queue_job() - .schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user)) + .schedule_job( + &mut rng, + &clock, + SendEmailAuthenticationCodeJob::new(&authentication, input.language), + ) .await?; repo.save().await?; - Ok(RemoveEmailPayload::Removed(user_email)) + Ok(ResendEmailAuthenticationCodePayload::Resent) } - /// Set an email address as primary - async fn set_primary_email( + /// Complete the email authentication flow + async fn complete_email_authentication( &self, ctx: &Context<'_>, - input: SetPrimaryEmailInput, - ) -> Result { + input: CompleteEmailAuthenticationInput, + ) -> Result { let state = ctx.state(); - let user_email_id = NodeType::UserEmail.extract_ulid(&input.user_email_id)?; - let requester = ctx.requester(); + let mut rng = state.rng(); + let clock = state.clock(); + let limiter = state.limiter(); + + let id = NodeType::UserEmailAuthentication.extract_ulid(&input.id)?; + + let Some(browser_session) = ctx.requester().browser_session() else { + return Err(async_graphql::Error::new("Unauthorized")); + }; + + // Allow to completing the email authentication flow if the site config allows + // it + if !state.site_config().email_change_allowed { + return Err(async_graphql::Error::new( + "Email changes are not allowed on this server", + )); + } let mut repo = state.repository().await?; - let user_email = repo.user_email().lookup(user_email_id).await?; - let Some(user_email) = user_email else { - return Ok(SetPrimaryEmailPayload::NotFound); + let Some(authentication) = repo.user_email().lookup_authentication(id).await? else { + return Ok(CompleteEmailAuthenticationPayload::InvalidCode); }; - if !requester.is_owner_or_admin(&user_email) { - return Err(async_graphql::Error::new("Unauthorized")); + // Make sure this authentication belongs to the requester + if authentication.user_session_id != Some(browser_session.id) { + return Ok(CompleteEmailAuthenticationPayload::InvalidCode); } - // Allow non-admins to change their primary email address if the site config - // allows it - if !requester.is_admin() && !state.site_config().email_change_allowed { - return Err(async_graphql::Error::new("Unauthorized")); + if let Err(e) = limiter.check_email_authentication_attempt(&authentication) { + tracing::warn!(error = &e as &dyn std::error::Error); + return Ok(CompleteEmailAuthenticationPayload::RateLimited); } - if user_email.confirmed_at.is_none() { - return Ok(SetPrimaryEmailPayload::Unverified); + let Some(code) = repo + .user_email() + .find_authentication_code(&authentication, &input.code) + .await? + else { + return Ok(CompleteEmailAuthenticationPayload::InvalidCode); + }; + + if code.expires_at < state.clock().now() { + return Ok(CompleteEmailAuthenticationPayload::CodeExpired); } - repo.user_email().set_as_primary(&user_email).await?; + let authentication = repo + .user_email() + .complete_authentication(&clock, authentication, &code) + .await?; - // The user primary email should already be up to date - let user = repo - .user() - .lookup(user_email.user_id) - .await? - .context("Failed to load user")?; + // Check the email is not already in use by anyone, including the current user + let count = repo + .user_email() + .count(UserEmailFilter::new().for_email(&authentication.email)) + .await?; + + if count > 0 { + // We still want to consume the code so that it can't be reused + repo.save().await?; + + return Ok(CompleteEmailAuthenticationPayload::InUse); + } + + repo.user_email() + .add( + &mut rng, + &clock, + &browser_session.user, + authentication.email, + ) + .await?; repo.save().await?; - Ok(SetPrimaryEmailPayload::Set(user)) + Ok(CompleteEmailAuthenticationPayload::Completed) } } diff --git a/crates/handlers/src/graphql/query/mod.rs b/crates/handlers/src/graphql/query/mod.rs index ab57a5f0b..464657c47 100644 --- a/crates/handlers/src/graphql/query/mod.rs +++ b/crates/handlers/src/graphql/query/mod.rs @@ -22,6 +22,7 @@ mod viewer; use self::{ session::SessionQuery, upstream_oauth::UpstreamOAuthQuery, user::UserQuery, viewer::ViewerQuery, }; +use super::model::UserEmailAuthentication; /// The query root of the GraphQL interface. #[derive(Default, MergedObject)] @@ -196,6 +197,32 @@ impl BaseQuery { Ok(ticket.map(UserRecoveryTicket)) } + /// Fetch a user email authentication session + async fn user_email_authentication( + &self, + ctx: &Context<'_>, + id: ID, + ) -> Result, async_graphql::Error> { + let state = ctx.state(); + let id = NodeType::UserEmailAuthentication.extract_ulid(&id)?; + let requester = ctx.requester(); + let mut repo = state.repository().await?; + let authentication = repo.user_email().lookup_authentication(id).await?; + let Some(authentication) = authentication else { + return Ok(None); + }; + + let Some(browser_session) = requester.browser_session() else { + return Ok(None); + }; + + if authentication.user_session_id != Some(browser_session.id) { + return Ok(None); + } + + Ok(Some(UserEmailAuthentication(authentication))) + } + /// Fetches an object given its ID. async fn node(&self, ctx: &Context<'_>, id: ID) -> Result, async_graphql::Error> { // Special case for the anonymous user @@ -237,6 +264,11 @@ impl BaseQuery { .await? .map(|e| Node::UserEmail(Box::new(e))), + NodeType::UserEmailAuthentication => self + .user_email_authentication(ctx, id) + .await? + .map(|e| Node::UserEmailAuthentication(Box::new(e))), + NodeType::CompatSession => self .compat_session(ctx, id) .await? diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 286ceb4b3..1dd7b5ef0 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -24,7 +24,6 @@ use axum::{ routing::{get, post}, Extension, Router, }; -use graphql::ExtraRouterParameters; use headers::HeaderName; use hyper::{ header::{ @@ -42,11 +41,12 @@ use mas_router::{Route, UrlBuilder}; use mas_storage::{BoxClock, BoxRepository, BoxRng}; use mas_templates::{ErrorContext, NotFoundContext, TemplateContext, Templates}; use opentelemetry::metrics::Meter; -use passwords::PasswordManager; use sqlx::PgPool; use tower::util::AndThenLayer; use tower_http::cors::{Any, CorsLayer}; +use self::{graphql::ExtraRouterParameters, passwords::PasswordManager}; + mod admin; mod compat; mod graphql; @@ -376,17 +376,21 @@ where ) .route( mas_router::PasswordRegister::route(), - get(self::views::password_register::get).post(self::views::password_register::post), + get(self::views::register::password::get).post(self::views::register::password::post), + ) + .route( + mas_router::RegisterVerifyEmail::route(), + get(self::views::register::steps::verify_email::get) + .post(self::views::register::steps::verify_email::post), ) .route( - mas_router::AccountVerifyEmail::route(), - get(self::views::account::emails::verify::get) - .post(self::views::account::emails::verify::post), + mas_router::RegisterDisplayName::route(), + get(self::views::register::steps::display_name::get) + .post(self::views::register::steps::display_name::post), ) .route( - mas_router::AccountAddEmail::route(), - get(self::views::account::emails::add::get) - .post(self::views::account::emails::add::post), + mas_router::RegisterFinish::route(), + get(self::views::register::steps::finish::get), ) .route( mas_router::AccountRecoveryStart::route(), diff --git a/crates/handlers/src/oauth2/userinfo.rs b/crates/handlers/src/oauth2/userinfo.rs index c3223482b..6e5945376 100644 --- a/crates/handlers/src/oauth2/userinfo.rs +++ b/crates/handlers/src/oauth2/userinfo.rs @@ -21,10 +21,7 @@ use mas_jose::{ }; use mas_keystore::Keystore; use mas_router::UrlBuilder; -use mas_storage::{ - oauth2::OAuth2ClientRepository, user::UserEmailRepository, BoxClock, BoxRepository, BoxRng, -}; -use oauth2_types::scope; +use mas_storage::{oauth2::OAuth2ClientRepository, BoxClock, BoxRepository, BoxRng}; use serde::Serialize; use serde_with::skip_serializing_none; use thiserror::Error; @@ -36,8 +33,6 @@ use crate::{impl_from_error_for_route, BoundActivityTracker}; struct UserInfo { sub: String, username: String, - email: Option, - email_verified: Option, } #[derive(Serialize)] @@ -123,17 +118,9 @@ pub async fn get( .await? .ok_or(RouteError::NoSuchUser)?; - let user_email = if session.scope.contains(&scope::EMAIL) { - repo.user_email().get_primary(&user).await? - } else { - None - }; - let user_info = UserInfo { sub: user.sub.clone(), username: user.username.clone(), - email_verified: user_email.as_ref().map(|u| u.confirmed_at.is_some()), - email: user_email.map(|u| u.email), }; let client = repo diff --git a/crates/handlers/src/rate_limit.rs b/crates/handlers/src/rate_limit.rs index 18e4cdccf..eff30d86f 100644 --- a/crates/handlers/src/rate_limit.rs +++ b/crates/handlers/src/rate_limit.rs @@ -8,7 +8,7 @@ use std::{net::IpAddr, sync::Arc, time::Duration}; use governor::{clock::QuantaClock, state::keyed::DashMapStateStore, RateLimiter}; use mas_config::RateLimitingConfig; -use mas_data_model::User; +use mas_data_model::{User, UserEmailAuthentication}; use ulid::Ulid; #[derive(Debug, Clone, thiserror::Error)] @@ -35,6 +35,18 @@ pub enum RegistrationLimitedError { Requester(RequesterFingerprint), } +#[derive(Debug, Clone, thiserror::Error)] +pub enum EmailAuthenticationLimitedError { + #[error("Too many email authentication requests for requester {0}")] + Requester(RequesterFingerprint), + + #[error("Too many email authentication requests for authentication session {0}")] + Authentication(Ulid), + + #[error("Too many email authentication requests for email {0}")] + Email(String), +} + /// Key used to rate limit requests per requester #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct RequesterFingerprint { @@ -78,6 +90,10 @@ struct LimiterInner { password_check_for_requester: KeyedRateLimiter, password_check_for_user: KeyedRateLimiter, registration_per_requester: KeyedRateLimiter, + email_authentication_per_requester: KeyedRateLimiter, + email_authentication_per_email: KeyedRateLimiter, + email_authentication_emails_per_session: KeyedRateLimiter, + email_authentication_attempt_per_session: KeyedRateLimiter, } impl LimiterInner { @@ -92,6 +108,18 @@ impl LimiterInner { password_check_for_requester: RateLimiter::keyed(config.login.per_ip.to_quota()?), password_check_for_user: RateLimiter::keyed(config.login.per_account.to_quota()?), registration_per_requester: RateLimiter::keyed(config.registration.to_quota()?), + email_authentication_per_email: RateLimiter::keyed( + config.email_authentication.per_address.to_quota()?, + ), + email_authentication_per_requester: RateLimiter::keyed( + config.email_authentication.per_ip.to_quota()?, + ), + email_authentication_emails_per_session: RateLimiter::keyed( + config.email_authentication.emails_per_session.to_quota()?, + ), + email_authentication_attempt_per_session: RateLimiter::keyed( + config.email_authentication.attempt_per_session.to_quota()?, + ), }) } } @@ -127,6 +155,16 @@ impl Limiter { this.inner.password_check_for_requester.retain_recent(); this.inner.password_check_for_user.retain_recent(); this.inner.registration_per_requester.retain_recent(); + this.inner.email_authentication_per_email.retain_recent(); + this.inner + .email_authentication_per_requester + .retain_recent(); + this.inner + .email_authentication_emails_per_session + .retain_recent(); + this.inner + .email_authentication_attempt_per_session + .retain_recent(); interval.tick().await; } @@ -199,6 +237,66 @@ impl Limiter { Ok(()) } + + /// Check if an email can be sent to the address for an email + /// authentication session + /// + /// # Errors + /// + /// Returns an error if the operation is rate limited. + pub fn check_email_authentication_email( + &self, + requester: RequesterFingerprint, + email: &str, + ) -> Result<(), EmailAuthenticationLimitedError> { + self.inner + .email_authentication_per_requester + .check_key(&requester) + .map_err(|_| EmailAuthenticationLimitedError::Requester(requester))?; + + // Convert to lowercase to prevent bypassing the limit by enumerating different + // case variations. + // A case-folding transformation may be more proper. + let canonical_email = email.to_lowercase(); + self.inner + .email_authentication_per_email + .check_key(&canonical_email) + .map_err(|_| EmailAuthenticationLimitedError::Email(email.to_owned()))?; + Ok(()) + } + + /// Check if an attempt can be done on an email authentication session + /// + /// # Errors + /// + /// Returns an error if the operation is rate limited. + pub fn check_email_authentication_attempt( + &self, + authentication: &UserEmailAuthentication, + ) -> Result<(), EmailAuthenticationLimitedError> { + self.inner + .email_authentication_attempt_per_session + .check_key(&authentication.id) + .map_err(|_| EmailAuthenticationLimitedError::Authentication(authentication.id)) + } + + /// Check if a new authentication code can be sent for an email + /// authentication session + /// + /// # Errors + /// + /// Returns an error if the operation is rate limited. + pub fn check_email_authentication_send_code( + &self, + requester: RequesterFingerprint, + authentication: &UserEmailAuthentication, + ) -> Result<(), EmailAuthenticationLimitedError> { + self.check_email_authentication_email(requester, &authentication.email)?; + self.inner + .email_authentication_emails_per_session + .check_key(&authentication.id) + .map_err(|_| EmailAuthenticationLimitedError::Authentication(authentication.id)) + } } #[cfg(test)] @@ -227,7 +325,6 @@ mod tests { id: Ulid::from_datetime_with_source(now.into(), &mut rng), username: "alice".to_owned(), sub: "123-456".to_owned(), - primary_user_email_id: None, created_at: now, locked_at: None, can_request_admin: false, @@ -237,7 +334,6 @@ mod tests { id: Ulid::from_datetime_with_source(now.into(), &mut rng), username: "bob".to_owned(), sub: "123-456".to_owned(), - primary_user_email_id: None, created_at: now, locked_at: None, can_request_admin: false, diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index ffe07f646..ee694768f 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -590,11 +590,6 @@ pub(crate) async fn post( } let context = context.build(); - // Is the email verified according to the upstream provider? - let provider_email_verified = env - .render_str("{{ user.email_verified | string }}", &context) - .is_ok_and(|v| v == "true"); - // Create a template context in case we need to re-render because of an error let ctx = UpstreamRegister::new(link.clone(), provider.clone()); @@ -802,24 +797,9 @@ pub(crate) async fn post( // If we have an email, add it to the user if let Some(email) = email { - let user_email = repo - .user_email() + repo.user_email() .add(&mut rng, &clock, &user, email) .await?; - // Mark the email as verified according to the policy and whether the provider - // claims it is, and make it the primary email. - if provider - .claims_imports - .verify_email - .should_mark_as_verified(provider_email_verified) - { - let user_email = repo - .user_email() - .mark_as_verified(&clock, user_email) - .await?; - - repo.user_email().set_as_primary(&user_email).await?; - } } repo.upstream_oauth_link() @@ -863,7 +843,9 @@ mod tests { use mas_iana::jose::JsonWebSignatureAlg; use mas_jose::jwt::{JsonWebSignatureHeader, Jwt}; use mas_router::Route; - use mas_storage::upstream_oauth2::UpstreamOAuthProviderParams; + use mas_storage::{ + upstream_oauth2::UpstreamOAuthProviderParams, user::UserEmailFilter, Pagination, + }; use oauth2_types::scope::{Scope, OPENID}; use sqlx::PgPool; @@ -1039,14 +1021,13 @@ mod tests { assert_eq!(link.user_id, Some(user.id)); - let email = repo + let page = repo .user_email() - .get_primary(&user) + .list(UserEmailFilter::new().for_user(&user), Pagination::first(1)) .await - .unwrap() - .expect("email exists"); + .unwrap(); + let email = page.edges.first().expect("email exists"); assert_eq!(email.email, "john@example.com"); - assert!(email.confirmed_at.is_some()); } } diff --git a/crates/handlers/src/views/account/emails/add.rs b/crates/handlers/src/views/account/emails/add.rs deleted file mode 100644 index fbdbccdd5..000000000 --- a/crates/handlers/src/views/account/emails/add.rs +++ /dev/null @@ -1,167 +0,0 @@ -// Copyright 2024 New Vector Ltd. -// Copyright 2022-2024 The Matrix.org Foundation C.I.C. -// -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. - -use axum::{ - extract::{Form, Query, State}, - response::{Html, IntoResponse, Response}, -}; -use mas_axum_utils::{ - cookies::CookieJar, - csrf::{CsrfExt, ProtectedForm}, - FancyError, SessionInfoExt, -}; -use mas_data_model::SiteConfig; -use mas_policy::Policy; -use mas_router::UrlBuilder; -use mas_storage::{ - queue::{QueueJobRepositoryExt as _, VerifyEmailJob}, - user::UserEmailRepository, - BoxClock, BoxRepository, BoxRng, -}; -use mas_templates::{EmailAddContext, ErrorContext, TemplateContext, Templates}; -use serde::Deserialize; - -use crate::{views::shared::OptionalPostAuthAction, BoundActivityTracker, PreferredLanguage}; - -#[derive(Deserialize, Debug)] -pub struct EmailForm { - email: String, -} - -#[tracing::instrument(name = "handlers.views.account_email_add.get", skip_all, err)] -pub(crate) async fn get( - mut rng: BoxRng, - clock: BoxClock, - PreferredLanguage(locale): PreferredLanguage, - State(templates): State, - State(url_builder): State, - State(site_config): State, - activity_tracker: BoundActivityTracker, - mut repo: BoxRepository, - cookie_jar: CookieJar, -) -> Result { - let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); - let (session_info, cookie_jar) = cookie_jar.session_info(); - - let maybe_session = session_info.load_session(&mut repo).await?; - - let Some(session) = maybe_session else { - let login = mas_router::Login::default(); - return Ok((cookie_jar, url_builder.redirect(&login)).into_response()); - }; - - if !site_config.email_change_allowed { - // XXX: this may not be the best error message, it's not translatable - return Err(FancyError::new( - ErrorContext::new() - .with_description("Email change is not allowed".to_owned()) - .with_details("The site configuration does not allow email changes".to_owned()), - )); - } - - activity_tracker - .record_browser_session(&clock, &session) - .await; - - let ctx = EmailAddContext::new() - .with_session(session) - .with_csrf(csrf_token.form_value()) - .with_language(locale); - - let content = templates.render_account_add_email(&ctx)?; - - Ok((cookie_jar, Html(content)).into_response()) -} - -#[tracing::instrument(name = "handlers.views.account_email_add.post", skip_all, err)] -pub(crate) async fn post( - mut rng: BoxRng, - clock: BoxClock, - mut repo: BoxRepository, - PreferredLanguage(locale): PreferredLanguage, - mut policy: Policy, - cookie_jar: CookieJar, - State(url_builder): State, - State(site_config): State, - activity_tracker: BoundActivityTracker, - Query(query): Query, - Form(form): Form>, -) -> Result { - let form = cookie_jar.verify_form(&clock, form)?; - let (session_info, cookie_jar) = cookie_jar.session_info(); - - let maybe_session = session_info.load_session(&mut repo).await?; - - let Some(session) = maybe_session else { - let login = mas_router::Login::default(); - return Ok((cookie_jar, url_builder.redirect(&login)).into_response()); - }; - - // XXX: we really should show human readable errors on the form here - if !site_config.email_change_allowed { - return Err(FancyError::new( - ErrorContext::new() - .with_description("Email change is not allowed".to_owned()) - .with_details("The site configuration does not allow email changes".to_owned()), - )); - } - - // Validate the email address - if form.email.parse::().is_err() { - return Err(anyhow::anyhow!("Invalid email address").into()); - } - - // Run the email policy - let res = policy.evaluate_email(&form.email).await?; - if !res.valid() { - return Err(FancyError::new( - ErrorContext::new() - .with_description(format!("Email address {:?} denied by policy", form.email)) - .with_details(format!("{res}")), - )); - } - - // Find an existing email address - let existing_user_email = repo.user_email().find(&session.user, &form.email).await?; - let user_email = if let Some(user_email) = existing_user_email { - user_email - } else { - repo.user_email() - .add(&mut rng, &clock, &session.user, form.email) - .await? - }; - - // If the email was not confirmed, send a confirmation email & redirect to the - // verify page - let next = if user_email.confirmed_at.is_none() { - repo.queue_job() - .schedule_job( - &mut rng, - &clock, - VerifyEmailJob::new(&user_email).with_language(locale.to_string()), - ) - .await?; - - let next = mas_router::AccountVerifyEmail::new(user_email.id); - let next = if let Some(action) = query.post_auth_action { - next.and_then(action) - } else { - next - }; - - url_builder.redirect(&next) - } else { - query.go_next_or_default(&url_builder, &mas_router::Account::default()) - }; - - repo.save().await?; - - activity_tracker - .record_browser_session(&clock, &session) - .await; - - Ok((cookie_jar, next).into_response()) -} diff --git a/crates/handlers/src/views/account/emails/mod.rs b/crates/handlers/src/views/account/emails/mod.rs deleted file mode 100644 index 935562d04..000000000 --- a/crates/handlers/src/views/account/emails/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright 2024 New Vector Ltd. -// Copyright 2022-2024 The Matrix.org Foundation C.I.C. -// -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. - -pub mod add; -pub mod verify; diff --git a/crates/handlers/src/views/account/emails/verify.rs b/crates/handlers/src/views/account/emails/verify.rs deleted file mode 100644 index 518177c7b..000000000 --- a/crates/handlers/src/views/account/emails/verify.rs +++ /dev/null @@ -1,157 +0,0 @@ -// Copyright 2024 New Vector Ltd. -// Copyright 2022-2024 The Matrix.org Foundation C.I.C. -// -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. - -use anyhow::Context; -use axum::{ - extract::{Form, Path, Query, State}, - response::{Html, IntoResponse, Response}, -}; -use mas_axum_utils::{ - cookies::CookieJar, - csrf::{CsrfExt, ProtectedForm}, - FancyError, SessionInfoExt, -}; -use mas_router::UrlBuilder; -use mas_storage::{ - queue::{ProvisionUserJob, QueueJobRepositoryExt as _}, - user::UserEmailRepository, - BoxClock, BoxRepository, BoxRng, RepositoryAccess, -}; -use mas_templates::{EmailVerificationPageContext, TemplateContext, Templates}; -use serde::Deserialize; -use ulid::Ulid; - -use crate::{views::shared::OptionalPostAuthAction, BoundActivityTracker, PreferredLanguage}; - -#[derive(Deserialize, Debug)] -pub struct CodeForm { - code: String, -} - -#[tracing::instrument( - name = "handlers.views.account_email_verify.get", - fields(user_email.id = %id), - skip_all, - err, -)] -pub(crate) async fn get( - mut rng: BoxRng, - clock: BoxClock, - PreferredLanguage(locale): PreferredLanguage, - State(templates): State, - State(url_builder): State, - activity_tracker: BoundActivityTracker, - mut repo: BoxRepository, - Query(query): Query, - Path(id): Path, - cookie_jar: CookieJar, -) -> Result { - let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); - let (session_info, cookie_jar) = cookie_jar.session_info(); - - let maybe_session = session_info.load_session(&mut repo).await?; - - let Some(session) = maybe_session else { - let login = mas_router::Login::default(); - return Ok((cookie_jar, url_builder.redirect(&login)).into_response()); - }; - - activity_tracker - .record_browser_session(&clock, &session) - .await; - - let user_email = repo - .user_email() - .lookup(id) - .await? - .filter(|u| u.user_id == session.user.id) - .context("Could not find user email")?; - - if user_email.confirmed_at.is_some() { - // This email was already verified, skip - let destination = query.go_next_or_default(&url_builder, &mas_router::Account::default()); - return Ok((cookie_jar, destination).into_response()); - } - - let ctx = EmailVerificationPageContext::new(user_email) - .with_session(session) - .with_csrf(csrf_token.form_value()) - .with_language(locale); - - let content = templates.render_account_verify_email(&ctx)?; - - Ok((cookie_jar, Html(content)).into_response()) -} - -#[tracing::instrument( - name = "handlers.views.account_email_verify.post", - fields(user_email.id = %id), - skip_all, - err, -)] -pub(crate) async fn post( - clock: BoxClock, - mut rng: BoxRng, - mut repo: BoxRepository, - cookie_jar: CookieJar, - State(url_builder): State, - activity_tracker: BoundActivityTracker, - Query(query): Query, - Path(id): Path, - Form(form): Form>, -) -> Result { - let form = cookie_jar.verify_form(&clock, form)?; - let (session_info, cookie_jar) = cookie_jar.session_info(); - - let maybe_session = session_info.load_session(&mut repo).await?; - - let Some(session) = maybe_session else { - let login = mas_router::Login::default(); - return Ok((cookie_jar, url_builder.redirect(&login)).into_response()); - }; - - let user_email = repo - .user_email() - .lookup(id) - .await? - .filter(|u| u.user_id == session.user.id) - .context("Could not find user email")?; - - // XXX: this logic should be extracted somewhere else, since most of it is - // duplicated in mas_graphql - - let verification = repo - .user_email() - .find_verification_code(&clock, &user_email, &form.code) - .await? - .context("Invalid code")?; - - // TODO: display nice errors if the code was already consumed or expired - repo.user_email() - .consume_verification_code(&clock, verification) - .await?; - - if session.user.primary_user_email_id.is_none() { - repo.user_email().set_as_primary(&user_email).await?; - } - - repo.user_email() - .mark_as_verified(&clock, user_email) - .await?; - - repo.queue_job() - .schedule_job(&mut rng, &clock, ProvisionUserJob::new(&session.user)) - .await?; - - repo.save().await?; - - activity_tracker - .record_browser_session(&clock, &session) - .await; - - let destination = query.go_next_or_default(&url_builder, &mas_router::Account::default()); - Ok((cookie_jar, destination).into_response()) -} diff --git a/crates/handlers/src/views/account/mod.rs b/crates/handlers/src/views/account/mod.rs deleted file mode 100644 index f85da5266..000000000 --- a/crates/handlers/src/views/account/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright 2024 New Vector Ltd. -// Copyright 2021-2024 The Matrix.org Foundation C.I.C. -// -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. - -pub mod emails; diff --git a/crates/handlers/src/views/mod.rs b/crates/handlers/src/views/mod.rs index 5a3928444..336ec9f2f 100644 --- a/crates/handlers/src/views/mod.rs +++ b/crates/handlers/src/views/mod.rs @@ -4,12 +4,10 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. -pub mod account; pub mod app; pub mod index; pub mod login; pub mod logout; -pub mod password_register; pub mod reauth; pub mod recovery; pub mod register; diff --git a/crates/handlers/src/views/register/cookie.rs b/crates/handlers/src/views/register/cookie.rs new file mode 100644 index 000000000..7e3eb8173 --- /dev/null +++ b/crates/handlers/src/views/register/cookie.rs @@ -0,0 +1,103 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +// TODO: move that to a standalone cookie manager + +use std::collections::BTreeSet; + +use chrono::{DateTime, Duration, Utc}; +use mas_axum_utils::cookies::CookieJar; +use mas_data_model::UserRegistration; +use mas_storage::Clock; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use ulid::Ulid; + +/// Name of the cookie +static COOKIE_NAME: &str = "user-registration-sessions"; + +/// Sessions expire after an hour +static SESSION_MAX_TIME: Duration = Duration::hours(1); + +/// The content of the cookie, which stores a list of user registration IDs +#[derive(Serialize, Deserialize, Default, Debug)] +pub struct UserRegistrationSessions(BTreeSet); + +#[derive(Debug, Error, PartialEq, Eq)] +#[error("user registration session not found")] +pub struct UserRegistrationSessionNotFound; + +impl UserRegistrationSessions { + /// Load the user registration sessions cookie + pub fn load(cookie_jar: &CookieJar) -> Self { + match cookie_jar.load(COOKIE_NAME) { + Ok(Some(sessions)) => sessions, + Ok(None) => Self::default(), + Err(e) => { + tracing::warn!( + error = &e as &dyn std::error::Error, + "Invalid upstream sessions cookie" + ); + Self::default() + } + } + } + + /// Returns true if the cookie is empty + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Save the user registration sessions to the cookie jar + pub fn save(self, cookie_jar: CookieJar, clock: &C) -> CookieJar + where + C: Clock, + { + let this = self.expire(clock.now()); + + if this.is_empty() { + cookie_jar.remove(COOKIE_NAME) + } else { + cookie_jar.save(COOKIE_NAME, &this, false) + } + } + + fn expire(mut self, now: DateTime) -> Self { + self.0.retain(|id| { + let Ok(ts) = id.timestamp_ms().try_into() else { + return false; + }; + let Some(when) = DateTime::from_timestamp_millis(ts) else { + return false; + }; + now - when < SESSION_MAX_TIME + }); + + self + } + + /// Add a new session, for a provider and a random state + pub fn add(mut self, user_registration: &UserRegistration) -> Self { + self.0.insert(user_registration.id); + self + } + + /// Check if the session is in the list + pub fn contains(&self, user_registration: &UserRegistration) -> bool { + self.0.contains(&user_registration.id) + } + + /// Mark a link as consumed to avoid replay + pub fn consume_session( + mut self, + user_registration: &UserRegistration, + ) -> Result { + if !self.0.remove(&user_registration.id) { + return Err(UserRegistrationSessionNotFound); + } + + Ok(self) + } +} diff --git a/crates/handlers/src/views/register.rs b/crates/handlers/src/views/register/mod.rs similarity index 98% rename from crates/handlers/src/views/register.rs rename to crates/handlers/src/views/register/mod.rs index 63d6ef1b6..ea8cb40ce 100644 --- a/crates/handlers/src/views/register.rs +++ b/crates/handlers/src/views/register/mod.rs @@ -17,6 +17,10 @@ use mas_templates::{RegisterContext, TemplateContext, Templates}; use super::shared::OptionalPostAuthAction; use crate::{BoundActivityTracker, PreferredLanguage}; +mod cookie; +pub(crate) mod password; +pub(crate) mod steps; + #[tracing::instrument(name = "handlers.views.register.get", skip_all, err)] pub(crate) async fn get( mut rng: BoxRng, diff --git a/crates/handlers/src/views/password_register.rs b/crates/handlers/src/views/register/password.rs similarity index 86% rename from crates/handlers/src/views/password_register.rs rename to crates/handlers/src/views/register/password.rs index a8cdc4f51..c2177c484 100644 --- a/crates/handlers/src/views/password_register.rs +++ b/crates/handlers/src/views/register/password.rs @@ -24,8 +24,8 @@ use mas_matrix::BoxHomeserverConnection; use mas_policy::Policy; use mas_router::UrlBuilder; use mas_storage::{ - queue::{ProvisionUserJob, QueueJobRepositoryExt as _, VerifyEmailJob}, - user::{BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository}, + queue::{QueueJobRepositoryExt as _, SendEmailAuthenticationCodeJob}, + user::{UserEmailRepository, UserRepository}, BoxClock, BoxRepository, BoxRng, RepositoryAccess, }; use mas_templates::{ @@ -35,10 +35,11 @@ use mas_templates::{ use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; -use super::shared::OptionalPostAuthAction; +use super::cookie::UserRegistrationSessions; use crate::{ - captcha::Form as CaptchaForm, passwords::PasswordManager, BoundActivityTracker, Limiter, - PreferredLanguage, RequesterFingerprint, SiteConfig, + captcha::Form as CaptchaForm, passwords::PasswordManager, + views::shared::OptionalPostAuthAction, BoundActivityTracker, Limiter, PreferredLanguage, + RequesterFingerprint, SiteConfig, }; #[derive(Debug, Deserialize, Serialize)] @@ -141,6 +142,8 @@ pub(crate) async fn post( Form(form): Form>, ) -> Result { let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); + + let ip_address = activity_tracker.ip(); if !site_config.password_registration_enabled { return Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()); } @@ -188,6 +191,9 @@ pub(crate) async fn post( homeserver_denied_username = true; } + // Note that we don't check here if the email is already taken here, as + // we don't want to leak the information about other users. Instead, we will + // show an error message once the user confirmed their email address. if form.email.is_empty() { state.add_error_on_field(RegisterFormField::Email, FieldError::Required); } else if Address::from_str(&form.email).is_err() { @@ -276,6 +282,11 @@ pub(crate) async fn post( tracing::warn!(error = &e as &dyn std::error::Error); state.add_error_on_form(FormError::RateLimitExceeded); } + + if let Err(e) = limiter.check_email_authentication_email(requester, &form.email) { + tracing::warn!(error = &e as &dyn std::error::Error); + state.add_error_on_form(FormError::RateLimitExceeded); + } } state @@ -296,57 +307,71 @@ pub(crate) async fn post( return Ok((cookie_jar, Html(content)).into_response()); } - let user = repo.user().add(&mut rng, &clock, form.username).await?; - - if let Some(tos_uri) = &site_config.tos_uri { - repo.user_terms() - .accept_terms(&mut rng, &clock, &user, tos_uri.clone()) - .await?; - } - - let password = Zeroizing::new(form.password.into_bytes()); - let (version, hashed_password) = password_manager.hash(&mut rng, password).await?; - let user_password = repo - .user_password() - .add(&mut rng, &clock, &user, version, hashed_password, None) - .await?; - - let user_email = repo - .user_email() - .add(&mut rng, &clock, &user, form.email) + let post_auth_action = query + .post_auth_action + .map(serde_json::to_value) + .transpose()?; + let registration = repo + .user_registration() + .add( + &mut rng, + &clock, + form.username, + ip_address, + user_agent, + post_auth_action, + ) .await?; - let next = mas_router::AccountVerifyEmail::new(user_email.id).and_maybe(query.post_auth_action); - - let session = repo - .browser_session() - .add(&mut rng, &clock, &user, user_agent) - .await?; + let registration = if let Some(tos_uri) = &site_config.tos_uri { + repo.user_registration() + .set_terms_url(registration, tos_uri.clone()) + .await? + } else { + registration + }; - repo.browser_session() - .authenticate_with_password(&mut rng, &clock, &session, &user_password) + // Create a new user email authentication session + let user_email_authentication = repo + .user_email() + .add_authentication_for_registration(&mut rng, &clock, form.email, ®istration) .await?; + // Schedule a job to verify the email repo.queue_job() .schedule_job( &mut rng, &clock, - VerifyEmailJob::new(&user_email).with_language(locale.to_string()), + SendEmailAuthenticationCodeJob::new(&user_email_authentication, locale.to_string()), ) .await?; - repo.queue_job() - .schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user)) + let registration = repo + .user_registration() + .set_email_authentication(registration, &user_email_authentication) + .await?; + + // Hash the password + let password = Zeroizing::new(form.password.into_bytes()); + let (version, hashed_password) = password_manager.hash(&mut rng, password).await?; + + // Add the password to the registration + let registration = repo + .user_registration() + .set_password(registration, hashed_password, version) .await?; repo.save().await?; - activity_tracker - .record_browser_session(&clock, &session) - .await; + let cookie_jar = UserRegistrationSessions::load(&cookie_jar) + .add(®istration) + .save(cookie_jar, &clock); - let cookie_jar = cookie_jar.set_session(&session); - Ok((cookie_jar, url_builder.redirect(&next)).into_response()) + Ok(( + cookie_jar, + url_builder.redirect(&mas_router::RegisterFinish::new(registration.id)), + ) + .into_response()) } async fn render( @@ -460,15 +485,31 @@ mod tests { let response = state.request(request).await; cookies.save_cookies(&response); response.assert_status(StatusCode::SEE_OTHER); + let location = response.headers().get(LOCATION).unwrap(); - // Now if we get to the home page, we should see the user's username - let request = Request::get("/").empty(); - let request = cookies.with_cookies(request); - let response = state.request(request).await; - cookies.save_cookies(&response); - response.assert_status(StatusCode::OK); - response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); - assert!(response.body().contains("john")); + // The handler redirects with the ID as the second to last portion of the path + let id = location + .to_str() + .unwrap() + .rsplit('/') + .nth(1) + .unwrap() + .parse() + .unwrap(); + + // There should be a new registration in the database + let mut repo = state.repository().await.unwrap(); + let registration = repo.user_registration().lookup(id).await.unwrap().unwrap(); + assert_eq!(registration.username, "john".to_owned()); + assert!(registration.password.is_some()); + + let email_authentication = repo + .user_email() + .lookup_authentication(registration.email_authentication_id.unwrap()) + .await + .unwrap() + .unwrap(); + assert_eq!(email_authentication.email, "john@example.com"); } /// When the two password fields mismatch, it should give an error diff --git a/crates/handlers/src/views/register/steps/display_name.rs b/crates/handlers/src/views/register/steps/display_name.rs new file mode 100644 index 000000000..1af314ecf --- /dev/null +++ b/crates/handlers/src/views/register/steps/display_name.rs @@ -0,0 +1,182 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use anyhow::Context as _; +use axum::{ + extract::{Path, State}, + response::{Html, IntoResponse, Response}, + Form, +}; +use mas_axum_utils::{ + cookies::CookieJar, + csrf::{CsrfExt as _, ProtectedForm}, + FancyError, +}; +use mas_router::{PostAuthAction, UrlBuilder}; +use mas_storage::{BoxClock, BoxRepository, BoxRng}; +use mas_templates::{ + FieldError, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, + TemplateContext as _, Templates, ToFormState, +}; +use serde::{Deserialize, Serialize}; +use ulid::Ulid; + +use crate::{views::shared::OptionalPostAuthAction, PreferredLanguage}; + +#[derive(Deserialize, Default)] +#[serde(rename_all = "snake_case")] +enum FormAction { + #[default] + Set, + Skip, +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct DisplayNameForm { + #[serde(skip_serializing, default)] + action: FormAction, + #[serde(default)] + display_name: String, +} + +impl ToFormState for DisplayNameForm { + type Field = mas_templates::RegisterStepsDisplayNameFormField; +} + +#[tracing::instrument( + name = "handlers.views.register.steps.display_name.get", + fields(user_registration.id = %id), + skip_all, + err, +)] +pub(crate) async fn get( + mut rng: BoxRng, + clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, + State(templates): State, + State(url_builder): State, + mut repo: BoxRepository, + Path(id): Path, + cookie_jar: CookieJar, +) -> Result { + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + + let registration = repo + .user_registration() + .lookup(id) + .await? + .context("Could not find user registration")?; + + // If the registration is completed, we can go to the registration destination + // XXX: this might not be the right thing to do? Maybe an error page would be + // better? + if registration.completed_at.is_some() { + let post_auth_action: Option = registration + .post_auth_action + .map(serde_json::from_value) + .transpose()?; + + return Ok(( + cookie_jar, + OptionalPostAuthAction::from(post_auth_action) + .go_next(&url_builder) + .into_response(), + ) + .into_response()); + } + + let ctx = RegisterStepsDisplayNameContext::new() + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let content = templates.render_register_steps_display_name(&ctx)?; + + Ok((cookie_jar, Html(content)).into_response()) +} + +#[tracing::instrument( + name = "handlers.views.register.steps.display_name.post", + fields(user_registration.id = %id), + skip_all, + err, +)] +pub(crate) async fn post( + mut rng: BoxRng, + clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, + State(templates): State, + State(url_builder): State, + mut repo: BoxRepository, + Path(id): Path, + cookie_jar: CookieJar, + Form(form): Form>, +) -> Result { + let registration = repo + .user_registration() + .lookup(id) + .await? + .context("Could not find user registration")?; + + // If the registration is completed, we can go to the registration destination + // XXX: this might not be the right thing to do? Maybe an error page would be + // better? + if registration.completed_at.is_some() { + let post_auth_action: Option = registration + .post_auth_action + .map(serde_json::from_value) + .transpose()?; + + return Ok(( + cookie_jar, + OptionalPostAuthAction::from(post_auth_action) + .go_next(&url_builder) + .into_response(), + ) + .into_response()); + } + + let form = cookie_jar.verify_form(&clock, form)?; + + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + + let display_name = match form.action { + FormAction::Set => { + let display_name = form.display_name.trim(); + + if display_name.is_empty() || display_name.len() > 255 { + let ctx = RegisterStepsDisplayNameContext::new() + .with_form_state(form.to_form_state().with_error_on_field( + RegisterStepsDisplayNameFormField::DisplayName, + FieldError::Invalid, + )) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + return Ok(( + cookie_jar, + Html(templates.render_register_steps_display_name(&ctx)?), + ) + .into_response()); + } + + display_name.to_owned() + } + FormAction::Skip => { + // If the user chose to skip, we do the same as Synapse and use the localpart as + // default display name + registration.username.clone() + } + }; + + let registration = repo + .user_registration() + .set_display_name(registration, display_name) + .await?; + + repo.save().await?; + + let destination = mas_router::RegisterFinish::new(registration.id); + return Ok((cookie_jar, url_builder.redirect(&destination)).into_response()); +} diff --git a/crates/handlers/src/views/register/steps/finish.rs b/crates/handlers/src/views/register/steps/finish.rs new file mode 100644 index 000000000..b460d6aed --- /dev/null +++ b/crates/handlers/src/views/register/steps/finish.rs @@ -0,0 +1,237 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use anyhow::Context as _; +use axum::{ + extract::{Path, State}, + response::{Html, IntoResponse, Response}, +}; +use axum_extra::TypedHeader; +use chrono::Duration; +use mas_axum_utils::{cookies::CookieJar, FancyError, SessionInfoExt as _}; +use mas_data_model::UserAgent; +use mas_matrix::BoxHomeserverConnection; +use mas_router::{PostAuthAction, UrlBuilder}; +use mas_storage::{ + queue::{ProvisionUserJob, QueueJobRepositoryExt as _}, + user::UserEmailFilter, + BoxClock, BoxRepository, BoxRng, +}; +use mas_templates::{RegisterStepsEmailInUseContext, TemplateContext as _, Templates}; +use ulid::Ulid; + +use super::super::cookie::UserRegistrationSessions; +use crate::{views::shared::OptionalPostAuthAction, BoundActivityTracker, PreferredLanguage}; + +#[tracing::instrument( + name = "handlers.views.register.steps.finish.get", + fields(user_registration.id = %id), + skip_all, + err, +)] +pub(crate) async fn get( + mut rng: BoxRng, + clock: BoxClock, + mut repo: BoxRepository, + activity_tracker: BoundActivityTracker, + user_agent: Option>, + State(url_builder): State, + State(homeserver): State, + State(templates): State, + PreferredLanguage(lang): PreferredLanguage, + cookie_jar: CookieJar, + Path(id): Path, +) -> Result { + let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned())); + let registration = repo + .user_registration() + .lookup(id) + .await? + .context("User registration not found")?; + + // If the registration is completed, we can go to the registration destination + // XXX: this might not be the right thing to do? Maybe an error page would be + // better? + if registration.completed_at.is_some() { + let post_auth_action: Option = registration + .post_auth_action + .map(serde_json::from_value) + .transpose()?; + + return Ok(( + cookie_jar, + OptionalPostAuthAction::from(post_auth_action).go_next(&url_builder), + ) + .into_response()); + } + + // Make sure the registration session hasn't expired + // XXX: this duration is hard-coded, could be configurable + if clock.now() - registration.created_at > Duration::hours(1) { + return Err(FancyError::from(anyhow::anyhow!( + "Registration session has expired" + ))); + } + + // Check that this registration belongs to this browser + let registrations = UserRegistrationSessions::load(&cookie_jar); + if !registrations.contains(®istration) { + // XXX: we should have a better error screen here + return Err(FancyError::from(anyhow::anyhow!( + "Could not find the registration in the browser cookies" + ))); + } + + // Let's perform last minute checks on the registration, especially to avoid + // race conditions where multiple users register with the same username or email + // address + + if repo.user().exists(®istration.username).await? { + // XXX: this could have a better error message, but as this is unlikely to + // happen, we're fine with a vague message for now + return Err(FancyError::from(anyhow::anyhow!( + "Username is already taken" + ))); + } + + if !homeserver + .is_localpart_available(®istration.username) + .await? + { + return Err(FancyError::from(anyhow::anyhow!( + "Username is not available" + ))); + } + + // For now, we require an email address on the registration, but this might + // change in the future + let email_authentication_id = registration + .email_authentication_id + .context("No email authentication started for this registration")?; + let email_authentication = repo + .user_email() + .lookup_authentication(email_authentication_id) + .await? + .context("Could not load the email authentication")?; + + // Check that the email authentication has been completed + if email_authentication.completed_at.is_none() { + return Ok(( + cookie_jar, + url_builder.redirect(&mas_router::RegisterVerifyEmail::new(id)), + ) + .into_response()); + } + + // Check that the email address isn't already used + // It is important to do that here, as we we're not checking during the + // registration, because we don't want to disclose whether an email is + // already being used or not before we verified it + if repo + .user_email() + .count(UserEmailFilter::new().for_email(&email_authentication.email)) + .await? + > 0 + { + let action = registration + .post_auth_action + .map(serde_json::from_value) + .transpose()?; + + let ctx = RegisterStepsEmailInUseContext::new(email_authentication.email, action) + .with_language(lang); + + return Ok(( + cookie_jar, + Html(templates.render_register_steps_email_in_use(&ctx)?), + ) + .into_response()); + } + + // Check that the display name is set + if registration.display_name.is_none() { + return Ok(( + cookie_jar, + url_builder.redirect(&mas_router::RegisterDisplayName::new(registration.id)), + ) + .into_response()); + } + + // Everuthing is good, let's complete the registration + let registration = repo + .user_registration() + .complete(&clock, registration) + .await?; + + // Consume the registration session + let cookie_jar = registrations + .consume_session(®istration)? + .save(cookie_jar, &clock); + + // Now we can start the user creation + let user = repo + .user() + .add(&mut rng, &clock, registration.username) + .await?; + // Also create a browser session which will log the user in + let user_session = repo + .browser_session() + .add(&mut rng, &clock, &user, user_agent) + .await?; + + repo.user_email() + .add(&mut rng, &clock, &user, email_authentication.email) + .await?; + + if let Some(password) = registration.password { + let user_password = repo + .user_password() + .add( + &mut rng, + &clock, + &user, + password.version, + password.hashed_password, + None, + ) + .await?; + + repo.browser_session() + .authenticate_with_password(&mut rng, &clock, &user_session, &user_password) + .await?; + } + + if let Some(terms_url) = registration.terms_url { + repo.user_terms() + .accept_terms(&mut rng, &clock, &user, terms_url) + .await?; + } + + let mut job = ProvisionUserJob::new(&user); + if let Some(display_name) = registration.display_name { + job = job.set_display_name(display_name); + } + repo.queue_job().schedule_job(&mut rng, &clock, job).await?; + + repo.save().await?; + + activity_tracker + .record_browser_session(&clock, &user_session) + .await; + + let post_auth_action: Option = registration + .post_auth_action + .map(serde_json::from_value) + .transpose()?; + + // Login the user with the session we just created + let cookie_jar = cookie_jar.set_session(&user_session); + + return Ok(( + cookie_jar, + OptionalPostAuthAction::from(post_auth_action).go_next(&url_builder), + ) + .into_response()); +} diff --git a/crates/handlers/src/views/register/steps/mod.rs b/crates/handlers/src/views/register/steps/mod.rs new file mode 100644 index 000000000..1b090abb9 --- /dev/null +++ b/crates/handlers/src/views/register/steps/mod.rs @@ -0,0 +1,8 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +pub(crate) mod display_name; +pub(crate) mod finish; +pub(crate) mod verify_email; diff --git a/crates/handlers/src/views/register/steps/verify_email.rs b/crates/handlers/src/views/register/steps/verify_email.rs new file mode 100644 index 000000000..9faf347e8 --- /dev/null +++ b/crates/handlers/src/views/register/steps/verify_email.rs @@ -0,0 +1,205 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use anyhow::Context; +use axum::{ + extract::{Form, Path, State}, + response::{Html, IntoResponse, Response}, +}; +use mas_axum_utils::{ + cookies::CookieJar, + csrf::{CsrfExt, ProtectedForm}, + FancyError, +}; +use mas_router::{PostAuthAction, UrlBuilder}; +use mas_storage::{user::UserEmailRepository, BoxClock, BoxRepository, BoxRng, RepositoryAccess}; +use mas_templates::{ + FieldError, RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField, + TemplateContext, Templates, ToFormState, +}; +use serde::{Deserialize, Serialize}; +use ulid::Ulid; + +use crate::{views::shared::OptionalPostAuthAction, Limiter, PreferredLanguage}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct CodeForm { + code: String, +} + +impl ToFormState for CodeForm { + type Field = mas_templates::RegisterStepsVerifyEmailFormField; +} + +#[tracing::instrument( + name = "handlers.views.register.steps.verify_email.get", + fields(user_registration.id = %id), + skip_all, + err, +)] +pub(crate) async fn get( + mut rng: BoxRng, + clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, + State(templates): State, + State(url_builder): State, + mut repo: BoxRepository, + Path(id): Path, + cookie_jar: CookieJar, +) -> Result { + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + + let registration = repo + .user_registration() + .lookup(id) + .await? + .context("Could not find user registration")?; + + // If the registration is completed, we can go to the registration destination + // XXX: this might not be the right thing to do? Maybe an error page would be + // better? + if registration.completed_at.is_some() { + let post_auth_action: Option = registration + .post_auth_action + .map(serde_json::from_value) + .transpose()?; + + return Ok(( + cookie_jar, + OptionalPostAuthAction::from(post_auth_action) + .go_next(&url_builder) + .into_response(), + ) + .into_response()); + } + + let email_authentication_id = registration + .email_authentication_id + .context("No email authentication started for this registration")?; + let email_authentication = repo + .user_email() + .lookup_authentication(email_authentication_id) + .await? + .context("Could not find email authentication")?; + + if email_authentication.completed_at.is_some() { + // XXX: display a better error here + return Err(FancyError::from(anyhow::anyhow!( + "Email authentication already completed" + ))); + } + + let ctx = RegisterStepsVerifyEmailContext::new(email_authentication) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let content = templates.render_register_steps_verify_email(&ctx)?; + + Ok((cookie_jar, Html(content)).into_response()) +} + +#[tracing::instrument( + name = "handlers.views.account_email_verify.post", + fields(user_email.id = %id), + skip_all, + err, +)] +pub(crate) async fn post( + clock: BoxClock, + mut rng: BoxRng, + PreferredLanguage(locale): PreferredLanguage, + State(templates): State, + State(limiter): State, + mut repo: BoxRepository, + cookie_jar: CookieJar, + State(url_builder): State, + Path(id): Path, + Form(form): Form>, +) -> Result { + let form = cookie_jar.verify_form(&clock, form)?; + + let registration = repo + .user_registration() + .lookup(id) + .await? + .context("Could not find user registration")?; + + // If the registration is completed, we can go to the registration destination + // XXX: this might not be the right thing to do? Maybe an error page would be + // better? + if registration.completed_at.is_some() { + let post_auth_action: Option = registration + .post_auth_action + .map(serde_json::from_value) + .transpose()?; + + return Ok(( + cookie_jar, + OptionalPostAuthAction::from(post_auth_action).go_next(&url_builder), + ) + .into_response()); + } + + let email_authentication_id = registration + .email_authentication_id + .context("No email authentication started for this registration")?; + let email_authentication = repo + .user_email() + .lookup_authentication(email_authentication_id) + .await? + .context("Could not find email authentication")?; + + if email_authentication.completed_at.is_some() { + // XXX: display a better error here + return Err(FancyError::from(anyhow::anyhow!( + "Email authentication already completed" + ))); + } + + if let Err(e) = limiter.check_email_authentication_attempt(&email_authentication) { + tracing::warn!(error = &e as &dyn std::error::Error); + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + let ctx = RegisterStepsVerifyEmailContext::new(email_authentication) + .with_form_state( + form.to_form_state() + .with_error_on_form(mas_templates::FormError::RateLimitExceeded), + ) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let content = templates.render_register_steps_verify_email(&ctx)?; + + return Ok((cookie_jar, Html(content)).into_response()); + } + + let Some(code) = repo + .user_email() + .find_authentication_code(&email_authentication, &form.code) + .await? + else { + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + let ctx = + RegisterStepsVerifyEmailContext::new(email_authentication) + .with_form_state(form.to_form_state().with_error_on_field( + RegisterStepsVerifyEmailFormField::Code, + FieldError::Invalid, + )) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let content = templates.render_register_steps_verify_email(&ctx)?; + + return Ok((cookie_jar, Html(content)).into_response()); + }; + + repo.user_email() + .complete_authentication(&clock, email_authentication, &code) + .await?; + + repo.save().await?; + + let destination = mas_router::RegisterFinish::new(registration.id); + return Ok((cookie_jar, url_builder.redirect(&destination)).into_response()); +} diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index 32faf919e..fa707a66b 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -444,72 +444,75 @@ impl From> for PasswordRegister { } } -/// `GET|POST /verify-email/:id` +/// `GET|POST /register/steps/:id/display-name` #[derive(Debug, Clone)] -pub struct AccountVerifyEmail { +pub struct RegisterDisplayName { id: Ulid, - post_auth_action: Option, } -impl AccountVerifyEmail { +impl RegisterDisplayName { #[must_use] pub fn new(id: Ulid) -> Self { - Self { - id, - post_auth_action: None, - } + Self { id } } +} - #[must_use] - pub fn and_maybe(mut self, action: Option) -> Self { - self.post_auth_action = action; - self +impl Route for RegisterDisplayName { + type Query = (); + fn route() -> &'static str { + "/register/steps/:id/display-name" } - #[must_use] - pub fn and_then(mut self, action: PostAuthAction) -> Self { - self.post_auth_action = Some(action); - self + fn path(&self) -> std::borrow::Cow<'static, str> { + format!("/register/steps/{}/display-name", self.id).into() } } -impl Route for AccountVerifyEmail { - type Query = PostAuthAction; - fn route() -> &'static str { - "/verify-email/:id" +/// `GET|POST /register/steps/:id/verify-email` +#[derive(Debug, Clone)] +pub struct RegisterVerifyEmail { + id: Ulid, +} + +impl RegisterVerifyEmail { + #[must_use] + pub fn new(id: Ulid) -> Self { + Self { id } } +} - fn query(&self) -> Option<&Self::Query> { - self.post_auth_action.as_ref() +impl Route for RegisterVerifyEmail { + type Query = (); + fn route() -> &'static str { + "/register/steps/:id/verify-email" } fn path(&self) -> std::borrow::Cow<'static, str> { - format!("/verify-email/{}", self.id).into() + format!("/register/steps/{}/verify-email", self.id).into() } } -/// `GET /add-email` -#[derive(Default, Debug, Clone)] -pub struct AccountAddEmail { - post_auth_action: Option, +/// `GET /register/steps/:id/finish` +#[derive(Debug, Clone)] +pub struct RegisterFinish { + id: Ulid, } -impl Route for AccountAddEmail { - type Query = PostAuthAction; - fn route() -> &'static str { - "/add-email" +impl RegisterFinish { + #[must_use] + pub const fn new(id: Ulid) -> Self { + Self { id } } +} - fn query(&self) -> Option<&Self::Query> { - self.post_auth_action.as_ref() +impl Route for RegisterFinish { + type Query = (); + fn route() -> &'static str { + "/register/steps/:id/finish" } -} -impl AccountAddEmail { - #[must_use] - pub fn and_then(mut self, action: PostAuthAction) -> Self { - self.post_auth_action = Some(action); - self + fn path(&self) -> std::borrow::Cow<'static, str> { + format!("/register/steps/{}/finish", self.id).into() } } diff --git a/crates/storage-pg/.sqlx/query-05b4dd39521eaf4e8e3c21654df67c00c8781f54054a84b3f3005b65cbc2a14a.json b/crates/storage-pg/.sqlx/query-05b4dd39521eaf4e8e3c21654df67c00c8781f54054a84b3f3005b65cbc2a14a.json new file mode 100644 index 000000000..b11646163 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-05b4dd39521eaf4e8e3c21654df67c00c8781f54054a84b3f3005b65cbc2a14a.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_email_authentications\n ( user_email_authentication_id\n , user_session_id\n , email\n , created_at\n )\n VALUES ($1, $2, $3, $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "05b4dd39521eaf4e8e3c21654df67c00c8781f54054a84b3f3005b65cbc2a14a" +} 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-1d372f36c382ab16264cea54537af3544ea6d6d75d10b432b07dbd0dadd2fa4e.json b/crates/storage-pg/.sqlx/query-1d372f36c382ab16264cea54537af3544ea6d6d75d10b432b07dbd0dadd2fa4e.json deleted file mode 100644 index 59115e633..000000000 --- a/crates/storage-pg/.sqlx/query-1d372f36c382ab16264cea54537af3544ea6d6d75d10b432b07dbd0dadd2fa4e.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT user_email_confirmation_code_id\n , user_email_id\n , code\n , created_at\n , expires_at\n , consumed_at\n FROM user_email_confirmation_codes\n WHERE code = $1\n AND user_email_id = $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "user_email_confirmation_code_id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "user_email_id", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "code", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "expires_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "consumed_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text", - "Uuid" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - true - ] - }, - "hash": "1d372f36c382ab16264cea54537af3544ea6d6d75d10b432b07dbd0dadd2fa4e" -} diff --git a/crates/storage-pg/.sqlx/query-2f8d402b7217aef47a5c45d4f7cfddbaeedcbbc6963ee573409bfc98e57de6ed.json b/crates/storage-pg/.sqlx/query-2f8d402b7217aef47a5c45d4f7cfddbaeedcbbc6963ee573409bfc98e57de6ed.json new file mode 100644 index 000000000..473db95dd --- /dev/null +++ b/crates/storage-pg/.sqlx/query-2f8d402b7217aef47a5c45d4f7cfddbaeedcbbc6963ee573409bfc98e57de6ed.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_email_authentication_codes\n ( user_email_authentication_code_id\n , user_email_authentication_id\n , code\n , created_at\n , expires_at\n )\n VALUES ($1, $2, $3, $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "2f8d402b7217aef47a5c45d4f7cfddbaeedcbbc6963ee573409bfc98e57de6ed" +} diff --git a/crates/storage-pg/.sqlx/query-4192c1144c0ea530cf1aa77993a38e94cd5cf8b5c42cb037efb7917c6fc44a1d.json b/crates/storage-pg/.sqlx/query-38eb6b635d30ca78ff78b926b414cbd866cfc2918ca4b1741b5687f21cfe273b.json similarity index 66% rename from crates/storage-pg/.sqlx/query-4192c1144c0ea530cf1aa77993a38e94cd5cf8b5c42cb037efb7917c6fc44a1d.json rename to crates/storage-pg/.sqlx/query-38eb6b635d30ca78ff78b926b414cbd866cfc2918ca4b1741b5687f21cfe273b.json index 723dd0f4e..883d2174f 100644 --- a/crates/storage-pg/.sqlx/query-4192c1144c0ea530cf1aa77993a38e94cd5cf8b5c42cb037efb7917c6fc44a1d.json +++ b/crates/storage-pg/.sqlx/query-38eb6b635d30ca78ff78b926b414cbd866cfc2918ca4b1741b5687f21cfe273b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_email_id\n , user_id\n , email\n , created_at\n , confirmed_at\n FROM user_emails\n\n WHERE user_email_id = $1\n ", + "query": "\n SELECT user_email_id\n , user_id\n , email\n , created_at\n FROM user_emails\n\n WHERE user_email_id = $1\n ", "describe": { "columns": [ { @@ -22,11 +22,6 @@ "ordinal": 3, "name": "created_at", "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "confirmed_at", - "type_info": "Timestamptz" } ], "parameters": { @@ -38,9 +33,8 @@ false, false, false, - false, - true + false ] }, - "hash": "4192c1144c0ea530cf1aa77993a38e94cd5cf8b5c42cb037efb7917c6fc44a1d" + "hash": "38eb6b635d30ca78ff78b926b414cbd866cfc2918ca4b1741b5687f21cfe273b" } 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-6772b17585f26365e70ec3e342100c6890d2d63f54f1306e1bb95ca6ca123777.json b/crates/storage-pg/.sqlx/query-6772b17585f26365e70ec3e342100c6890d2d63f54f1306e1bb95ca6ca123777.json new file mode 100644 index 000000000..6ee03e2d7 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-6772b17585f26365e70ec3e342100c6890d2d63f54f1306e1bb95ca6ca123777.json @@ -0,0 +1,88 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_registration_id\n , ip_address as \"ip_address: IpAddr\"\n , user_agent\n , post_auth_action\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": "post_auth_action", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "terms_url", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "email_authentication_id", + "type_info": "Uuid" + }, + { + "ordinal": 8, + "name": "hashed_password", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "hashed_password_version", + "type_info": "Int4" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "completed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true, + true, + true, + false, + true, + true, + true, + true, + true, + false, + true + ] + }, + "hash": "6772b17585f26365e70ec3e342100c6890d2d63f54f1306e1bb95ca6ca123777" +} diff --git a/crates/storage-pg/.sqlx/query-7e367e416d18fcf9b227bf053421410b4b7b4af441f0a138c5421d1111cb9f79.json b/crates/storage-pg/.sqlx/query-7e367e416d18fcf9b227bf053421410b4b7b4af441f0a138c5421d1111cb9f79.json new file mode 100644 index 000000000..a6a02b326 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-7e367e416d18fcf9b227bf053421410b4b7b4af441f0a138c5421d1111cb9f79.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "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": [ + { + "ordinal": 0, + "name": "user_email_authentication_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_session_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "user_registration_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "completed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true, + true, + false, + false, + true + ] + }, + "hash": "7e367e416d18fcf9b227bf053421410b4b7b4af441f0a138c5421d1111cb9f79" +} diff --git a/crates/storage-pg/.sqlx/query-e602a7c76386f732de686694257e03f35c18643c91a06f9c4a3fa0a5f103df58.json b/crates/storage-pg/.sqlx/query-7ea1a668480cbfda1439ba80fbd6ef2d751a3bb781e30260383eee3579f3a962.json similarity index 75% rename from crates/storage-pg/.sqlx/query-e602a7c76386f732de686694257e03f35c18643c91a06f9c4a3fa0a5f103df58.json rename to crates/storage-pg/.sqlx/query-7ea1a668480cbfda1439ba80fbd6ef2d751a3bb781e30260383eee3579f3a962.json index 848130a9c..ccadfd747 100644 --- a/crates/storage-pg/.sqlx/query-e602a7c76386f732de686694257e03f35c18643c91a06f9c4a3fa0a5f103df58.json +++ b/crates/storage-pg/.sqlx/query-7ea1a668480cbfda1439ba80fbd6ef2d751a3bb781e30260383eee3579f3a962.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT s.user_session_id\n , s.created_at AS \"user_session_created_at\"\n , s.finished_at AS \"user_session_finished_at\"\n , s.user_agent AS \"user_session_user_agent\"\n , s.last_active_at AS \"user_session_last_active_at\"\n , s.last_active_ip AS \"user_session_last_active_ip: IpAddr\"\n , u.user_id\n , u.username AS \"user_username\"\n , u.primary_user_email_id AS \"user_primary_user_email_id\"\n , u.created_at AS \"user_created_at\"\n , u.locked_at AS \"user_locked_at\"\n , u.can_request_admin AS \"user_can_request_admin\"\n FROM user_sessions s\n INNER JOIN users u\n USING (user_id)\n WHERE s.user_session_id = $1\n ", + "query": "\n SELECT s.user_session_id\n , s.created_at AS \"user_session_created_at\"\n , s.finished_at AS \"user_session_finished_at\"\n , s.user_agent AS \"user_session_user_agent\"\n , s.last_active_at AS \"user_session_last_active_at\"\n , s.last_active_ip AS \"user_session_last_active_ip: IpAddr\"\n , u.user_id\n , u.username AS \"user_username\"\n , u.created_at AS \"user_created_at\"\n , u.locked_at AS \"user_locked_at\"\n , u.can_request_admin AS \"user_can_request_admin\"\n FROM user_sessions s\n INNER JOIN users u\n USING (user_id)\n WHERE s.user_session_id = $1\n ", "describe": { "columns": [ { @@ -45,21 +45,16 @@ }, { "ordinal": 8, - "name": "user_primary_user_email_id", - "type_info": "Uuid" - }, - { - "ordinal": 9, "name": "user_created_at", "type_info": "Timestamptz" }, { - "ordinal": 10, + "ordinal": 9, "name": "user_locked_at", "type_info": "Timestamptz" }, { - "ordinal": 11, + "ordinal": 10, "name": "user_can_request_admin", "type_info": "Bool" } @@ -78,11 +73,10 @@ true, false, false, - true, false, true, false ] }, - "hash": "e602a7c76386f732de686694257e03f35c18643c91a06f9c4a3fa0a5f103df58" + "hash": "7ea1a668480cbfda1439ba80fbd6ef2d751a3bb781e30260383eee3579f3a962" } 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-0d892dc8589ba54bb886972b6db00eaf7e41ff0db98fabdff5dcba0a7aa4e77d.json b/crates/storage-pg/.sqlx/query-86767be88b7594cc9a98a2f1f1c61cf66118f2fda4b4b0415de15087524f1356.json similarity index 63% rename from crates/storage-pg/.sqlx/query-0d892dc8589ba54bb886972b6db00eaf7e41ff0db98fabdff5dcba0a7aa4e77d.json rename to crates/storage-pg/.sqlx/query-86767be88b7594cc9a98a2f1f1c61cf66118f2fda4b4b0415de15087524f1356.json index 5e70ba8d5..82b1b659c 100644 --- a/crates/storage-pg/.sqlx/query-0d892dc8589ba54bb886972b6db00eaf7e41ff0db98fabdff5dcba0a7aa4e77d.json +++ b/crates/storage-pg/.sqlx/query-86767be88b7594cc9a98a2f1f1c61cf66118f2fda4b4b0415de15087524f1356.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_id\n , username\n , primary_user_email_id\n , created_at\n , locked_at\n , can_request_admin\n FROM users\n WHERE user_id = $1\n ", + "query": "\n SELECT user_id\n , username\n , created_at\n , locked_at\n , can_request_admin\n FROM users\n WHERE user_id = $1\n ", "describe": { "columns": [ { @@ -15,21 +15,16 @@ }, { "ordinal": 2, - "name": "primary_user_email_id", - "type_info": "Uuid" - }, - { - "ordinal": 3, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 4, + "ordinal": 3, "name": "locked_at", "type_info": "Timestamptz" }, { - "ordinal": 5, + "ordinal": 4, "name": "can_request_admin", "type_info": "Bool" } @@ -42,11 +37,10 @@ "nullable": [ false, false, - true, false, true, false ] }, - "hash": "0d892dc8589ba54bb886972b6db00eaf7e41ff0db98fabdff5dcba0a7aa4e77d" + "hash": "86767be88b7594cc9a98a2f1f1c61cf66118f2fda4b4b0415de15087524f1356" } diff --git a/crates/storage-pg/.sqlx/query-8d240d72d651f59d53bed7380710038e9d00492b1e282237c0ec0e03bc36a9c0.json b/crates/storage-pg/.sqlx/query-8d240d72d651f59d53bed7380710038e9d00492b1e282237c0ec0e03bc36a9c0.json new file mode 100644 index 000000000..00f736abc --- /dev/null +++ b/crates/storage-pg/.sqlx/query-8d240d72d651f59d53bed7380710038e9d00492b1e282237c0ec0e03bc36a9c0.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_registrations\n ( user_registration_id\n , ip_address\n , user_agent\n , post_auth_action\n , username\n , created_at\n )\n VALUES ($1, $2, $3, $4, $5, $6)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Inet", + "Text", + "Jsonb", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "8d240d72d651f59d53bed7380710038e9d00492b1e282237c0ec0e03bc36a9c0" +} 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-90b5512c0c9dc3b3eb6500056cc72f9993216d9b553c2e33a7edec26ffb0fc59.json b/crates/storage-pg/.sqlx/query-90b5512c0c9dc3b3eb6500056cc72f9993216d9b553c2e33a7edec26ffb0fc59.json deleted file mode 100644 index 6740c5afd..000000000 --- a/crates/storage-pg/.sqlx/query-90b5512c0c9dc3b3eb6500056cc72f9993216d9b553c2e33a7edec26ffb0fc59.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE user_emails\n SET confirmed_at = $2\n WHERE user_email_id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Timestamptz" - ] - }, - "nullable": [] - }, - "hash": "90b5512c0c9dc3b3eb6500056cc72f9993216d9b553c2e33a7edec26ffb0fc59" -} diff --git a/crates/storage-pg/.sqlx/query-921d77c194609615a7e9a6fd806e9cc17a7927e3e5deb58f3917ceeb9ab4dede.json b/crates/storage-pg/.sqlx/query-921d77c194609615a7e9a6fd806e9cc17a7927e3e5deb58f3917ceeb9ab4dede.json deleted file mode 100644 index 2e49564f0..000000000 --- a/crates/storage-pg/.sqlx/query-921d77c194609615a7e9a6fd806e9cc17a7927e3e5deb58f3917ceeb9ab4dede.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE user_email_confirmation_codes\n SET consumed_at = $2\n WHERE user_email_confirmation_code_id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Timestamptz" - ] - }, - "nullable": [] - }, - "hash": "921d77c194609615a7e9a6fd806e9cc17a7927e3e5deb58f3917ceeb9ab4dede" -} diff --git a/crates/storage-pg/.sqlx/query-ae6bf8958c4d9837d63f56574e91f91acc6076a8521adc3e30a83bf70e2121a0.json b/crates/storage-pg/.sqlx/query-ae6bf8958c4d9837d63f56574e91f91acc6076a8521adc3e30a83bf70e2121a0.json new file mode 100644 index 000000000..d46a6c5da --- /dev/null +++ b/crates/storage-pg/.sqlx/query-ae6bf8958c4d9837d63f56574e91f91acc6076a8521adc3e30a83bf70e2121a0.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_email_authentication_code_id\n , user_email_authentication_id\n , code\n , created_at\n , expires_at\n FROM user_email_authentication_codes\n WHERE user_email_authentication_id = $1\n AND code = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_email_authentication_code_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_email_authentication_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "code", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "ae6bf8958c4d9837d63f56574e91f91acc6076a8521adc3e30a83bf70e2121a0" +} diff --git a/crates/storage-pg/.sqlx/query-b515bbfb331e46acd3c0219f09223cc5d8d31cb41287e693dcb82c6e199f7991.json b/crates/storage-pg/.sqlx/query-b515bbfb331e46acd3c0219f09223cc5d8d31cb41287e693dcb82c6e199f7991.json deleted file mode 100644 index 8f0424c91..000000000 --- a/crates/storage-pg/.sqlx/query-b515bbfb331e46acd3c0219f09223cc5d8d31cb41287e693dcb82c6e199f7991.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO user_email_confirmation_codes\n (user_email_confirmation_code_id, user_email_id, code, created_at, expires_at)\n VALUES ($1, $2, $3, $4, $5)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid", - "Text", - "Timestamptz", - "Timestamptz" - ] - }, - "nullable": [] - }, - "hash": "b515bbfb331e46acd3c0219f09223cc5d8d31cb41287e693dcb82c6e199f7991" -} 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-90fe32cb9c88a262a682c0db700fef7d69d6ce0be1f930d9f16c50b921a8b819.json b/crates/storage-pg/.sqlx/query-b697bbc5aaaca219602ac8f19f90097e88faf8052effa84a03cc638ae315ff69.json similarity index 59% rename from crates/storage-pg/.sqlx/query-90fe32cb9c88a262a682c0db700fef7d69d6ce0be1f930d9f16c50b921a8b819.json rename to crates/storage-pg/.sqlx/query-b697bbc5aaaca219602ac8f19f90097e88faf8052effa84a03cc638ae315ff69.json index a9d19cac0..d62f4c55b 100644 --- a/crates/storage-pg/.sqlx/query-90fe32cb9c88a262a682c0db700fef7d69d6ce0be1f930d9f16c50b921a8b819.json +++ b/crates/storage-pg/.sqlx/query-b697bbc5aaaca219602ac8f19f90097e88faf8052effa84a03cc638ae315ff69.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO user_emails (user_email_id, user_id, email, created_at)\n VALUES ($1, $2, $3, $4)\n ", + "query": "\n INSERT INTO user_emails (user_email_id, user_id, email, created_at, confirmed_at)\n VALUES ($1, $2, $3, $4, $4)\n ", "describe": { "columns": [], "parameters": { @@ -13,5 +13,5 @@ }, "nullable": [] }, - "hash": "90fe32cb9c88a262a682c0db700fef7d69d6ce0be1f930d9f16c50b921a8b819" + "hash": "b697bbc5aaaca219602ac8f19f90097e88faf8052effa84a03cc638ae315ff69" } diff --git a/crates/storage-pg/.sqlx/query-bd1f6daa5fa1b10250c01f8b3fbe451646a9ceeefa6f72b9c4e29b6d05f17641.json b/crates/storage-pg/.sqlx/query-bd1f6daa5fa1b10250c01f8b3fbe451646a9ceeefa6f72b9c4e29b6d05f17641.json deleted file mode 100644 index f2a4a2c7b..000000000 --- a/crates/storage-pg/.sqlx/query-bd1f6daa5fa1b10250c01f8b3fbe451646a9ceeefa6f72b9c4e29b6d05f17641.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE users\n SET primary_user_email_id = user_emails.user_email_id\n FROM user_emails\n WHERE user_emails.user_email_id = $1\n AND users.user_id = user_emails.user_id\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "bd1f6daa5fa1b10250c01f8b3fbe451646a9ceeefa6f72b9c4e29b6d05f17641" -} diff --git a/crates/storage-pg/.sqlx/query-dd02cc4a48123c28b34da8501060096c33df9e30611ef89d01bf0502119cbbe1.json b/crates/storage-pg/.sqlx/query-dd02cc4a48123c28b34da8501060096c33df9e30611ef89d01bf0502119cbbe1.json new file mode 100644 index 000000000..01b783259 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-dd02cc4a48123c28b34da8501060096c33df9e30611ef89d01bf0502119cbbe1.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_email_authentications\n SET completed_at = $2\n WHERE user_email_authentication_id = $1\n AND completed_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "dd02cc4a48123c28b34da8501060096c33df9e30611ef89d01bf0502119cbbe1" +} diff --git a/crates/storage-pg/.sqlx/query-a300fe99c95679c5664646a6a525c0491829e97db45f3234483872ed38436322.json b/crates/storage-pg/.sqlx/query-dda97742d389ffeeaab33d352d05767e2150f7da3cf384a7f44741c769f44144.json similarity index 64% rename from crates/storage-pg/.sqlx/query-a300fe99c95679c5664646a6a525c0491829e97db45f3234483872ed38436322.json rename to crates/storage-pg/.sqlx/query-dda97742d389ffeeaab33d352d05767e2150f7da3cf384a7f44741c769f44144.json index c10ed0e59..c36ba7d27 100644 --- a/crates/storage-pg/.sqlx/query-a300fe99c95679c5664646a6a525c0491829e97db45f3234483872ed38436322.json +++ b/crates/storage-pg/.sqlx/query-dda97742d389ffeeaab33d352d05767e2150f7da3cf384a7f44741c769f44144.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_email_id\n , user_id\n , email\n , created_at\n , confirmed_at\n FROM user_emails\n\n WHERE user_id = $1\n\n ORDER BY email ASC\n ", + "query": "\n SELECT user_email_id\n , user_id\n , email\n , created_at\n FROM user_emails\n\n WHERE user_id = $1\n\n ORDER BY email ASC\n ", "describe": { "columns": [ { @@ -22,11 +22,6 @@ "ordinal": 3, "name": "created_at", "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "confirmed_at", - "type_info": "Timestamptz" } ], "parameters": { @@ -38,9 +33,8 @@ false, false, false, - false, - true + false ] }, - "hash": "a300fe99c95679c5664646a6a525c0491829e97db45f3234483872ed38436322" + "hash": "dda97742d389ffeeaab33d352d05767e2150f7da3cf384a7f44741c769f44144" } diff --git a/crates/storage-pg/.sqlx/query-423e6aa88e0b8a01a90e108107a3d3998418fa43638b6510f28b56a2d6952222.json b/crates/storage-pg/.sqlx/query-e1a18bd82d28fd86d8b8da8a6ac6eddf224ab32cf96e9c28706dd9aa1d09332b.json similarity index 63% rename from crates/storage-pg/.sqlx/query-423e6aa88e0b8a01a90e108107a3d3998418fa43638b6510f28b56a2d6952222.json rename to crates/storage-pg/.sqlx/query-e1a18bd82d28fd86d8b8da8a6ac6eddf224ab32cf96e9c28706dd9aa1d09332b.json index e95ba53aa..2a7e94117 100644 --- a/crates/storage-pg/.sqlx/query-423e6aa88e0b8a01a90e108107a3d3998418fa43638b6510f28b56a2d6952222.json +++ b/crates/storage-pg/.sqlx/query-e1a18bd82d28fd86d8b8da8a6ac6eddf224ab32cf96e9c28706dd9aa1d09332b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_id\n , username\n , primary_user_email_id\n , created_at\n , locked_at\n , can_request_admin\n FROM users\n WHERE username = $1\n ", + "query": "\n SELECT user_id\n , username\n , created_at\n , locked_at\n , can_request_admin\n FROM users\n WHERE username = $1\n ", "describe": { "columns": [ { @@ -15,21 +15,16 @@ }, { "ordinal": 2, - "name": "primary_user_email_id", - "type_info": "Uuid" - }, - { - "ordinal": 3, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 4, + "ordinal": 3, "name": "locked_at", "type_info": "Timestamptz" }, { - "ordinal": 5, + "ordinal": 4, "name": "can_request_admin", "type_info": "Bool" } @@ -42,11 +37,10 @@ "nullable": [ false, false, - true, false, true, false ] }, - "hash": "423e6aa88e0b8a01a90e108107a3d3998418fa43638b6510f28b56a2d6952222" + "hash": "e1a18bd82d28fd86d8b8da8a6ac6eddf224ab32cf96e9c28706dd9aa1d09332b" } diff --git a/crates/storage-pg/.sqlx/query-aff08a8caabeb62f4929e6e901e7ca7c55e284c18c5c1d1e78821dd9bc961412.json b/crates/storage-pg/.sqlx/query-f7d26de1d380e3e52f47f2b89ed7506e1e4cca72682bc7737e6508dc4015b8d5.json similarity index 66% rename from crates/storage-pg/.sqlx/query-aff08a8caabeb62f4929e6e901e7ca7c55e284c18c5c1d1e78821dd9bc961412.json rename to crates/storage-pg/.sqlx/query-f7d26de1d380e3e52f47f2b89ed7506e1e4cca72682bc7737e6508dc4015b8d5.json index d235cab38..3e278f223 100644 --- a/crates/storage-pg/.sqlx/query-aff08a8caabeb62f4929e6e901e7ca7c55e284c18c5c1d1e78821dd9bc961412.json +++ b/crates/storage-pg/.sqlx/query-f7d26de1d380e3e52f47f2b89ed7506e1e4cca72682bc7737e6508dc4015b8d5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_email_id\n , user_id\n , email\n , created_at\n , confirmed_at\n FROM user_emails\n\n WHERE user_id = $1 AND email = $2\n ", + "query": "\n SELECT user_email_id\n , user_id\n , email\n , created_at\n FROM user_emails\n\n WHERE user_id = $1 AND email = $2\n ", "describe": { "columns": [ { @@ -22,11 +22,6 @@ "ordinal": 3, "name": "created_at", "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "confirmed_at", - "type_info": "Timestamptz" } ], "parameters": { @@ -39,9 +34,8 @@ false, false, false, - false, - true + false ] }, - "hash": "aff08a8caabeb62f4929e6e901e7ca7c55e284c18c5c1d1e78821dd9bc961412" + "hash": "f7d26de1d380e3e52f47f2b89ed7506e1e4cca72682bc7737e6508dc4015b8d5" } diff --git a/crates/storage-pg/migrations/20250109105709_user_email_authentication_codes.sql b/crates/storage-pg/migrations/20250109105709_user_email_authentication_codes.sql new file mode 100644 index 000000000..fb1b02e4e --- /dev/null +++ b/crates/storage-pg/migrations/20250109105709_user_email_authentication_codes.sql @@ -0,0 +1,29 @@ +-- 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 email authentication sessions +CREATE TABLE "user_email_authentications" ( + "user_email_authentication_id" UUID PRIMARY KEY, + "user_session_id" UUID + REFERENCES "user_sessions" ("user_session_id") + ON DELETE SET NULL, + "email" TEXT NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "completed_at" TIMESTAMP WITH TIME ZONE +); + +-- A single authentication session has multiple codes, in case the user ask for re-sending +CREATE TABLE "user_email_authentication_codes" ( + "user_email_authentication_code_id" UUID PRIMARY KEY, + "user_email_authentication_id" UUID + NOT NULL + REFERENCES "user_email_authentications" ("user_email_authentication_id") + ON DELETE CASCADE, + "code" TEXT NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT "user_email_authentication_codes_auth_id_code_unique" + UNIQUE ("user_email_authentication_id", "code") +); 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..6b4590f43 --- /dev/null +++ b/crates/storage-pg/migrations/20250113102144_user_registrations.sql @@ -0,0 +1,49 @@ +-- 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, + + -- The IP address of the user agent, if any + "ip_address" INET, + + -- The user agent string of the user agent, if any + "user_agent" TEXT, + + -- The post auth action to execute after the registration, if any + "post_auth_action" JSONB, + + -- The username the user asked for + "username" TEXT NOT NULL, + + -- The display name the user asked for + "display_name" TEXT, + + -- The URL to the terms of service at the time of registration + "terms_url" TEXT, + + -- The ID of the email authentication session + "email_authentication_id" UUID + REFERENCES "user_email_authentications" ("user_email_authentication_id") + ON DELETE SET NULL, + + -- The hashed password of the user + "hashed_password" TEXT, + -- The scheme version used to hash the password + "hashed_password_version" INTEGER, + + -- When the object was created + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + + -- When the registration was completed + "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/migrations/20250115155255_cleanup_unverified_emails.sql b/crates/storage-pg/migrations/20250115155255_cleanup_unverified_emails.sql new file mode 100644 index 000000000..61a4101f6 --- /dev/null +++ b/crates/storage-pg/migrations/20250115155255_cleanup_unverified_emails.sql @@ -0,0 +1,14 @@ +-- Copyright 2025 New Vector Ltd. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +-- This drops all the unverified email addresses from the database, as they are +-- now always verified when they land in the user_emails table. +-- We don't drop the `confirmed_at` column to allow rolling back + +-- First, truncate all the confirmation codes +TRUNCATE TABLE user_email_confirmation_codes; + +-- Then, delete all the unverified email addresses +DELETE FROM user_emails WHERE confirmed_at IS NULL; diff --git a/crates/storage-pg/src/iden.rs b/crates/storage-pg/src/iden.rs index 46cb2bc85..8393cb247 100644 --- a/crates/storage-pg/src/iden.rs +++ b/crates/storage-pg/src/iden.rs @@ -23,7 +23,6 @@ pub enum Users { Table, UserId, Username, - PrimaryUserEmailId, CreatedAt, LockedAt, CanRequestAdmin, @@ -36,7 +35,6 @@ pub enum UserEmails { UserId, Email, CreatedAt, - ConfirmedAt, } #[derive(sea_query::Iden)] 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 5f4a04fa4..091714f4a 100644 --- a/crates/storage-pg/src/user/email.rs +++ b/crates/storage-pg/src/user/email.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -6,7 +6,10 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{User, UserEmail, UserEmailVerification, UserEmailVerificationState}; +use mas_data_model::{ + BrowserSession, User, UserEmail, UserEmailAuthentication, UserEmailAuthenticationCode, + UserRegistration, +}; use mas_storage::{ user::{UserEmailFilter, UserEmailRepository}, Clock, Page, Pagination, @@ -25,7 +28,7 @@ use crate::{ iden::UserEmails, pagination::QueryBuilderExt, tracing::ExecuteExt, - DatabaseError, DatabaseInconsistencyError, + DatabaseError, }; /// An implementation of [`UserEmailRepository`] for a PostgreSQL connection @@ -48,7 +51,6 @@ struct UserEmailLookup { user_id: Uuid, email: String, created_at: DateTime, - confirmed_at: Option>, } impl From for UserEmail { @@ -58,39 +60,48 @@ impl From for UserEmail { user_id: e.user_id.into(), email: e.email, created_at: e.created_at, - confirmed_at: e.confirmed_at, } } } -struct UserEmailConfirmationCodeLookup { - user_email_confirmation_code_id: Uuid, - user_email_id: Uuid, +struct UserEmailAuthenticationLookup { + user_email_authentication_id: Uuid, + user_session_id: Option, + user_registration_id: Option, + email: String, + created_at: DateTime, + completed_at: Option>, +} + +impl From for UserEmailAuthentication { + fn from(value: UserEmailAuthenticationLookup) -> Self { + 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, + } + } +} + +struct UserEmailAuthenticationCodeLookup { + user_email_authentication_code_id: Uuid, + user_email_authentication_id: Uuid, code: String, created_at: DateTime, expires_at: DateTime, - consumed_at: Option>, } -impl UserEmailConfirmationCodeLookup { - fn into_verification(self, clock: &dyn Clock) -> UserEmailVerification { - let now = clock.now(); - let state = if let Some(when) = self.consumed_at { - UserEmailVerificationState::AlreadyUsed { when } - } else if self.expires_at < now { - UserEmailVerificationState::Expired { - when: self.expires_at, - } - } else { - UserEmailVerificationState::Valid - }; - - UserEmailVerification { - id: self.user_email_confirmation_code_id.into(), - user_email_id: self.user_email_id.into(), - code: self.code, - state, - created_at: self.created_at, +impl From for UserEmailAuthenticationCode { + fn from(value: UserEmailAuthenticationCodeLookup) -> Self { + UserEmailAuthenticationCode { + id: value.user_email_authentication_code_id.into(), + user_email_authentication_id: value.user_email_authentication_id.into(), + code: value.code, + created_at: value.created_at, + expires_at: value.expires_at, } } } @@ -105,13 +116,6 @@ impl Filter for UserEmailFilter<'_> { self.email() .map(|email| Expr::col((UserEmails::Table, UserEmails::Email)).eq(email)), ) - .add_option(self.state().map(|state| { - if state.is_verified() { - Expr::col((UserEmails::Table, UserEmails::ConfirmedAt)).is_not_null() - } else { - Expr::col((UserEmails::Table, UserEmails::ConfirmedAt)).is_null() - } - })) } } @@ -136,7 +140,6 @@ impl UserEmailRepository for PgUserEmailRepository<'_> { , user_id , email , created_at - , confirmed_at FROM user_emails WHERE user_email_id = $1 @@ -172,7 +175,6 @@ impl UserEmailRepository for PgUserEmailRepository<'_> { , user_id , email , created_at - , confirmed_at FROM user_emails WHERE user_id = $1 AND email = $2 @@ -191,29 +193,6 @@ impl UserEmailRepository for PgUserEmailRepository<'_> { Ok(Some(user_email.into())) } - #[tracing::instrument( - name = "db.user_email.get_primary", - skip_all, - fields( - db.query.text, - %user.id, - ), - err, - )] - async fn get_primary(&mut self, user: &User) -> Result, Self::Error> { - let Some(id) = user.primary_user_email_id else { - return Ok(None); - }; - - let user_email = self.lookup(id).await?.ok_or_else(|| { - DatabaseInconsistencyError::on("users") - .column("primary_user_email_id") - .row(user.id) - })?; - - Ok(Some(user_email)) - } - #[tracing::instrument( name = "db.user_email.all", skip_all, @@ -231,7 +210,6 @@ impl UserEmailRepository for PgUserEmailRepository<'_> { , user_id , email , created_at - , confirmed_at FROM user_emails WHERE user_id = $1 @@ -277,10 +255,6 @@ impl UserEmailRepository for PgUserEmailRepository<'_> { Expr::col((UserEmails::Table, UserEmails::CreatedAt)), UserEmailLookupIden::CreatedAt, ) - .expr_as( - Expr::col((UserEmails::Table, UserEmails::ConfirmedAt)), - UserEmailLookupIden::ConfirmedAt, - ) .from(UserEmails::Table) .apply_filter(filter) .generate_pagination((UserEmails::Table, UserEmails::UserEmailId), pagination) @@ -343,10 +317,12 @@ impl UserEmailRepository for PgUserEmailRepository<'_> { let id = Ulid::from_datetime_with_source(created_at.into(), rng); tracing::Span::current().record("user_email.id", tracing::field::display(id)); + // We now always set the 'confirmed_at' field, so that older app version + // consider those emails as verified. sqlx::query!( r#" - INSERT INTO user_emails (user_email_id, user_id, email, created_at) - VALUES ($1, $2, $3, $4) + INSERT INTO user_emails (user_email_id, user_id, email, created_at, confirmed_at) + VALUES ($1, $2, $3, $4, $4) "#, Uuid::from(id), Uuid::from(user.id), @@ -362,7 +338,6 @@ impl UserEmailRepository for PgUserEmailRepository<'_> { user_id: user.id, email, created_at, - confirmed_at: None, }) } @@ -410,79 +385,152 @@ impl UserEmailRepository for PgUserEmailRepository<'_> { Ok(()) } - async fn mark_as_verified( + #[tracing::instrument( + name = "db.user_email.add_authentication_for_session", + skip_all, + fields( + db.query.text, + %session.id, + user_email_authentication.id, + user_email_authentication.email = email, + ), + err, + )] + async fn add_authentication_for_session( &mut self, + rng: &mut (dyn RngCore + Send), clock: &dyn Clock, - mut user_email: UserEmail, - ) -> Result { - let confirmed_at = clock.now(); + email: String, + session: &BrowserSession, + ) -> 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#" - UPDATE user_emails - SET confirmed_at = $2 - WHERE user_email_id = $1 + INSERT INTO user_email_authentications + ( user_email_authentication_id + , user_session_id + , email + , created_at + ) + VALUES ($1, $2, $3, $4) "#, - Uuid::from(user_email.id), - confirmed_at, + Uuid::from(id), + Uuid::from(session.id), + &email, + created_at, ) + .traced() .execute(&mut *self.conn) .await?; - user_email.confirmed_at = Some(confirmed_at); - Ok(user_email) + Ok(UserEmailAuthentication { + id, + user_session_id: Some(session.id), + user_registration_id: None, + email, + created_at, + completed_at: None, + }) } - async fn set_as_primary(&mut self, user_email: &UserEmail) -> Result<(), Self::Error> { + #[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#" - UPDATE users - SET primary_user_email_id = user_emails.user_email_id - FROM user_emails - WHERE user_emails.user_email_id = $1 - AND users.user_id = user_emails.user_id + INSERT INTO user_email_authentications + ( user_email_authentication_id + , user_registration_id + , email + , created_at + ) + VALUES ($1, $2, $3, $4) "#, - Uuid::from(user_email.id), + Uuid::from(id), + Uuid::from(user_registration.id), + &email, + created_at, ) + .traced() .execute(&mut *self.conn) .await?; - Ok(()) + Ok(UserEmailAuthentication { + id, + user_session_id: None, + user_registration_id: Some(user_registration.id), + email, + created_at, + completed_at: None, + }) } #[tracing::instrument( - name = "db.user_email.add_verification_code", + name = "db.user_email.add_authentication_code", skip_all, fields( db.query.text, - %user_email.id, - %user_email.email, - user_email_verification.id, - user_email_verification.code = code, + %user_email_authentication.id, + %user_email_authentication.email, + user_email_authentication_code.id, + user_email_authentication_code.code = code, ), err, )] - async fn add_verification_code( + async fn add_authentication_code( &mut self, rng: &mut (dyn RngCore + Send), clock: &dyn Clock, - user_email: &UserEmail, - max_age: chrono::Duration, + duration: chrono::Duration, + user_email_authentication: &UserEmailAuthentication, code: String, - ) -> Result { + ) -> Result { let created_at = clock.now(); + let expires_at = created_at + duration; let id = Ulid::from_datetime_with_source(created_at.into(), rng); - tracing::Span::current().record("user_email_confirmation.id", tracing::field::display(id)); - let expires_at = created_at + max_age; + tracing::Span::current().record( + "user_email_authentication_code.id", + tracing::field::display(id), + ); sqlx::query!( r#" - INSERT INTO user_email_confirmation_codes - (user_email_confirmation_code_id, user_email_id, code, created_at, expires_at) + INSERT INTO user_email_authentication_codes + ( user_email_authentication_code_id + , user_email_authentication_id + , code + , created_at + , expires_at + ) VALUES ($1, $2, $3, $4, $5) "#, Uuid::from(id), - Uuid::from(user_email.id), - code, + Uuid::from(user_email_authentication.id), + &code, created_at, expires_at, ) @@ -490,98 +538,129 @@ impl UserEmailRepository for PgUserEmailRepository<'_> { .execute(&mut *self.conn) .await?; - let verification = UserEmailVerification { + Ok(UserEmailAuthenticationCode { id, - user_email_id: user_email.id, + user_email_authentication_id: user_email_authentication.id, code, created_at, - state: UserEmailVerificationState::Valid, - }; + expires_at, + }) + } - Ok(verification) + #[tracing::instrument( + name = "db.user_email.lookup_authentication", + skip_all, + fields( + db.query.text, + user_email_authentication.id = %id, + ), + err, + )] + async fn lookup_authentication( + &mut self, + id: Ulid, + ) -> Result, Self::Error> { + let res = sqlx::query_as!( + UserEmailAuthenticationLookup, + r#" + SELECT user_email_authentication_id + , user_session_id + , user_registration_id + , email + , created_at + , completed_at + FROM user_email_authentications + WHERE user_email_authentication_id = $1 + "#, + Uuid::from(id), + ) + .traced() + .fetch_optional(&mut *self.conn) + .await?; + + Ok(res.map(UserEmailAuthentication::from)) } #[tracing::instrument( - name = "db.user_email.find_verification_code", + name = "db.user_email.find_authentication_by_code", skip_all, fields( db.query.text, - %user_email.id, - user.id = %user_email.user_id, + %authentication.id, + user_email_authentication_code.code = code, ), err, )] - async fn find_verification_code( + async fn find_authentication_code( &mut self, - clock: &dyn Clock, - user_email: &UserEmail, + authentication: &UserEmailAuthentication, code: &str, - ) -> Result, Self::Error> { + ) -> Result, Self::Error> { let res = sqlx::query_as!( - UserEmailConfirmationCodeLookup, + UserEmailAuthenticationCodeLookup, r#" - SELECT user_email_confirmation_code_id - , user_email_id + SELECT user_email_authentication_code_id + , user_email_authentication_id , code , created_at , expires_at - , consumed_at - FROM user_email_confirmation_codes - WHERE code = $1 - AND user_email_id = $2 + FROM user_email_authentication_codes + WHERE user_email_authentication_id = $1 + AND code = $2 "#, + Uuid::from(authentication.id), code, - Uuid::from(user_email.id), ) .traced() .fetch_optional(&mut *self.conn) .await?; - let Some(res) = res else { return Ok(None) }; - - Ok(Some(res.into_verification(clock))) + Ok(res.map(UserEmailAuthenticationCode::from)) } #[tracing::instrument( - name = "db.user_email.consume_verification_code", + name = "db.user_email.complete_email_authentication", skip_all, fields( db.query.text, - %user_email_verification.id, - user_email.id = %user_email_verification.user_email_id, + %user_email_authentication.id, + %user_email_authentication.email, + %user_email_authentication_code.id, + %user_email_authentication_code.code, ), err, )] - async fn consume_verification_code( + async fn complete_authentication( &mut self, clock: &dyn Clock, - mut user_email_verification: UserEmailVerification, - ) -> Result { - if !matches!( - user_email_verification.state, - UserEmailVerificationState::Valid - ) { - return Err(DatabaseError::invalid_operation()); - } - - let consumed_at = clock.now(); - - sqlx::query!( + mut user_email_authentication: UserEmailAuthentication, + user_email_authentication_code: &UserEmailAuthenticationCode, + ) -> Result { + // We technically don't use the authentication code here (other than + // recording it in the span), but this is to make sure the caller has + // fetched one before calling this + let completed_at = clock.now(); + + // We'll assume the caller has checked that completed_at is None, so in case + // they haven't, the update will not affect any rows, which will raise + // an error + let res = sqlx::query!( r#" - UPDATE user_email_confirmation_codes - SET consumed_at = $2 - WHERE user_email_confirmation_code_id = $1 + UPDATE user_email_authentications + SET completed_at = $2 + WHERE user_email_authentication_id = $1 + AND completed_at IS NULL "#, - Uuid::from(user_email_verification.id), - consumed_at + Uuid::from(user_email_authentication.id), + completed_at, ) .traced() .execute(&mut *self.conn) .await?; - user_email_verification.state = - UserEmailVerificationState::AlreadyUsed { when: consumed_at }; + DatabaseError::ensure_affected_rows(&res, 1)?; - Ok(user_email_verification) + user_email_authentication.completed_at = Some(completed_at); + Ok(user_email_authentication) } } diff --git a/crates/storage-pg/src/user/mod.rs b/crates/storage-pg/src/user/mod.rs index 8824c299b..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 @@ -69,7 +70,6 @@ mod priv_ { pub(super) struct UserLookup { pub(super) user_id: Uuid, pub(super) username: String, - pub(super) primary_user_email_id: Option, pub(super) created_at: DateTime, pub(super) locked_at: Option>, pub(super) can_request_admin: bool, @@ -85,7 +85,6 @@ impl From for User { id, username: value.username, sub: id.to_string(), - primary_user_email_id: value.primary_user_email_id.map(Into::into), created_at: value.created_at, locked_at: value.locked_at, can_request_admin: value.can_request_admin, @@ -128,7 +127,6 @@ impl UserRepository for PgUserRepository<'_> { r#" SELECT user_id , username - , primary_user_email_id , created_at , locked_at , can_request_admin @@ -161,7 +159,6 @@ impl UserRepository for PgUserRepository<'_> { r#" SELECT user_id , username - , primary_user_email_id , created_at , locked_at , can_request_admin @@ -221,7 +218,6 @@ impl UserRepository for PgUserRepository<'_> { id, username, sub: id.to_string(), - primary_user_email_id: None, created_at, locked_at: None, can_request_admin: false, @@ -378,10 +374,6 @@ impl UserRepository for PgUserRepository<'_> { Expr::col((Users::Table, Users::Username)), UserLookupIden::Username, ) - .expr_as( - Expr::col((Users::Table, Users::PrimaryUserEmailId)), - UserLookupIden::PrimaryUserEmailId, - ) .expr_as( Expr::col((Users::Table, Users::CreatedAt)), UserLookupIden::CreatedAt, diff --git a/crates/storage-pg/src/user/registration.rs b/crates/storage-pg/src/user/registration.rs new file mode 100644 index 000000000..5087d1d9e --- /dev/null +++ b/crates/storage-pg/src/user/registration.rs @@ -0,0 +1,819 @@ +// 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, + post_auth_action: Option, + username: String, + 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, + post_auth_action: value.post_auth_action, + 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 + , post_auth_action + , 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, + username: String, + ip_address: Option, + user_agent: Option, + post_auth_action: 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 + , post_auth_action + , username + , created_at + ) + VALUES ($1, $2, $3, $4, $5, $6) + "#, + Uuid::from(id), + ip_address as Option, + user_agent.as_deref(), + post_auth_action, + username, + created_at, + ) + .traced() + .execute(&mut *self.conn) + .await?; + + Ok(UserRegistration { + id, + ip_address, + user_agent, + post_auth_action, + created_at, + completed_at: None, + username, + display_name: None, + terms_url: None, + email_authentication_id: None, + password: None, + }) + } + + #[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, "alice".to_owned(), None, None, None) + .await + .unwrap(); + + assert_eq!(registration.created_at, clock.now()); + assert_eq!(registration.completed_at, None); + assert_eq!(registration.username, "alice"); + 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); + assert_eq!(registration.post_auth_action, 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); + assert_eq!(lookup.post_auth_action, registration.post_auth_action); + + // 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, + "alice".to_owned(), + Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))), + Some(UserAgent::parse("Mozilla/5.0".to_owned())), + Some(serde_json::json!({"action": "continue_compat_sso_login", "id": "01FSHN9AG0MKGTBNZ16RDR3PVY"})), + ) + .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))) + ); + assert_eq!( + registration.post_auth_action, + Some( + serde_json::json!({"action": "continue_compat_sso_login", "id": "01FSHN9AG0MKGTBNZ16RDR3PVY"}) + ) + ); + + 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); + assert_eq!(lookup.post_auth_action, registration.post_auth_action); + } + + #[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, "alice".to_owned(), None, 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, "alice".to_owned(), None, 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, "alice".to_owned(), None, 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, "alice".to_owned(), None, 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-pg/src/user/session.rs b/crates/storage-pg/src/user/session.rs index 7191e360b..7d0f6bda7 100644 --- a/crates/storage-pg/src/user/session.rs +++ b/crates/storage-pg/src/user/session.rs @@ -57,7 +57,6 @@ struct SessionLookup { user_session_last_active_ip: Option, user_id: Uuid, user_username: String, - user_primary_user_email_id: Option, user_created_at: DateTime, user_locked_at: Option>, user_can_request_admin: bool, @@ -72,7 +71,6 @@ impl TryFrom for BrowserSession { id, username: value.user_username, sub: id.to_string(), - primary_user_email_id: value.user_primary_user_email_id.map(Into::into), created_at: value.user_created_at, locked_at: value.user_locked_at, can_request_admin: value.user_can_request_admin, @@ -173,7 +171,6 @@ impl BrowserSessionRepository for PgBrowserSessionRepository<'_> { , s.last_active_ip AS "user_session_last_active_ip: IpAddr" , u.user_id , u.username AS "user_username" - , u.primary_user_email_id AS "user_primary_user_email_id" , u.created_at AS "user_created_at" , u.locked_at AS "user_locked_at" , u.can_request_admin AS "user_can_request_admin" @@ -351,10 +348,6 @@ impl BrowserSessionRepository for PgBrowserSessionRepository<'_> { Expr::col((Users::Table, Users::Username)), SessionLookupIden::UserUsername, ) - .expr_as( - Expr::col((Users::Table, Users::PrimaryUserEmailId)), - SessionLookupIden::UserPrimaryUserEmailId, - ) .expr_as( Expr::col((Users::Table, Users::CreatedAt)), SessionLookupIden::UserCreatedAt, diff --git a/crates/storage-pg/src/user/tests.rs b/crates/storage-pg/src/user/tests.rs index 55976cb18..c6371500a 100644 --- a/crates/storage-pg/src/user/tests.rs +++ b/crates/storage-pg/src/user/tests.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -11,7 +11,7 @@ use mas_storage::{ BrowserSessionFilter, BrowserSessionRepository, UserEmailFilter, UserEmailRepository, UserFilter, UserPasswordRepository, UserRepository, }, - Pagination, RepositoryAccess, + Clock, Pagination, RepositoryAccess, }; use rand::SeedableRng; use rand_chacha::ChaChaRng; @@ -185,8 +185,6 @@ async fn test_user_repo(pool: PgPool) { #[sqlx::test(migrator = "crate::MIGRATOR")] async fn test_user_email_repo(pool: PgPool) { const USERNAME: &str = "john"; - const CODE: &str = "012345"; - const CODE2: &str = "543210"; const EMAIL: &str = "john@example.com"; let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); @@ -208,13 +206,9 @@ async fn test_user_email_repo(pool: PgPool) { .is_none()); let all = UserEmailFilter::new().for_user(&user); - let pending = all.pending_only(); - let verified = all.verified_only(); // Check the counts assert_eq!(repo.user_email().count(all).await.unwrap(), 0); - assert_eq!(repo.user_email().count(pending).await.unwrap(), 0); - assert_eq!(repo.user_email().count(verified).await.unwrap(), 0); let user_email = repo .user_email() @@ -224,12 +218,9 @@ async fn test_user_email_repo(pool: PgPool) { assert_eq!(user_email.user_id, user.id); assert_eq!(user_email.email, EMAIL); - assert!(user_email.confirmed_at.is_none()); // Check the counts assert_eq!(repo.user_email().count(all).await.unwrap(), 1); - assert_eq!(repo.user_email().count(pending).await.unwrap(), 1); - assert_eq!(repo.user_email().count(verified).await.unwrap(), 0); assert!(repo .user_email() @@ -248,115 +239,6 @@ async fn test_user_email_repo(pool: PgPool) { assert_eq!(user_email.user_id, user.id); assert_eq!(user_email.email, EMAIL); - let verification = repo - .user_email() - .add_verification_code( - &mut rng, - &clock, - &user_email, - Duration::try_hours(8).unwrap(), - CODE.to_owned(), - ) - .await - .unwrap(); - - let verification_id = verification.id; - assert_eq!(verification.user_email_id, user_email.id); - assert_eq!(verification.code, CODE); - - // A single user email can have multiple verification at the same time - let _verification2 = repo - .user_email() - .add_verification_code( - &mut rng, - &clock, - &user_email, - Duration::try_hours(8).unwrap(), - CODE2.to_owned(), - ) - .await - .unwrap(); - - let verification = repo - .user_email() - .find_verification_code(&clock, &user_email, CODE) - .await - .unwrap() - .expect("user email verification was not found"); - - assert_eq!(verification.id, verification_id); - assert_eq!(verification.user_email_id, user_email.id); - assert_eq!(verification.code, CODE); - - // Consuming the verification code - repo.user_email() - .consume_verification_code(&clock, verification) - .await - .unwrap(); - - // Mark the email as verified - repo.user_email() - .mark_as_verified(&clock, user_email) - .await - .unwrap(); - - // Check the counts - assert_eq!(repo.user_email().count(all).await.unwrap(), 1); - assert_eq!(repo.user_email().count(pending).await.unwrap(), 0); - assert_eq!(repo.user_email().count(verified).await.unwrap(), 1); - - // Reload the user_email - let user_email = repo - .user_email() - .find(&user, EMAIL) - .await - .unwrap() - .expect("user email was not found"); - - // The email should be marked as verified now - assert!(user_email.confirmed_at.is_some()); - - // Reload the verification - let verification = repo - .user_email() - .find_verification_code(&clock, &user_email, CODE) - .await - .unwrap() - .expect("user email verification was not found"); - - // Consuming a second time should not work - assert!(repo - .user_email() - .consume_verification_code(&clock, verification) - .await - .is_err()); - - // The user shouldn't have a primary email yet - assert!(repo - .user_email() - .get_primary(&user) - .await - .unwrap() - .is_none()); - - repo.user_email().set_as_primary(&user_email).await.unwrap(); - - // Reload the user - let user = repo - .user() - .lookup(user.id) - .await - .unwrap() - .expect("user was not found"); - - // Now it should have one - assert!(repo - .user_email() - .get_primary(&user) - .await - .unwrap() - .is_some()); - // Listing the user emails should work let emails = repo .user_email() @@ -367,23 +249,6 @@ async fn test_user_email_repo(pool: PgPool) { assert_eq!(emails.edges.len(), 1); assert_eq!(emails.edges[0], user_email); - let emails = repo - .user_email() - .list(verified, Pagination::first(10)) - .await - .unwrap(); - assert!(!emails.has_next_page); - assert_eq!(emails.edges.len(), 1); - assert_eq!(emails.edges[0], user_email); - - let emails = repo - .user_email() - .list(pending, Pagination::first(10)) - .await - .unwrap(); - assert!(!emails.has_next_page); - assert!(emails.edges.is_empty()); - // Listing emails from the email address should work let emails = repo .user_email() @@ -419,26 +284,123 @@ async fn test_user_email_repo(pool: PgPool) { // Deleting the user email should work repo.user_email().remove(user_email).await.unwrap(); assert_eq!(repo.user_email().count(all).await.unwrap(), 0); - assert_eq!(repo.user_email().count(pending).await.unwrap(), 0); - assert_eq!(repo.user_email().count(verified).await.unwrap(), 0); - // Reload the user + repo.save().await.unwrap(); +} + +/// Test the authentication codes methods in the user email repository +#[sqlx::test(migrator = "crate::MIGRATOR")] +async fn test_user_email_repo_authentications(pool: PgPool) { + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + + // Create a user and a user session so that we can create an authentication let user = repo .user() - .lookup(user.id) + .add(&mut rng, &clock, "alice".to_owned()) + .await + .unwrap(); + + let browser_session = repo + .browser_session() + .add(&mut rng, &clock, &user, None) + .await + .unwrap(); + + // Create an authentication session + let authentication = repo + .user_email() + .add_authentication_for_session( + &mut rng, + &clock, + "alice@example.com".to_owned(), + &browser_session, + ) + .await + .unwrap(); + + assert_eq!(authentication.email, "alice@example.com"); + assert_eq!(authentication.user_session_id, Some(browser_session.id)); + assert_eq!(authentication.created_at, clock.now()); + assert_eq!(authentication.completed_at, None); + + // Check that we can find the authentication by its ID + let lookup = repo + .user_email() + .lookup_authentication(authentication.id) .await .unwrap() - .expect("user was not found"); + .unwrap(); + assert_eq!(lookup.id, authentication.id); + assert_eq!(lookup.email, "alice@example.com"); + assert_eq!(lookup.user_session_id, Some(browser_session.id)); + assert_eq!(lookup.created_at, clock.now()); + assert_eq!(lookup.completed_at, None); - // The primary user email should be gone - assert!(repo + // Add a code to the session + let code = repo + .user_email() + .add_authentication_code( + &mut rng, + &clock, + Duration::minutes(5), + &authentication, + "123456".to_owned(), + ) + .await + .unwrap(); + + assert_eq!(code.code, "123456"); + assert_eq!(code.created_at, clock.now()); + assert_eq!(code.expires_at, clock.now() + Duration::minutes(5)); + + // Check that we can find the code by its ID + let id = code.id; + let lookup = repo .user_email() - .get_primary(&user) + .find_authentication_code(&authentication, "123456") .await .unwrap() - .is_none()); + .unwrap(); - repo.save().await.unwrap(); + assert_eq!(lookup.id, id); + assert_eq!(lookup.code, "123456"); + assert_eq!(lookup.created_at, clock.now()); + assert_eq!(lookup.expires_at, clock.now() + Duration::minutes(5)); + + // Complete the authentication + let authentication = repo + .user_email() + .complete_authentication(&clock, authentication, &code) + .await + .unwrap(); + + assert_eq!(authentication.id, authentication.id); + assert_eq!(authentication.email, "alice@example.com"); + assert_eq!(authentication.user_session_id, Some(browser_session.id)); + assert_eq!(authentication.created_at, clock.now()); + assert_eq!(authentication.completed_at, Some(clock.now())); + + // Check that we can find the completed authentication by its ID + let lookup = repo + .user_email() + .lookup_authentication(authentication.id) + .await + .unwrap() + .unwrap(); + assert_eq!(lookup.id, authentication.id); + assert_eq!(lookup.email, "alice@example.com"); + assert_eq!(lookup.user_session_id, Some(browser_session.id)); + assert_eq!(lookup.created_at, clock.now()); + assert_eq!(lookup.completed_at, Some(clock.now())); + + // Completing a second time should fail + let res = repo + .user_email() + .complete_authentication(&clock, authentication, &code) + .await; + assert!(res.is_err()); } /// Test the user password repository implementation. diff --git a/crates/storage/src/queue/tasks.rs b/crates/storage/src/queue/tasks.rs index fe8b1f9e5..3e3eec5e6 100644 --- a/crates/storage/src/queue/tasks.rs +++ b/crates/storage/src/queue/tasks.rs @@ -1,15 +1,17 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. -use mas_data_model::{Device, User, UserEmail, UserRecoverySession}; +use mas_data_model::{Device, User, UserEmailAuthentication, UserRecoverySession}; use serde::{Deserialize, Serialize}; use ulid::Ulid; use super::InsertableJob; -/// A job to verify an email address. +/// This is the previous iteration of the email verification job. It has been +/// replaced by [`SendEmailAuthenticationCodeJob`]. This struct is kept to be +/// able to consume jobs that are still in the queue. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct VerifyEmailJob { user_email_id: Ulid, @@ -17,37 +19,49 @@ pub struct VerifyEmailJob { } impl VerifyEmailJob { - /// Create a new job to verify an email address. + /// The ID of the email address to verify. #[must_use] - pub fn new(user_email: &UserEmail) -> Self { - Self { - user_email_id: user_email.id, - language: None, - } + pub fn user_email_id(&self) -> Ulid { + self.user_email_id } +} - /// Set the language to use for the email. +impl InsertableJob for VerifyEmailJob { + const QUEUE_NAME: &'static str = "verify-email"; +} + +/// A job to send an email authentication code to a user. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SendEmailAuthenticationCodeJob { + user_email_authentication_id: Ulid, + language: String, +} + +impl SendEmailAuthenticationCodeJob { + /// Create a new job to send an email authentication code to a user. #[must_use] - pub fn with_language(mut self, language: String) -> Self { - self.language = Some(language); - self + pub fn new(user_email_authentication: &UserEmailAuthentication, language: String) -> Self { + Self { + user_email_authentication_id: user_email_authentication.id, + language, + } } /// The language to use for the email. #[must_use] - pub fn language(&self) -> Option<&str> { - self.language.as_deref() + pub fn language(&self) -> &str { + &self.language } - /// The ID of the email address to verify. + /// The ID of the email authentication to send the code for. #[must_use] - pub fn user_email_id(&self) -> Ulid { - self.user_email_id + pub fn user_email_authentication_id(&self) -> Ulid { + self.user_email_authentication_id } } -impl InsertableJob for VerifyEmailJob { - const QUEUE_NAME: &'static str = "verify-email"; +impl InsertableJob for SendEmailAuthenticationCodeJob { + const QUEUE_NAME: &'static str = "send-email-authentication-code"; } /// A job to provision the user on the homeserver. 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 16f87d035..695876a92 100644 --- a/crates/storage/src/user/email.rs +++ b/crates/storage/src/user/email.rs @@ -1,40 +1,24 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. use async_trait::async_trait; -use mas_data_model::{User, UserEmail, UserEmailVerification}; +use mas_data_model::{ + BrowserSession, User, UserEmail, UserEmailAuthentication, UserEmailAuthenticationCode, + UserRegistration, +}; use rand_core::RngCore; use ulid::Ulid; use crate::{pagination::Page, repository_impl, Clock, Pagination}; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum UserEmailState { - Pending, - Verified, -} - -impl UserEmailState { - /// Returns true if the filter should only return non-verified emails - pub fn is_pending(self) -> bool { - matches!(self, Self::Pending) - } - - /// Returns true if the filter should only return verified emails - pub fn is_verified(self) -> bool { - matches!(self, Self::Verified) - } -} - /// Filter parameters for listing user emails #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub struct UserEmailFilter<'a> { user: Option<&'a User>, email: Option<&'a str>, - state: Option, } impl<'a> UserEmailFilter<'a> { @@ -73,28 +57,6 @@ impl<'a> UserEmailFilter<'a> { pub fn email(&self) -> Option<&str> { self.email } - - /// Filter for emails that are verified - #[must_use] - pub fn verified_only(mut self) -> Self { - self.state = Some(UserEmailState::Verified); - self - } - - /// Filter for emails that are not verified - #[must_use] - pub fn pending_only(mut self) -> Self { - self.state = Some(UserEmailState::Pending); - self - } - - /// Get the state filter - /// - /// Returns [`None`] if no state filter is set - #[must_use] - pub fn state(&self) -> Option { - self.state - } } /// A [`UserEmailRepository`] helps interacting with [`UserEmail`] saved in the @@ -131,19 +93,6 @@ pub trait UserEmailRepository: Send + Sync { /// Returns [`Self::Error`] if the underlying repository fails async fn find(&mut self, user: &User, email: &str) -> Result, Self::Error>; - /// Get the primary [`UserEmail`] of a [`User`] - /// - /// Returns `None` if no the user has no primary [`UserEmail`] - /// - /// # Parameters - /// - /// * `user`: The [`User`] for whom to lookup the primary [`UserEmail`] - /// - /// # Errors - /// - /// Returns [`Self::Error`] if the underlying repository fails - async fn get_primary(&mut self, user: &User) -> Result, Self::Error>; - /// Get all [`UserEmail`] of a [`User`] /// /// # Parameters @@ -215,102 +164,127 @@ pub trait UserEmailRepository: Send + Sync { /// Returns [`Self::Error`] if the underlying repository fails async fn remove(&mut self, user_email: UserEmail) -> Result<(), Self::Error>; - /// Mark a [`UserEmail`] as verified - /// - /// Returns the updated [`UserEmail`] + /// Add a new [`UserEmailAuthentication`] for a [`BrowserSession`] /// /// # Parameters /// + /// * `rng`: The random number generator to use /// * `clock`: The clock to use - /// * `user_email`: The [`UserEmail`] to mark as verified + /// * `email`: The email address to add + /// * `session`: The [`BrowserSession`] for which to add the + /// [`UserEmailAuthentication`] /// /// # Errors /// - /// Returns [`Self::Error`] if the underlying repository fails - async fn mark_as_verified( + /// Returns an error if the underlying repository fails + async fn add_authentication_for_session( &mut self, + rng: &mut (dyn RngCore + Send), clock: &dyn Clock, - user_email: UserEmail, - ) -> Result; + email: String, + session: &BrowserSession, + ) -> Result; - /// Mark a [`UserEmail`] as primary + /// Add a new [`UserEmailAuthentication`] for a [`UserRegistration`] /// /// # Parameters /// - /// * `user_email`: The [`UserEmail`] to mark as primary + /// * `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 [`Self::Error`] if the underlying repository fails - async fn set_as_primary(&mut self, user_email: &UserEmail) -> Result<(), Self::Error>; + /// 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 [`UserEmailVerification`] for a [`UserEmail`] + /// Add a new [`UserEmailAuthenticationCode`] for a + /// [`UserEmailAuthentication`] /// /// # Parameters /// /// * `rng`: The random number generator to use /// * `clock`: The clock to use - /// * `user_email`: The [`UserEmail`] for which to add the - /// [`UserEmailVerification`] - /// * `max_age`: The duration for which the [`UserEmailVerification`] is - /// valid + /// * `duration`: The duration for which the code is valid + /// * `authentication`: The [`UserEmailAuthentication`] for which to add the + /// [`UserEmailAuthenticationCode`] + /// * `code`: The code to add /// /// # Errors /// - /// Returns [`Self::Error`] if the underlying repository fails - async fn add_verification_code( + /// Returns an error if the underlying repository fails or if the code + /// already exists for this session + async fn add_authentication_code( &mut self, rng: &mut (dyn RngCore + Send), clock: &dyn Clock, - user_email: &UserEmail, - max_age: chrono::Duration, + duration: chrono::Duration, + authentication: &UserEmailAuthentication, code: String, - ) -> Result; + ) -> Result; - /// Find a [`UserEmailVerification`] for a [`UserEmail`] by its code + /// Lookup a [`UserEmailAuthentication`] + /// + /// # Parameters /// - /// Returns `None` if no matching [`UserEmailVerification`] was found + /// * `id`: The ID of the [`UserEmailAuthentication`] to lookup + /// + /// # Errors + /// + /// Returns an error if the underlying repository fails + async fn lookup_authentication( + &mut self, + id: Ulid, + ) -> Result, Self::Error>; + + /// Find a [`UserEmailAuthenticationCode`] by its code and session /// /// # Parameters /// - /// * `clock`: The clock to use - /// * `user_email`: The [`UserEmail`] for which to lookup the - /// [`UserEmailVerification`] - /// * `code`: The code used to lookup + /// * `authentication`: The [`UserEmailAuthentication`] to find the code for + /// * `code`: The code of the [`UserEmailAuthentication`] to lookup /// /// # Errors /// - /// Returns [`Self::Error`] if the underlying repository fails - async fn find_verification_code( + /// Returns an error if the underlying repository fails + async fn find_authentication_code( &mut self, - clock: &dyn Clock, - user_email: &UserEmail, + authentication: &UserEmailAuthentication, code: &str, - ) -> Result, Self::Error>; + ) -> Result, Self::Error>; - /// Consume a [`UserEmailVerification`] + /// Complete a [`UserEmailAuthentication`] by using the given code /// - /// Returns the consumed [`UserEmailVerification`] + /// Returns the completed [`UserEmailAuthentication`] /// /// # Parameters /// - /// * `clock`: The clock to use - /// * `verification`: The [`UserEmailVerification`] to consume + /// * `clock`: The clock to use to generate timestamps + /// * `authentication`: The [`UserEmailAuthentication`] to complete + /// * `code`: The [`UserEmailAuthenticationCode`] to use /// /// # Errors /// - /// Returns [`Self::Error`] if the underlying repository fails - async fn consume_verification_code( + /// Returns an error if the underlying repository fails + async fn complete_authentication( &mut self, clock: &dyn Clock, - verification: UserEmailVerification, - ) -> Result; + authentication: UserEmailAuthentication, + code: &UserEmailAuthenticationCode, + ) -> Result; } repository_impl!(UserEmailRepository: async fn lookup(&mut self, id: Ulid) -> Result, Self::Error>; async fn find(&mut self, user: &User, email: &str) -> Result, Self::Error>; - async fn get_primary(&mut self, user: &User) -> Result, Self::Error>; async fn all(&mut self, user: &User) -> Result, Self::Error>; async fn list( @@ -329,33 +303,46 @@ repository_impl!(UserEmailRepository: ) -> Result; async fn remove(&mut self, user_email: UserEmail) -> Result<(), Self::Error>; - async fn mark_as_verified( + async fn add_authentication_for_session( &mut self, + rng: &mut (dyn RngCore + Send), clock: &dyn Clock, - user_email: UserEmail, - ) -> Result; + email: String, + session: &BrowserSession, + ) -> Result; - async fn set_as_primary(&mut self, user_email: &UserEmail) -> Result<(), Self::Error>; + async fn add_authentication_for_registration( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + email: String, + registration: &UserRegistration, + ) -> Result; - async fn add_verification_code( + async fn add_authentication_code( &mut self, rng: &mut (dyn RngCore + Send), clock: &dyn Clock, - user_email: &UserEmail, - max_age: chrono::Duration, + duration: chrono::Duration, + authentication: &UserEmailAuthentication, code: String, - ) -> Result; + ) -> Result; - async fn find_verification_code( + async fn lookup_authentication( &mut self, - clock: &dyn Clock, - user_email: &UserEmail, + id: Ulid, + ) -> Result, Self::Error>; + + async fn find_authentication_code( + &mut self, + authentication: &UserEmailAuthentication, code: &str, - ) -> Result, Self::Error>; + ) -> Result, Self::Error>; - async fn consume_verification_code( + async fn complete_authentication( &mut self, clock: &dyn Clock, - verification: UserEmailVerification, - ) -> Result; + authentication: UserEmailAuthentication, + code: &UserEmailAuthenticationCode, + ) -> Result; ); 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..f193fa220 --- /dev/null +++ b/crates/storage/src/user/registration.rs @@ -0,0 +1,198 @@ +// 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 + /// + /// Returns the newly created [`UserRegistration`] + /// + /// # Parameters + /// + /// * `rng`: The random number generator to use + /// * `clock`: The clock used to generate timestamps + /// * `username`: The username of the user + /// * `ip_address`: The IP address of the user agent, if any + /// * `user_agent`: The user agent of the user agent, if any + /// * `post_auth_action`: The post auth action to execute after the + /// registration, if any + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn add( + &mut self, + rng: &mut (dyn RngCore + Send), + clock: &dyn Clock, + username: String, + ip_address: Option, + user_agent: Option, + post_auth_action: Option, + ) -> 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, + username: String, + ip_address: Option, + user_agent: Option, + post_auth_action: Option, + ) -> 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; +); diff --git a/crates/tasks/src/email.rs b/crates/tasks/src/email.rs index 25cbf2e7d..a0e7eaab6 100644 --- a/crates/tasks/src/email.rs +++ b/crates/tasks/src/email.rs @@ -1,16 +1,14 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. -use anyhow::Context; use async_trait::async_trait; use chrono::Duration; -use mas_email::{Address, Mailbox}; -use mas_i18n::locale; -use mas_storage::queue::VerifyEmailJob; -use mas_templates::{EmailVerificationContext, TemplateContext}; +use mas_email::{Address, EmailVerificationContext, Mailbox}; +use mas_storage::queue::{SendEmailAuthenticationCodeJob, VerifyEmailJob}; +use mas_templates::TemplateContext as _; use rand::{distributions::Uniform, Rng}; use tracing::info; @@ -27,72 +25,112 @@ impl RunnableJob for VerifyEmailJob { skip_all, err, )] + async fn run(&self, _state: &State, _context: JobContext) -> Result<(), JobError> { + // This job was for the old email verification flow, which has been replaced. + // We still want to consume existing jobs in the queue, so we just make them + // permanently fail. + Err(JobError::fail(anyhow::anyhow!("Not implemented"))) + } +} + +#[async_trait] +impl RunnableJob for SendEmailAuthenticationCodeJob { + #[tracing::instrument( + name = "job.send_email_authentication_code", + fields(user_email_authentication.id = %self.user_email_authentication_id()), + skip_all, + err, + )] async fn run(&self, state: &State, _context: JobContext) -> Result<(), JobError> { - let mut repo = state.repository().await.map_err(JobError::retry)?; - let mut rng = state.rng(); - let mailer = state.mailer(); let clock = state.clock(); + let mailer = state.mailer(); + let mut rng = state.rng(); + let mut repo = state.repository().await.map_err(JobError::retry)?; - let language = self - .language() - .and_then(|l| l.parse().ok()) - .unwrap_or(locale!("en").into()); - - // Lookup the user email - let user_email = repo + let user_email_authentication = repo .user_email() - .lookup(self.user_email_id()) + .lookup_authentication(self.user_email_authentication_id()) .await .map_err(JobError::retry)? - .context("User email not found") - .map_err(JobError::fail)?; + .ok_or(JobError::fail(anyhow::anyhow!( + "User email authentication not found" + )))?; - // Lookup the user associated with the email - let user = repo - .user() - .lookup(user_email.user_id) - .await - .map_err(JobError::retry)? - .context("User not found") - .map_err(JobError::fail)?; + if user_email_authentication.completed_at.is_some() { + return Err(JobError::fail(anyhow::anyhow!( + "User email authentication already completed" + ))); + } - // Generate a verification code + // Load the browser session, if any + let browser_session = + if let Some(browser_session) = user_email_authentication.user_session_id { + Some( + repo.browser_session() + .lookup(browser_session) + .await + .map_err(JobError::retry)? + .ok_or(JobError::fail(anyhow::anyhow!( + "Failed to load browser session" + )))?, + ) + } else { + None + }; + + // Load the registration, if any + let registration = + if let Some(registration_id) = user_email_authentication.user_registration_id { + Some( + repo.user_registration() + .lookup(registration_id) + .await + .map_err(JobError::retry)? + .ok_or(JobError::fail(anyhow::anyhow!( + "Failed to load user registration" + )))?, + ) + } else { + None + }; + + // Generate a new 6-digit authentication code let range = Uniform::::from(0..1_000_000); let code = rng.sample(range); let code = format!("{code:06}"); - - let address: Address = user_email.email.parse().map_err(JobError::fail)?; - - // Save the verification code in the database - let verification = repo + let code = repo .user_email() - .add_verification_code( + .add_authentication_code( &mut rng, &clock, - &user_email, - Duration::try_hours(8).unwrap(), + Duration::minutes(5), // TODO: make this configurable + &user_email_authentication, code, ) .await .map_err(JobError::retry)?; - // And send the verification email - let mailbox = Mailbox::new(Some(user.username.clone()), address); + let address: Address = user_email_authentication + .email + .parse() + .map_err(JobError::fail)?; + let username_from_session = browser_session.as_ref().map(|s| s.user.username.clone()); + let username_from_registration = registration.as_ref().map(|r| r.username.clone()); + let username = username_from_registration.or(username_from_session); + let mailbox = Mailbox::new(username, address); - let context = EmailVerificationContext::new(user.clone(), verification.clone()) - .with_language(language); + info!("Sending email verification code to {}", mailbox); + let language = self.language().parse().map_err(JobError::fail)?; + + let context = EmailVerificationContext::new(code, browser_session, registration) + .with_language(language); mailer .send_verification_email(mailbox, &context) .await - .map_err(JobError::retry)?; - - info!( - email.id = %user_email.id, - "Verification email sent" - ); + .map_err(JobError::fail)?; - repo.save().await.map_err(JobError::retry)?; + repo.save().await.map_err(JobError::fail)?; Ok(()) } diff --git a/crates/tasks/src/lib.rs b/crates/tasks/src/lib.rs index 38be3a91c..4ee635266 100644 --- a/crates/tasks/src/lib.rs +++ b/crates/tasks/src/lib.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -125,6 +125,7 @@ pub async fn init( .register_handler::() .register_handler::() .register_handler::() + .register_handler::() .register_handler::() .register_handler::() .add_schedule( diff --git a/crates/tasks/src/matrix.rs b/crates/tasks/src/matrix.rs index 0f58773b3..226868b04 100644 --- a/crates/tasks/src/matrix.rs +++ b/crates/tasks/src/matrix.rs @@ -59,7 +59,6 @@ impl RunnableJob for ProvisionUserJob { .await .map_err(JobError::retry)? .into_iter() - .filter(|email| email.confirmed_at.is_some()) .map(|email| email.email) .collect(); let mut request = ProvisionRequest::new(mxid.clone(), user.sub.clone()).set_emails(emails); diff --git a/crates/tasks/src/recovery.rs b/crates/tasks/src/recovery.rs index 294d7f1ba..4a7560ce6 100644 --- a/crates/tasks/src/recovery.rs +++ b/crates/tasks/src/recovery.rs @@ -67,12 +67,7 @@ impl RunnableJob for SendAccountRecoveryEmailsJob { loop { let page = repo .user_email() - .list( - UserEmailFilter::new() - .for_email(&session.email) - .verified_only(), - cursor, - ) + .list(UserEmailFilter::new().for_email(&session.email), cursor) .await .map_err(JobError::retry)?; diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 8e8dd2e4e..7fcfcf8f6 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -22,8 +22,8 @@ use mas_data_model::{ AuthorizationGrant, BrowserSession, Client, CompatSsoLogin, CompatSsoLoginState, DeviceCodeGrant, UpstreamOAuthLink, UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderPkceMode, - UpstreamOAuthProviderTokenAuthMethod, User, UserAgent, UserEmail, UserEmailVerification, - UserRecoverySession, + UpstreamOAuthProviderTokenAuthMethod, User, UserAgent, UserEmailAuthentication, + UserEmailAuthenticationCode, UserRecoverySession, UserRegistration, }; use mas_i18n::DataLocale; use mas_iana::jose::JsonWebSignatureAlg; @@ -878,27 +878,38 @@ impl TemplateContext for EmailRecoveryContext { /// Context used by the `emails/verification.{txt,html,subject}` templates #[derive(Serialize)] pub struct EmailVerificationContext { - user: User, - verification: UserEmailVerification, + #[serde(skip_serializing_if = "Option::is_none")] + browser_session: Option, + #[serde(skip_serializing_if = "Option::is_none")] + user_registration: Option, + authentication_code: UserEmailAuthenticationCode, } impl EmailVerificationContext { /// Constructs a context for the verification email #[must_use] - pub fn new(user: User, verification: UserEmailVerification) -> Self { - Self { user, verification } + pub fn new( + authentication_code: UserEmailAuthenticationCode, + browser_session: Option, + user_registration: Option, + ) -> Self { + Self { + browser_session, + user_registration, + authentication_code, + } } /// Get the user to which this email is being sent #[must_use] - pub fn user(&self) -> &User { - &self.user + pub fn user(&self) -> Option<&User> { + self.browser_session.as_ref().map(|s| &s.user) } /// Get the verification code being sent #[must_use] - pub fn verification(&self) -> &UserEmailVerification { - &self.verification + pub fn code(&self) -> &str { + &self.authentication_code.code } } @@ -907,26 +918,22 @@ impl TemplateContext for EmailVerificationContext { where Self: Sized, { - User::samples(now, rng) + BrowserSession::samples(now, rng) .into_iter() - .map(|user| { - let email = UserEmail { - id: Ulid::from_datetime_with_source(now.into(), rng), - user_id: user.id, - email: "foobar@example.com".to_owned(), - created_at: now, - confirmed_at: None, - }; - - let verification = UserEmailVerification { + .map(|browser_session| { + let authentication_code = UserEmailAuthenticationCode { id: Ulid::from_datetime_with_source(now.into(), rng), - user_email_id: email.id, + user_email_authentication_id: Ulid::from_datetime_with_source(now.into(), rng), code: "123456".to_owned(), - created_at: now, - state: mas_data_model::UserEmailVerificationState::Valid, + created_at: now - Duration::try_minutes(5).unwrap(), + expires_at: now + Duration::try_minutes(25).unwrap(), }; - Self { user, verification } + Self { + browser_session: Some(browser_session), + user_registration: None, + authentication_code, + } }) .collect() } @@ -935,12 +942,12 @@ impl TemplateContext for EmailVerificationContext { /// Fields of the email verification form #[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] #[serde(rename_all = "snake_case")] -pub enum EmailVerificationFormField { +pub enum RegisterStepsVerifyEmailFormField { /// The code field Code, } -impl FormField for EmailVerificationFormField { +impl FormField for RegisterStepsVerifyEmailFormField { fn keep(&self) -> bool { match self { Self::Code => true, @@ -948,74 +955,101 @@ impl FormField for EmailVerificationFormField { } } -/// Context used by the `pages/account/verify.html` templates +/// Context used by the `pages/register/steps/verify_email.html` templates #[derive(Serialize)] -pub struct EmailVerificationPageContext { - form: FormState, - email: UserEmail, +pub struct RegisterStepsVerifyEmailContext { + form: FormState, + authentication: UserEmailAuthentication, } -impl EmailVerificationPageContext { +impl RegisterStepsVerifyEmailContext { /// Constructs a context for the email verification page #[must_use] - pub fn new(email: UserEmail) -> Self { + pub fn new(authentication: UserEmailAuthentication) -> Self { Self { form: FormState::default(), - email, + authentication, } } /// Set the form state #[must_use] - pub fn with_form_state(self, form: FormState) -> Self { + pub fn with_form_state(self, form: FormState) -> Self { Self { form, ..self } } } -impl TemplateContext for EmailVerificationPageContext { +impl TemplateContext for RegisterStepsVerifyEmailContext { fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec where Self: Sized, { - let email = UserEmail { + let authentication = UserEmailAuthentication { id: Ulid::from_datetime_with_source(now.into(), rng), - user_id: Ulid::from_datetime_with_source(now.into(), rng), + user_session_id: None, + user_registration_id: None, email: "foobar@example.com".to_owned(), created_at: now, - confirmed_at: None, + completed_at: None, }; vec![Self { form: FormState::default(), - email, + authentication, }] } } -/// Fields of the account email add form +/// Context used by the `pages/register/steps/email_in_use.html` template +#[derive(Serialize)] +pub struct RegisterStepsEmailInUseContext { + email: String, + action: Option, +} + +impl RegisterStepsEmailInUseContext { + /// Constructs a context for the email in use page + #[must_use] + pub fn new(email: String, action: Option) -> Self { + Self { email, action } + } +} + +impl TemplateContext for RegisterStepsEmailInUseContext { + fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec + where + Self: Sized, + { + let email = "hello@example.com".to_owned(); + let action = PostAuthAction::continue_grant(Ulid::nil()); + vec![Self::new(email, Some(action))] + } +} + +/// Fields for the display name form #[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] #[serde(rename_all = "snake_case")] -pub enum EmailAddFormField { - /// The email - Email, +pub enum RegisterStepsDisplayNameFormField { + /// The display name + DisplayName, } -impl FormField for EmailAddFormField { +impl FormField for RegisterStepsDisplayNameFormField { fn keep(&self) -> bool { match self { - Self::Email => true, + Self::DisplayName => true, } } } -/// Context used by the `pages/account/verify.html` templates +/// Context used by the `display_name.html` template #[derive(Serialize, Default)] -pub struct EmailAddContext { - form: FormState, +pub struct RegisterStepsDisplayNameContext { + form: FormState, } -impl EmailAddContext { - /// Constructs a context for the email add page +impl RegisterStepsDisplayNameContext { + /// Constructs a context for the display name page #[must_use] pub fn new() -> Self { Self::default() @@ -1023,17 +1057,23 @@ impl EmailAddContext { /// Set the form state #[must_use] - pub fn with_form_state(form: FormState) -> Self { - Self { form } + pub fn with_form_state( + mut self, + form_state: FormState, + ) -> Self { + self.form = form_state; + self } } -impl TemplateContext for EmailAddContext { - fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec +impl TemplateContext for RegisterStepsDisplayNameContext { + fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec where Self: Sized, { - vec![Self::default()] + vec![Self { + form: FormState::default(), + }] } } diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 7faea6b2c..60482f792 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -35,16 +35,17 @@ mod macros; pub use self::{ context::{ ApiDocContext, AppContext, CompatSsoContext, ConsentContext, DeviceConsentContext, - DeviceLinkContext, DeviceLinkFormField, EmailAddContext, EmailRecoveryContext, - EmailVerificationContext, EmailVerificationPageContext, EmptyContext, ErrorContext, - FormPostContext, IndexContext, LoginContext, LoginFormField, NotFoundContext, - PasswordRegisterContext, PolicyViolationContext, PostAuthContext, PostAuthContextInner, - ReauthContext, ReauthFormField, RecoveryExpiredContext, RecoveryFinishContext, - RecoveryFinishFormField, RecoveryProgressContext, RecoveryStartContext, - RecoveryStartFormField, RegisterContext, RegisterFormField, SiteBranding, SiteConfigExt, - SiteFeatures, TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, - UpstreamRegisterFormField, UpstreamSuggestLink, WithCaptcha, WithCsrf, WithLanguage, - WithOptionalSession, WithSession, + DeviceLinkContext, DeviceLinkFormField, EmailRecoveryContext, EmailVerificationContext, + EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField, + NotFoundContext, PasswordRegisterContext, PolicyViolationContext, PostAuthContext, + PostAuthContextInner, ReauthContext, ReauthFormField, RecoveryExpiredContext, + RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext, + RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField, + RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, + RegisterStepsEmailInUseContext, RegisterStepsVerifyEmailContext, + RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures, + TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField, + UpstreamSuggestLink, WithCaptcha, WithCsrf, WithLanguage, WithOptionalSession, WithSession, }, forms::{FieldError, FormError, FormField, FormState, ToFormState}, }; @@ -327,10 +328,19 @@ register_templates! { pub fn render_login(WithLanguage>) { "pages/login.html" } /// Render the registration page - pub fn render_register(WithLanguage>) { "pages/register.html" } + pub fn render_register(WithLanguage>) { "pages/register/index.html" } /// Render the password registration page - pub fn render_password_register(WithLanguage>>) { "pages/password_register.html" } + pub fn render_password_register(WithLanguage>>) { "pages/register/password.html" } + + /// Render the email verification page + pub fn render_register_steps_verify_email(WithLanguage>) { "pages/register/steps/verify_email.html" } + + /// Render the email in use page + pub fn render_register_steps_email_in_use(WithLanguage) { "pages/register/steps/email_in_use.html" } + + /// Render the display name page + pub fn render_register_steps_display_name(WithLanguage>) { "pages/register/steps/display_name.html" } /// Render the client consent page pub fn render_consent(WithLanguage>>) { "pages/consent.html" } @@ -344,12 +354,6 @@ register_templates! { /// Render the home page pub fn render_index(WithLanguage>>) { "pages/index.html" } - /// Render the email verification page - pub fn render_account_verify_email(WithLanguage>>) { "pages/account/emails/verify.html" } - - /// Render the email verification page - pub fn render_account_add_email(WithLanguage>>) { "pages/account/emails/add.html" } - /// Render the account recovery start page pub fn render_recovery_start(WithLanguage>) { "pages/recovery/start.html" } @@ -429,12 +433,14 @@ impl Templates { check::render_swagger_callback(self, now, rng)?; check::render_login(self, now, rng)?; check::render_register(self, now, rng)?; + check::render_password_register(self, now, rng)?; + check::render_register_steps_verify_email(self, now, rng)?; + check::render_register_steps_email_in_use(self, now, rng)?; + check::render_register_steps_display_name(self, now, rng)?; check::render_consent(self, now, rng)?; check::render_policy_violation(self, now, rng)?; check::render_sso_login(self, now, rng)?; check::render_index(self, now, rng)?; - check::render_account_add_email(self, now, rng)?; - check::render_account_verify_email(self, now, rng)?; check::render_recovery_start(self, now, rng)?; check::render_recovery_progress(self, now, rng)?; check::render_recovery_finish(self, now, rng)?; diff --git a/docs/config.schema.json b/docs/config.schema.json index 92dd150df..fafe759d3 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -1715,6 +1715,32 @@ "$ref": "#/definitions/RateLimiterConfiguration" } ] + }, + "email_authentication": { + "description": "Email authentication-specific rate limits", + "default": { + "per_ip": { + "burst": 5, + "per_second": 0.016666666666666666 + }, + "per_address": { + "burst": 3, + "per_second": 0.0002777777777777778 + }, + "emails_per_session": { + "burst": 2, + "per_second": 0.0033333333333333335 + }, + "attempt_per_session": { + "burst": 10, + "per_second": 0.016666666666666666 + } + }, + "allOf": [ + { + "$ref": "#/definitions/EmailauthenticationRateLimitingConfig" + } + ] } } }, @@ -1796,6 +1822,59 @@ } } }, + "EmailauthenticationRateLimitingConfig": { + "type": "object", + "properties": { + "per_ip": { + "description": "Controls how many email authentication attempts are permitted based on the source IP address. This can protect against causing e-mail spam to many targets.", + "default": { + "burst": 5, + "per_second": 0.016666666666666666 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] + }, + "per_address": { + "description": "Controls how many email authentication attempts are permitted based on the e-mail address entered into the authentication form. This can protect against causing e-mail spam to one target.\n\nNote: this limit also applies to re-sends.", + "default": { + "burst": 3, + "per_second": 0.0002777777777777778 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] + }, + "emails_per_session": { + "description": "Controls how many authentication emails are permitted to be sent per authentication session. This ensures not too many authentication codes are created for the same authentication session.", + "default": { + "burst": 2, + "per_second": 0.0033333333333333335 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] + }, + "attempt_per_session": { + "description": "Controls how many code authentication attempts are permitted per authentication session. This can protect against brute-forcing the code.", + "default": { + "burst": 10, + "per_second": 0.016666666666666666 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] + } + } + }, "UpstreamOAuth2Config": { "description": "Upstream OAuth 2.0 providers configuration", "type": "object", @@ -2240,43 +2319,9 @@ "template": { "description": "The Jinja2 template to use for the email address attribute\n\nIf not provided, the default template is `{{ user.email }}`", "type": "string" - }, - "set_email_verification": { - "description": "Should the email address be marked as verified", - "allOf": [ - { - "$ref": "#/definitions/SetEmailVerification" - } - ] } } }, - "SetEmailVerification": { - "description": "Should the email address be marked as verified", - "oneOf": [ - { - "description": "Mark the email address as verified", - "type": "string", - "enum": [ - "always" - ] - }, - { - "description": "Don't mark the email address as verified", - "type": "string", - "enum": [ - "never" - ] - }, - { - "description": "Mark the email address as verified if the upstream provider says it is through the `email_verified` claim", - "type": "string", - "enum": [ - "import" - ] - } - ] - }, "AccountNameImportPreference": { "description": "What should be done for the account name attribute", "type": "object", diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index f09d9e6c3..73a06d8c2 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -57,9 +57,8 @@ const config: StorybookConfig = { }, viteFinal: async (config) => { - // Host all the assets in the root directory, - // so that the service worker is correctly scoped to the root - config.build.assetsDir = ""; + // Serve the storybook-specific assets, which has the service worker + config.publicDir = ".storybook/public"; return config; }, }; diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index b4b4a7a58..268849ffa 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -18,13 +18,12 @@ import i18n, { setupI18n } from "../src/i18n"; import { DummyRouter } from "../src/test-utils/router"; import { handlers } from "../tests/mocks/handlers"; import localazyMetadata from "./locales"; -import swUrl from "./mockServiceWorker.js?url"; initialize( { onUnhandledRequest: "bypass", serviceWorker: { - url: swUrl, + url: "./mockServiceWorker.js", }, }, handlers, diff --git a/frontend/.storybook/mockServiceWorker.js b/frontend/.storybook/public/mockServiceWorker.js similarity index 100% rename from frontend/.storybook/mockServiceWorker.js rename to frontend/.storybook/public/mockServiceWorker.js diff --git a/frontend/locales/en.json b/frontend/locales/en.json index e139cb724..72df9d5fe 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -49,9 +49,9 @@ }, "add_email_form": { "email_denied_error": "The entered email is not allowed by the server policy", - "email_exists_error": "The entered email is already added to this account", "email_field_help": "Add an alternative email you can use to access this account.", "email_field_label": "Add email", + "email_in_use_error": "The entered email is already in use", "email_invalid_error": "The entered email is invalid" }, "browser_session_details": { @@ -78,6 +78,9 @@ "tablet": "Tablet", "unknown": "Unknown device type" }, + "email_in_use": { + "heading": "The email address {{email}} is already in use." + }, "end_session_button": { "confirmation_modal_title": "Are you sure you want to end this session?", "text": "Sign out" @@ -247,24 +250,13 @@ "title": "Cannot find session: {{deviceId}}" } }, - "unverified_email_alert": { - "button": "Review and verify", - "text:one": "You have {{count}} unverified email address.", - "text:other": "You have {{count}} unverified email addresses.", - "title": "Unverified email" - }, "user_email": { - "cant_delete_primary": "Choose a different primary email to delete this one.", "delete_button_confirmation_modal": { "action": "Delete email", "body": "Delete this email?" }, "delete_button_title": "Remove email address", - "email": "Email", - "make_primary_button": "Make primary", - "not_verified": "Not verified", - "primary_email": "Primary email", - "retry_button": "Resend code" + "email": "Email" }, "user_email_list": { "no_primary_email_alert": "No primary email address" @@ -277,6 +269,10 @@ } }, "verify_email": { + "code_expired_alert": { + "description": "The code has expired. Please request a new code.", + "title": "Code expired" + }, "code_field_error": "Code not recognised", "code_field_label": "6-digit code", "code_field_wrong_shape": "Code must be 6 digits", diff --git a/frontend/package.json b/frontend/package.json index b47d8b6a0..b052bd059 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -88,7 +88,7 @@ "vitest": "^3.0.1" }, "msw": { - "workerDirectory": [".storybook"] + "workerDirectory": [".storybook/public"] }, "overrides": { "swagger-ui-react": { diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 3eb56573e..58678a73b 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -484,6 +484,56 @@ type CompatSsoLoginEdge { cursor: String! } +""" +The input for the `completeEmailAuthentication` mutation +""" +input CompleteEmailAuthenticationInput { + """ + The authentication code to use + """ + code: String! + """ + The ID of the authentication session to complete + """ + id: ID! +} + +""" +The payload of the `completeEmailAuthentication` mutation +""" +type CompleteEmailAuthenticationPayload { + """ + Status of the operation + """ + status: CompleteEmailAuthenticationStatus! +} + +""" +The status of the `completeEmailAuthentication` mutation +""" +enum CompleteEmailAuthenticationStatus { + """ + The authentication was completed + """ + COMPLETED + """ + The authentication code is invalid + """ + INVALID_CODE + """ + The authentication code has expired + """ + CODE_EXPIRED + """ + Too many attempts to complete an email authentication + """ + RATE_LIMITED + """ + The email address is already in use + """ + IN_USE +} + """ The input of the `createOauth2Session` mutation. """ @@ -747,16 +797,7 @@ type Mutation { Add an email address to the specified user """ addEmail(input: AddEmailInput!): AddEmailPayload! - """ - Send a verification code for an email address - """ - sendVerificationEmail( - input: SendVerificationEmailInput! - ): SendVerificationEmailPayload! - """ - Submit a verification code for an email address - """ - verifyEmail(input: VerifyEmailInput!): VerifyEmailPayload! + @deprecated(reason: "Use `startEmailAuthentication` instead.") """ Remove an email address """ @@ -765,6 +806,27 @@ type Mutation { Set an email address as primary """ setPrimaryEmail(input: SetPrimaryEmailInput!): SetPrimaryEmailPayload! + @deprecated( + reason: "This doesn't do anything anymore, but is kept to avoid breaking existing queries" + ) + """ + Start a new email authentication flow + """ + startEmailAuthentication( + input: StartEmailAuthenticationInput! + ): StartEmailAuthenticationPayload! + """ + Resend the email authentication code + """ + resendEmailAuthenticationCode( + input: ResendEmailAuthenticationCodeInput! + ): ResendEmailAuthenticationCodePayload! + """ + Complete the email authentication flow + """ + completeEmailAuthentication( + input: CompleteEmailAuthenticationInput! + ): CompleteEmailAuthenticationPayload! """ Add a user. This is only available to administrators. """ @@ -1040,6 +1102,10 @@ type Query { """ userRecoveryTicket(ticket: String!): UserRecoveryTicket """ + Fetch a user email authentication session + """ + userEmailAuthentication(id: ID!): UserEmailAuthentication + """ Fetches an object given its ID. """ node(id: ID!): Node @@ -1166,97 +1232,93 @@ enum RemoveEmailStatus { """ REMOVED """ - Can't remove the primary email address - """ - PRIMARY - """ The email address was not found """ NOT_FOUND } """ -The input for the `resendRecoveryEmail` mutation. +The input for the `resendEmailAuthenticationCode` mutation """ -input ResendRecoveryEmailInput { +input ResendEmailAuthenticationCodeInput { """ - The recovery ticket to use. + The ID of the authentication session to resend the code for """ - ticket: String! + id: ID! + """ + The language to use for the email + """ + language: String! = "en" } """ -The return type for the `resendRecoveryEmail` mutation. +The payload of the `resendEmailAuthenticationCode` mutation """ -type ResendRecoveryEmailPayload { +type ResendEmailAuthenticationCodePayload { """ Status of the operation """ - status: ResendRecoveryEmailStatus! - """ - URL to continue the recovery process - """ - progressUrl: Url + status: ResendEmailAuthenticationCodeStatus! } """ -The status of the `resendRecoveryEmail` mutation. +The status of the `resendEmailAuthenticationCode` mutation """ -enum ResendRecoveryEmailStatus { +enum ResendEmailAuthenticationCodeStatus { """ - The recovery ticket was not found. + The email was resent """ - NO_SUCH_RECOVERY_TICKET + RESENT """ - The rate limit was exceeded. + The email authentication session is already completed """ - RATE_LIMITED + COMPLETED """ - The recovery email was sent. + Too many attempts to resend an email authentication code """ - SENT + RATE_LIMITED } """ -The input for the `sendVerificationEmail` mutation +The input for the `resendRecoveryEmail` mutation. """ -input SendVerificationEmailInput { +input ResendRecoveryEmailInput { """ - The ID of the email address to verify + The recovery ticket to use. """ - userEmailId: ID! + ticket: String! } """ -The payload of the `sendVerificationEmail` mutation +The return type for the `resendRecoveryEmail` mutation. """ -type SendVerificationEmailPayload { +type ResendRecoveryEmailPayload { """ Status of the operation """ - status: SendVerificationEmailStatus! - """ - The email address to which the verification email was sent - """ - email: UserEmail! + status: ResendRecoveryEmailStatus! """ - The user to whom the email address belongs + URL to continue the recovery process """ - user: User! + progressUrl: Url } """ -The status of the `sendVerificationEmail` mutation +The status of the `resendRecoveryEmail` mutation. """ -enum SendVerificationEmailStatus { +enum ResendRecoveryEmailStatus { """ - The verification email was sent + The recovery ticket was not found. """ - SENT + NO_SUCH_RECOVERY_TICKET """ - The email address is already verified + The rate limit was exceeded. + """ + RATE_LIMITED + """ + The recovery email was sent. """ - ALREADY_VERIFIED + SENT } """ @@ -1539,6 +1601,64 @@ type SiteConfig implements Node { id: ID! } +""" +The input for the `startEmailAuthentication` mutation +""" +input StartEmailAuthenticationInput { + """ + The email address to add to the account + """ + email: String! + """ + The language to use for the email + """ + language: String! = "en" +} + +""" +The payload of the `startEmailAuthentication` mutation +""" +type StartEmailAuthenticationPayload { + """ + Status of the operation + """ + status: StartEmailAuthenticationStatus! + """ + The email authentication session that was started + """ + authentication: UserEmailAuthentication + """ + The list of policy violations if the email address was denied + """ + violations: [String!] +} + +""" +The status of the `startEmailAuthentication` mutation +""" +enum StartEmailAuthenticationStatus { + """ + The email address was started + """ + STARTED + """ + The email address is invalid + """ + INVALID_EMAIL_ADDRESS + """ + Too many attempts to start an email authentication + """ + RATE_LIMITED + """ + The email address isn't allowed by the policy + """ + DENIED + """ + The email address is already in use on this account + """ + IN_USE +} + """ The input for the `unlockUser` mutation. """ @@ -1737,10 +1857,6 @@ type User implements Node { """ matrix: MatrixUser! """ - Primary email address of the user. - """ - primaryEmail: UserEmail - """ Get the list of compatibility SSO logins, chronologically sorted """ compatSsoLogins( @@ -1831,6 +1947,9 @@ type User implements Node { List only emails in the given state. """ state: UserEmailState + @deprecated( + reason: "Emails are always confirmed, and have only one state" + ) """ Returns the elements in the list that come after the cursor. """ @@ -2029,7 +2148,29 @@ type UserEmail implements Node & CreationEvent { When the email address was confirmed. Is `null` if the email was never verified by the user. """ - confirmedAt: DateTime + confirmedAt: DateTime @deprecated(reason: "Emails are always confirmed now.") +} + +""" +A email authentication session +""" +type UserEmailAuthentication implements Node & CreationEvent { + """ + ID of the object. + """ + id: ID! + """ + When the object was created. + """ + createdAt: DateTime! + """ + When the object was last updated. + """ + completedAt: DateTime + """ + The email address associated with this session + """ + email: String! } type UserEmailConnection { @@ -2137,56 +2278,6 @@ enum UserState { LOCKED } -""" -The input for the `verifyEmail` mutation -""" -input VerifyEmailInput { - """ - The ID of the email address to verify - """ - userEmailId: ID! - """ - The verification code - """ - code: String! -} - -""" -The payload of the `verifyEmail` mutation -""" -type VerifyEmailPayload { - """ - Status of the operation - """ - status: VerifyEmailStatus! - """ - The email address that was verified - """ - email: UserEmail - """ - The user to whom the email address belongs - """ - user: User -} - -""" -The status of the `verifyEmail` mutation -""" -enum VerifyEmailStatus { - """ - The email address was just verified - """ - VERIFIED - """ - The email address was already verified before - """ - ALREADY_VERIFIED - """ - The verification code is invalid - """ - INVALID_CODE -} - """ Represents the current viewer """ diff --git a/frontend/src/components/PageHeading/PageHeading.tsx b/frontend/src/components/PageHeading/PageHeading.tsx index ef3a94cd4..72efbb1e0 100644 --- a/frontend/src/components/PageHeading/PageHeading.tsx +++ b/frontend/src/components/PageHeading/PageHeading.tsx @@ -12,8 +12,8 @@ type Props = { Icon: React.ComponentType>; invalid?: boolean; success?: boolean; - title: string; - subtitle?: string; + title: React.ReactNode; + subtitle?: React.ReactNode; }; const PageHeading: React.FC = ({ diff --git a/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.module.css b/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.module.css deleted file mode 100644 index 89636451a..000000000 --- a/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.module.css +++ /dev/null @@ -1,10 +0,0 @@ -/* Copyright 2024 New Vector Ltd. -* Copyright 2023, 2024 The Matrix.org Foundation C.I.C. -* -* SPDX-License-Identifier: AGPL-3.0-only -* Please see LICENSE in the repository root for full details. - */ - -.alert > * { - box-sizing: content-box; -} diff --git a/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.test.tsx b/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.test.tsx deleted file mode 100644 index cd65e2be2..000000000 --- a/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.test.tsx +++ /dev/null @@ -1,168 +0,0 @@ -// Copyright 2024 New Vector Ltd. -// Copyright 2023, 2024 The Matrix.org Foundation C.I.C. -// -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. - -// @vitest-environment happy-dom - -import { fireEvent, render } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; - -import { makeFragmentData } from "../../gql/fragment-masking"; -import { DummyRouter } from "../../test-utils/router"; - -import UnverifiedEmailAlert, { - UNVERIFIED_EMAILS_FRAGMENT, -} from "./UnverifiedEmailAlert"; - -describe("", () => { - it("does not render a warning when there are no unverified emails", () => { - const data = makeFragmentData( - { - id: "abc123", - unverifiedEmails: { - totalCount: 0, - }, - }, - UNVERIFIED_EMAILS_FRAGMENT, - ); - - const { container } = render( - - - , - ); - - expect(container).toMatchInlineSnapshot("
"); - }); - - it("renders a warning when there are unverified emails", () => { - const data = makeFragmentData( - { - id: "abc123", - unverifiedEmails: { - totalCount: 2, - }, - }, - UNVERIFIED_EMAILS_FRAGMENT, - ); - - const { container } = render( - - - , - ); - - expect(container).toMatchSnapshot(); - }); - - it("hides warning after it has been dismissed", () => { - const data = makeFragmentData( - { - id: "abc123", - unverifiedEmails: { - totalCount: 2, - }, - }, - UNVERIFIED_EMAILS_FRAGMENT, - ); - - const { container, getByText, getByLabelText } = render( - - - , - ); - - // warning is rendered - expect(getByText("Unverified email")).toBeTruthy(); - - fireEvent.click(getByLabelText("Close")); - - // no more warning - expect(container).toMatchInlineSnapshot("
"); - }); - - it("hides warning when count of unverified emails becomes 0", () => { - const data = makeFragmentData( - { - id: "abc123", - unverifiedEmails: { - totalCount: 2, - }, - }, - UNVERIFIED_EMAILS_FRAGMENT, - ); - - const { container, getByText, rerender } = render( - - - , - ); - - // warning is rendered - expect(getByText("Unverified email")).toBeTruthy(); - - const newData = makeFragmentData( - { - id: "abc123", - unverifiedEmails: { - totalCount: 0, - }, - }, - UNVERIFIED_EMAILS_FRAGMENT, - ); - rerender( - - - , - ); - - // warning removed - expect(container).toMatchInlineSnapshot("
"); - }); - - it("shows a dismissed warning again when there are new unverified emails", () => { - const data = makeFragmentData( - { - id: "abc123", - unverifiedEmails: { - totalCount: 2, - }, - }, - UNVERIFIED_EMAILS_FRAGMENT, - ); - - const { container, getByText, getByLabelText, rerender } = render( - - - , - ); - - // warning is rendered - expect(getByText("Unverified email")).toBeTruthy(); - - fireEvent.click(getByLabelText("Close")); - - // no more warning - expect(container).toMatchInlineSnapshot("
"); - - const newData = makeFragmentData( - { - id: "abc123", - unverifiedEmails: { - totalCount: 3, - }, - }, - UNVERIFIED_EMAILS_FRAGMENT, - ); - rerender( - - - , - ); - - // warning is rendered - expect(getByText("Unverified email")).toBeTruthy(); - }); -}); diff --git a/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.tsx b/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.tsx deleted file mode 100644 index 319b0f5da..000000000 --- a/frontend/src/components/UnverifiedEmailAlert/UnverifiedEmailAlert.tsx +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright 2024 New Vector Ltd. -// Copyright 2023, 2024 The Matrix.org Foundation C.I.C. -// -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. - -import { Alert } from "@vector-im/compound-web"; -import { useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import { type FragmentType, graphql, useFragment } from "../../gql"; -import { Link } from "../Link"; - -import styles from "./UnverifiedEmailAlert.module.css"; - -export const UNVERIFIED_EMAILS_FRAGMENT = graphql(/* GraphQL */ ` - fragment UnverifiedEmailAlert_user on User { - unverifiedEmails: emails(first: 0, state: PENDING) { - totalCount - } - } -`); - -const UnverifiedEmailAlert: React.FC<{ - user: FragmentType; -}> = ({ user }) => { - const data = useFragment(UNVERIFIED_EMAILS_FRAGMENT, user); - const [dismiss, setDismiss] = useState(false); - const { t } = useTranslation(); - const currentCount = useRef(data.unverifiedEmails.totalCount); - - const doDismiss = (): void => setDismiss(true); - - useEffect(() => { - if (currentCount.current !== data.unverifiedEmails.totalCount) { - currentCount.current = data.unverifiedEmails.totalCount; - setDismiss(false); - } - }, [data]); - - if (!data.unverifiedEmails.totalCount || dismiss) { - return null; - } - - return ( - - {t("frontend.unverified_email_alert.text", { - count: data.unverifiedEmails.totalCount, - })}{" "} - - {t("frontend.unverified_email_alert.button")} - - - ); -}; - -export default UnverifiedEmailAlert; diff --git a/frontend/src/components/UnverifiedEmailAlert/__snapshots__/UnverifiedEmailAlert.test.tsx.snap b/frontend/src/components/UnverifiedEmailAlert/__snapshots__/UnverifiedEmailAlert.test.tsx.snap deleted file mode 100644 index 040c86a8c..000000000 --- a/frontend/src/components/UnverifiedEmailAlert/__snapshots__/UnverifiedEmailAlert.test.tsx.snap +++ /dev/null @@ -1,78 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[` > renders a warning when there are unverified emails 1`] = ` -
-
- -
-
-

- Unverified email -

-

- You have 2 unverified email addresses. - - - Review and verify - -

-
-
- -
-
-`; diff --git a/frontend/src/components/UnverifiedEmailAlert/index.ts b/frontend/src/components/UnverifiedEmailAlert/index.ts deleted file mode 100644 index 47176edfd..000000000 --- a/frontend/src/components/UnverifiedEmailAlert/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright 2024 New Vector Ltd. -// Copyright 2023, 2024 The Matrix.org Foundation C.I.C. -// -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. - -export { default } from "./UnverifiedEmailAlert"; diff --git a/frontend/src/components/UserEmail/UserEmail.tsx b/frontend/src/components/UserEmail/UserEmail.tsx index 88dedbb6d..02771412e 100644 --- a/frontend/src/components/UserEmail/UserEmail.tsx +++ b/frontend/src/components/UserEmail/UserEmail.tsx @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -13,7 +13,6 @@ import { Translation, useTranslation } from "react-i18next"; import { type FragmentType, graphql, useFragment } from "../../gql"; import { graphqlRequest } from "../../graphql"; import { Close, Description, Dialog, Title } from "../Dialog"; -import { Link } from "../Link"; import styles from "./UserEmail.module.css"; // This component shows a single user email address, with controls to verify it, @@ -23,7 +22,6 @@ export const FRAGMENT = graphql(/* GraphQL */ ` fragment UserEmail_email on UserEmail { id email - confirmedAt } `); @@ -45,20 +43,6 @@ const REMOVE_EMAIL_MUTATION = graphql(/* GraphQL */ ` } `); -const SET_PRIMARY_EMAIL_MUTATION = graphql(/* GraphQL */ ` - mutation SetPrimaryEmail($id: ID!) { - setPrimaryEmail(input: { userEmailId: $id }) { - status - user { - id - primaryEmail { - id - } - } - } - } -`); - const DeleteButton: React.FC<{ disabled?: boolean; onClick?: () => void }> = ({ disabled, onClick, @@ -123,24 +107,13 @@ const DeleteButtonWithConfirmation: React.FC< const UserEmail: React.FC<{ email: FragmentType; - siteConfig: FragmentType; + canRemove?: boolean; onRemove?: () => void; - isPrimary?: boolean; -}> = ({ email, siteConfig, isPrimary, onRemove }) => { +}> = ({ email, canRemove, onRemove }) => { const { t } = useTranslation(); const data = useFragment(FRAGMENT, email); - const { emailChangeAllowed } = useFragment(CONFIG_FRAGMENT, siteConfig); const queryClient = useQueryClient(); - const setPrimary = useMutation({ - mutationFn: (id: string) => - graphqlRequest({ query: SET_PRIMARY_EMAIL_MUTATION, variables: { id } }), - onSuccess: (_data) => { - queryClient.invalidateQueries({ queryKey: ["currentUserGreeting"] }); - queryClient.invalidateQueries({ queryKey: ["userEmails"] }); - }, - }); - const removeEmail = useMutation({ mutationFn: (id: string) => graphqlRequest({ query: REMOVE_EMAIL_MUTATION, variables: { id } }), @@ -155,18 +128,10 @@ const UserEmail: React.FC<{ removeEmail.mutate(data.id); }; - const onSetPrimaryClick = (): void => { - setPrimary.mutate(data.id); - }; - return ( - - {isPrimary - ? t("frontend.user_email.primary_email") - : t("frontend.user_email.email")} - + {t("frontend.user_email.email")}
- {!isPrimary && emailChangeAllowed && ( + {canRemove && ( )}
- - {isPrimary && emailChangeAllowed && ( - - {t("frontend.user_email.cant_delete_primary")} - - )} - - {data.confirmedAt && !isPrimary && emailChangeAllowed && ( - - - - )} - - {!data.confirmedAt && ( - - {t("frontend.user_email.not_verified")} |{" "} - - {t("frontend.user_email.retry_button")} - - - )}
); diff --git a/frontend/src/components/UserProfile/AddEmailForm.tsx b/frontend/src/components/UserProfile/AddEmailForm.tsx index 83ed59302..6459f495e 100644 --- a/frontend/src/components/UserProfile/AddEmailForm.tsx +++ b/frontend/src/components/UserProfile/AddEmailForm.tsx @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -15,44 +15,42 @@ import { graphql } from "../../gql"; import { graphqlRequest } from "../../graphql"; const ADD_EMAIL_MUTATION = graphql(/* GraphQL */ ` - mutation AddEmail($userId: ID!, $email: String!) { - addEmail(input: { userId: $userId, email: $email }) { + mutation AddEmail($email: String!, $language: String!) { + startEmailAuthentication(input: { email: $email, language: $language }) { status violations - email { + authentication { id - ...UserEmail_email } } } `); const AddEmailForm: React.FC<{ - userId: string; onAdd: (id: string) => Promise; -}> = ({ userId, onAdd }) => { - const { t } = useTranslation(); +}> = ({ onAdd }) => { + const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); const addEmail = useMutation({ - mutationFn: ({ userId, email }: { userId: string; email: string }) => + mutationFn: ({ email, language }: { email: string; language: string }) => graphqlRequest({ query: ADD_EMAIL_MUTATION, - variables: { userId, email }, + variables: { email, language }, }), onSuccess: async (data) => { queryClient.invalidateQueries({ queryKey: ["userEmails"] }); // Don't clear the form if the email was invalid or already exists - if (data.addEmail.status !== "ADDED") { + if (data.startEmailAuthentication.status !== "STARTED") { return; } - if (!data.addEmail.email?.id) { + if (!data.startEmailAuthentication.authentication?.id) { throw new Error("Unexpected response from server"); } // Call the onAdd callback - await onAdd(data.addEmail.email?.id); + await onAdd(data.startEmailAuthentication.authentication?.id); }, }); @@ -63,20 +61,18 @@ const AddEmailForm: React.FC<{ const formData = new FormData(e.currentTarget); const email = formData.get("input") as string; - addEmail.mutate({ userId, email }); + await addEmail.mutateAsync({ email, language: i18n.languages[0] }); }; - const status = addEmail.data?.addEmail.status ?? null; - const violations = addEmail.data?.addEmail.violations ?? []; + const status = addEmail.data?.startEmailAuthentication.status ?? null; + const violations = addEmail.data?.startEmailAuthentication.violations ?? []; return ( - + {t("frontend.add_email_form.email_invalid_error")} - {status === "EXISTS" && ( + {status === "IN_USE" && ( - {t("frontend.add_email_form.email_exists_error")} + {t("frontend.add_email_form.email_in_use_error")} )} + {status === "RATE_LIMITED" && ( + {t("frontend.errors.rate_limit_exceeded")} + )} + {status === "DENIED" && ( <> diff --git a/frontend/src/components/UserProfile/UserEmailList.tsx b/frontend/src/components/UserProfile/UserEmailList.tsx index 9af5b0fb0..6db4adcf3 100644 --- a/frontend/src/components/UserProfile/UserEmailList.tsx +++ b/frontend/src/components/UserProfile/UserEmailList.tsx @@ -1,10 +1,11 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. -import { useSuspenseQuery } from "@tanstack/react-query"; +import { queryOptions, useSuspenseQuery } from "@tanstack/react-query"; +import { notFound } from "@tanstack/react-router"; import { useTransition } from "react"; import { type FragmentType, graphql, useFragment } from "../../gql"; import { graphqlRequest } from "../../graphql"; @@ -20,78 +21,64 @@ import UserEmail from "../UserEmail"; const QUERY = graphql(/* GraphQL */ ` query UserEmailList( - $userId: ID! $first: Int $after: String $last: Int $before: String ) { - user(id: $userId) { - id - - emails(first: $first, after: $after, last: $last, before: $before) { - edges { - cursor - node { - id - ...UserEmail_email + viewer { + __typename + ... on User { + emails(first: $first, after: $after, last: $last, before: $before) { + edges { + cursor + node { + ...UserEmail_email + } + } + totalCount + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor } - } - totalCount - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor } } } } `); -const FRAGMENT = graphql(/* GraphQL */ ` - fragment UserEmailList_user on User { - id - primaryEmail { - id - } - } -`); +export const query = (pagination: AnyPagination = { first: 6 }) => + queryOptions({ + queryKey: ["userEmails", pagination], + queryFn: ({ signal }) => + graphqlRequest({ + query: QUERY, + variables: pagination, + signal, + }), + }); export const CONFIG_FRAGMENT = graphql(/* GraphQL */ ` fragment UserEmailList_siteConfig on SiteConfig { - ...UserEmail_siteConfig + emailChangeAllowed } `); const UserEmailList: React.FC<{ - user: FragmentType; siteConfig: FragmentType; -}> = ({ user, siteConfig }) => { - const data = useFragment(FRAGMENT, user); - const config = useFragment(CONFIG_FRAGMENT, siteConfig); +}> = ({ siteConfig }) => { + const { emailChangeAllowed } = useFragment(CONFIG_FRAGMENT, siteConfig); const [pending, startTransition] = useTransition(); const [pagination, setPagination] = usePagination(); - const result = useSuspenseQuery({ - queryKey: ["userEmails", pagination], - queryFn: ({ signal }) => - graphqlRequest({ - query: QUERY, - variables: { - userId: data.id, - ...(pagination as AnyPagination), - }, - signal, - }), - }); - const emails = result.data.user?.emails; - if (!emails) throw new Error(); + const result = useSuspenseQuery(query(pagination)); + if (result.data.viewer.__typename !== "User") throw notFound(); + const emails = result.data.viewer.emails; const [prevPage, nextPage] = usePages(pagination, emails.pageInfo); - const primaryEmailId = data.primaryEmail?.id; - const paginate = (pagination: Pagination): void => { startTransition(() => { setPagination(pagination); @@ -105,22 +92,23 @@ const UserEmailList: React.FC<{ }); }; + // Is it allowed to remove an email? If there's only one, we can't + const canRemove = emailChangeAllowed && emails.totalCount > 1; + return ( <> - {emails.edges.map((edge) => - primaryEmailId === edge.node.id ? null : ( - - ), - )} + {emails.edges.map((edge) => ( + + ))} paginate(prevPage) : null} onNext={nextPage ? (): void => paginate(nextPage) : null} disabled={pending} diff --git a/frontend/src/components/VerifyEmail/VerifyEmail.module.css b/frontend/src/components/VerifyEmail/VerifyEmail.module.css deleted file mode 100644 index a13de89ab..000000000 --- a/frontend/src/components/VerifyEmail/VerifyEmail.module.css +++ /dev/null @@ -1,32 +0,0 @@ -/* Copyright 2024 New Vector Ltd. -* Copyright 2023, 2024 The Matrix.org Foundation C.I.C. -* -* SPDX-License-Identifier: AGPL-3.0-only -* Please see LICENSE in the repository root for full details. - */ - -.header { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--cpd-space-2x); - text-align: center; -} - -.tagline { - color: var(--cpd-color-text-secondary); - - & > span { - color: var(--cpd-color-text-primary); - } -} - -.icon { - height: var(--cpd-space-16x); - width: var(--cpd-space-16x); - color: var(--cpd-color-icon-secondary); - background: var(--cpd-color-bg-subtle-secondary); - padding: var(--cpd-space-2x); - border-radius: var(--cpd-space-2x); - margin-bottom: var(--cpd-space-2x); -} diff --git a/frontend/src/components/VerifyEmail/VerifyEmail.tsx b/frontend/src/components/VerifyEmail/VerifyEmail.tsx deleted file mode 100644 index c7c696b92..000000000 --- a/frontend/src/components/VerifyEmail/VerifyEmail.tsx +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright 2024 New Vector Ltd. -// Copyright 2023, 2024 The Matrix.org Foundation C.I.C. -// -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. - -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useLinkProps, useNavigate } from "@tanstack/react-router"; -import IconArrowLeft from "@vector-im/compound-design-tokens/assets/web/icons/arrow-left"; -import IconSend from "@vector-im/compound-design-tokens/assets/web/icons/send-solid"; -import { Alert, Button, Form, H1, Text } from "@vector-im/compound-web"; -import { useRef } from "react"; -import { Trans, useTranslation } from "react-i18next"; -import { type FragmentType, graphql, useFragment } from "../../gql"; -import { graphqlRequest } from "../../graphql"; -import styles from "./VerifyEmail.module.css"; - -const FRAGMENT = graphql(/* GraphQL */ ` - fragment UserEmail_verifyEmail on UserEmail { - id - email - } -`); - -const VERIFY_EMAIL_MUTATION = graphql(/* GraphQL */ ` - mutation DoVerifyEmail($id: ID!, $code: String!) { - verifyEmail(input: { userEmailId: $id, code: $code }) { - status - - user { - id - primaryEmail { - id - } - } - - email { - id - ...UserEmail_email - } - } - } -`); - -const RESEND_VERIFICATION_EMAIL_MUTATION = graphql(/* GraphQL */ ` - mutation ResendVerificationEmail($id: ID!) { - sendVerificationEmail(input: { userEmailId: $id }) { - status - - user { - id - primaryEmail { - id - } - } - - email { - id - ...UserEmail_email - } - } - } -`); - -const BackButton: React.FC = () => { - const props = useLinkProps({ to: "/" }); - const { t } = useTranslation(); - - return ( - - ); -}; - -const VerifyEmail: React.FC<{ - email: FragmentType; -}> = ({ email }) => { - const data = useFragment(FRAGMENT, email); - const queryClient = useQueryClient(); - const verifyEmail = useMutation({ - mutationFn: ({ id, code }: { id: string; code: string }) => - graphqlRequest({ query: VERIFY_EMAIL_MUTATION, variables: { id, code } }), - onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ["currentUserGreeting"] }); - queryClient.invalidateQueries({ queryKey: ["userProfile"] }); - queryClient.invalidateQueries({ queryKey: ["userEmails"] }); - - if (data.verifyEmail.status === "VERIFIED") { - navigate({ to: "/" }); - } - }, - }); - - const resendVerificationEmail = useMutation({ - mutationFn: (id: string) => - graphqlRequest({ - query: RESEND_VERIFICATION_EMAIL_MUTATION, - variables: { id }, - }), - onSuccess: () => { - fieldRef.current?.focus(); - }, - }); - const navigate = useNavigate(); - const fieldRef = useRef(null); - const { t } = useTranslation(); - - const onFormSubmit = (e: React.FormEvent): void => { - e.preventDefault(); - const form = e.currentTarget; - const formData = new FormData(form); - const code = formData.get("code") as string; - verifyEmail.mutateAsync({ id: data.id, code }).finally(() => form.reset()); - }; - - const onResendClick = (): void => { - resendVerificationEmail.mutate(data.id); - }; - - const emailSent = - resendVerificationEmail.data?.sendVerificationEmail.status === "SENT"; - const invalidCode = verifyEmail.data?.verifyEmail.status === "INVALID_CODE"; - const { email: codeEmail } = data; - - return ( - <> -
- -

{t("frontend.verify_email.heading")}

- - }} - /> - -
- - - {emailSent && ( - - {t("frontend.verify_email.email_sent_alert.description")} - - )} - {invalidCode && ( - - {t("frontend.verify_email.invalid_code_alert.description")} - - )} - - {t("frontend.verify_email.code_field_label")} - - - {invalidCode && ( - - {t("frontend.verify_email.code_field_error")} - - )} - - - {t("frontend.verify_email.code_field_wrong_shape")} - - - - - {t("action.continue")} - - - - - - ); -}; - -export default VerifyEmail; diff --git a/frontend/src/components/VerifyEmail/__snapshots__/VerifyEmail.test.tsx.snap b/frontend/src/components/VerifyEmail/__snapshots__/VerifyEmail.test.tsx.snap deleted file mode 100644 index 9e89b6078..000000000 --- a/frontend/src/components/VerifyEmail/__snapshots__/VerifyEmail.test.tsx.snap +++ /dev/null @@ -1,179 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[` > renders an active session 1`] = ` -[ -
- - - -

- Verify your email -

-

- Enter the 6-digit code sent to - - - ernie@sesame.st - -

-
, -
-
- - -
- - -
, -] -`; - -exports[` > renders verify screen for email 1`] = ` -[ -
- - - -

- Verify your email -

-

- Enter the 6-digit code sent to - - - ernie@sesame.st - -

-
, -
-
- - -
- - -
, -] -`; diff --git a/frontend/src/components/VerifyEmail/index.ts b/frontend/src/components/VerifyEmail/index.ts deleted file mode 100644 index 6b4461f11..000000000 --- a/frontend/src/components/VerifyEmail/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright 2024 New Vector Ltd. -// Copyright 2023, 2024 The Matrix.org Foundation C.I.C. -// -// SPDX-License-Identifier: AGPL-3.0-only -// Please see LICENSE in the repository root for full details. - -export { default } from "./VerifyEmail"; diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index d6334121d..e7d2ce0f6 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -29,32 +29,28 @@ const documents = { "\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent {\n name\n model\n os\n }\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n": types.BrowserSession_DetailFragmentDoc, "\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n userAgent {\n name\n os\n model\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n": types.CompatSession_DetailFragmentDoc, "\n fragment OAuth2Session_detail on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n client {\n id\n clientId\n clientName\n clientUri\n logoUri\n }\n }\n": types.OAuth2Session_DetailFragmentDoc, - "\n fragment UnverifiedEmailAlert_user on User {\n unverifiedEmails: emails(first: 0, state: PENDING) {\n totalCount\n }\n }\n": types.UnverifiedEmailAlert_UserFragmentDoc, - "\n fragment UserEmail_email on UserEmail {\n id\n email\n confirmedAt\n }\n": types.UserEmail_EmailFragmentDoc, + "\n fragment UserEmail_email on UserEmail {\n id\n email\n }\n": types.UserEmail_EmailFragmentDoc, "\n fragment UserEmail_siteConfig on SiteConfig {\n emailChangeAllowed\n }\n": types.UserEmail_SiteConfigFragmentDoc, "\n mutation RemoveEmail($id: ID!) {\n removeEmail(input: { userEmailId: $id }) {\n status\n\n user {\n id\n }\n }\n }\n": types.RemoveEmailDocument, - "\n mutation SetPrimaryEmail($id: ID!) {\n setPrimaryEmail(input: { userEmailId: $id }) {\n status\n user {\n id\n primaryEmail {\n id\n }\n }\n }\n }\n": types.SetPrimaryEmailDocument, "\n fragment UserGreeting_user on User {\n id\n matrix {\n mxid\n displayName\n }\n }\n": types.UserGreeting_UserFragmentDoc, "\n fragment UserGreeting_siteConfig on SiteConfig {\n displayNameChangeAllowed\n }\n": types.UserGreeting_SiteConfigFragmentDoc, "\n mutation SetDisplayName($userId: ID!, $displayName: String) {\n setDisplayName(input: { userId: $userId, displayName: $displayName }) {\n status\n }\n }\n": types.SetDisplayNameDocument, - "\n mutation AddEmail($userId: ID!, $email: String!) {\n addEmail(input: { userId: $userId, email: $email }) {\n status\n violations\n email {\n id\n ...UserEmail_email\n }\n }\n }\n": types.AddEmailDocument, - "\n query UserEmailList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n\n emails(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n id\n ...UserEmail_email\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n": types.UserEmailListDocument, - "\n fragment UserEmailList_user on User {\n id\n primaryEmail {\n id\n }\n }\n": types.UserEmailList_UserFragmentDoc, - "\n fragment UserEmailList_siteConfig on SiteConfig {\n ...UserEmail_siteConfig\n }\n": types.UserEmailList_SiteConfigFragmentDoc, + "\n mutation AddEmail($email: String!, $language: String!) {\n startEmailAuthentication(input: { email: $email, language: $language }) {\n status\n violations\n authentication {\n id\n }\n }\n }\n": types.AddEmailDocument, + "\n query UserEmailList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n viewer {\n __typename\n ... on User {\n emails(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n ...UserEmail_email\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n": types.UserEmailListDocument, + "\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n }\n": types.UserEmailList_SiteConfigFragmentDoc, "\n fragment BrowserSessionsOverview_user on User {\n id\n\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n": types.BrowserSessionsOverview_UserFragmentDoc, - "\n fragment UserEmail_verifyEmail on UserEmail {\n id\n email\n }\n": types.UserEmail_VerifyEmailFragmentDoc, - "\n mutation DoVerifyEmail($id: ID!, $code: String!) {\n verifyEmail(input: { userEmailId: $id, code: $code }) {\n status\n\n user {\n id\n primaryEmail {\n id\n }\n }\n\n email {\n id\n ...UserEmail_email\n }\n }\n }\n": types.DoVerifyEmailDocument, - "\n mutation ResendVerificationEmail($id: ID!) {\n sendVerificationEmail(input: { userEmailId: $id }) {\n status\n\n user {\n id\n primaryEmail {\n id\n }\n }\n\n email {\n id\n ...UserEmail_email\n }\n }\n }\n": types.ResendVerificationEmailDocument, - "\n query UserProfile {\n viewer {\n __typename\n ... on User {\n id\n primaryEmail {\n id\n ...UserEmail_email\n }\n\n ...UserEmailList_user\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n ...UserEmailList_siteConfig\n ...UserEmail_siteConfig\n ...PasswordChange_siteConfig\n }\n }\n": types.UserProfileDocument, + "\n query UserProfile {\n viewer {\n __typename\n ... on User {\n emails(first: 0) {\n totalCount\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n ...UserEmailList_siteConfig\n ...UserEmail_siteConfig\n ...PasswordChange_siteConfig\n }\n }\n": types.UserProfileDocument, "\n query SessionDetail($id: ID!) {\n viewerSession {\n ... on Node {\n id\n }\n }\n\n node(id: $id) {\n __typename\n id\n ...CompatSession_detail\n ...OAuth2Session_detail\n ...BrowserSession_detail\n }\n }\n": types.SessionDetailDocument, "\n query BrowserSessionList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n $lastActive: DateFilter\n ) {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n\n user {\n id\n\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n lastActive: $lastActive\n state: ACTIVE\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n }\n": types.BrowserSessionListDocument, "\n query SessionsOverview {\n viewer {\n __typename\n\n ... on User {\n id\n ...BrowserSessionsOverview_user\n }\n }\n }\n": types.SessionsOverviewDocument, "\n query AppSessionsList(\n $before: String\n $after: String\n $first: Int\n $last: Int\n $lastActive: DateFilter\n ) {\n viewer {\n __typename\n\n ... on User {\n id\n appSessions(\n before: $before\n after: $after\n first: $first\n last: $last\n lastActive: $lastActive\n state: ACTIVE\n ) {\n edges {\n cursor\n node {\n __typename\n ...CompatSession_session\n ...OAuth2Session_session\n }\n }\n\n totalCount\n pageInfo {\n startCursor\n endCursor\n hasNextPage\n hasPreviousPage\n }\n }\n }\n }\n }\n": types.AppSessionsListDocument, - "\n query CurrentUserGreeting {\n viewerSession {\n __typename\n\n ... on BrowserSession {\n id\n\n user {\n ...UnverifiedEmailAlert_user\n ...UserGreeting_user\n }\n }\n }\n\n siteConfig {\n ...UserGreeting_siteConfig\n }\n }\n": types.CurrentUserGreetingDocument, + "\n query CurrentUserGreeting {\n viewerSession {\n __typename\n\n ... on BrowserSession {\n id\n\n user {\n ...UserGreeting_user\n }\n }\n }\n\n siteConfig {\n ...UserGreeting_siteConfig\n }\n }\n": types.CurrentUserGreetingDocument, "\n query OAuth2Client($id: ID!) {\n oauth2Client(id: $id) {\n ...OAuth2Client_detail\n }\n }\n": types.OAuth2ClientDocument, "\n query CurrentViewer {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n }\n": types.CurrentViewerDocument, "\n query DeviceRedirect($deviceId: String!, $userId: ID!) {\n session(deviceId: $deviceId, userId: $userId) {\n __typename\n ... on Node {\n id\n }\n }\n }\n": types.DeviceRedirectDocument, - "\n query VerifyEmail($id: ID!) {\n userEmail(id: $id) {\n ...UserEmail_verifyEmail\n }\n }\n": types.VerifyEmailDocument, + "\n mutation DoVerifyEmail($id: ID!, $code: String!) {\n completeEmailAuthentication(input: { id: $id, code: $code }) {\n status\n }\n }\n": types.DoVerifyEmailDocument, + "\n mutation ResendEmailAuthenticationCode($id: ID!, $language: String!) {\n resendEmailAuthenticationCode(input: { id: $id, language: $language }) {\n status\n }\n }\n": types.ResendEmailAuthenticationCodeDocument, + "\n query VerifyEmail($id: ID!) {\n userEmailAuthentication(id: $id) {\n id\n email\n completedAt\n }\n }\n": types.VerifyEmailDocument, "\n mutation ChangePassword(\n $userId: ID!\n $oldPassword: String!\n $newPassword: String!\n ) {\n setPassword(\n input: {\n userId: $userId\n currentPassword: $oldPassword\n newPassword: $newPassword\n }\n ) {\n status\n }\n }\n": types.ChangePasswordDocument, "\n query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n }\n": types.PasswordChangeDocument, "\n mutation RecoverPassword($ticket: String!, $newPassword: String!) {\n setPasswordByRecovery(\n input: { ticket: $ticket, newPassword: $newPassword }\n ) {\n status\n }\n }\n": types.RecoverPasswordDocument, @@ -124,11 +120,7 @@ export function graphql(source: "\n fragment OAuth2Session_detail on Oauth2Sess /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n fragment UnverifiedEmailAlert_user on User {\n unverifiedEmails: emails(first: 0, state: PENDING) {\n totalCount\n }\n }\n"): typeof import('./graphql').UnverifiedEmailAlert_UserFragmentDoc; -/** - * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. - */ -export function graphql(source: "\n fragment UserEmail_email on UserEmail {\n id\n email\n confirmedAt\n }\n"): typeof import('./graphql').UserEmail_EmailFragmentDoc; +export function graphql(source: "\n fragment UserEmail_email on UserEmail {\n id\n email\n }\n"): typeof import('./graphql').UserEmail_EmailFragmentDoc; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -137,10 +129,6 @@ export function graphql(source: "\n fragment UserEmail_siteConfig on SiteConfig * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n mutation RemoveEmail($id: ID!) {\n removeEmail(input: { userEmailId: $id }) {\n status\n\n user {\n id\n }\n }\n }\n"): typeof import('./graphql').RemoveEmailDocument; -/** - * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. - */ -export function graphql(source: "\n mutation SetPrimaryEmail($id: ID!) {\n setPrimaryEmail(input: { userEmailId: $id }) {\n status\n user {\n id\n primaryEmail {\n id\n }\n }\n }\n }\n"): typeof import('./graphql').SetPrimaryEmailDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -156,19 +144,15 @@ export function graphql(source: "\n mutation SetDisplayName($userId: ID!, $disp /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n mutation AddEmail($userId: ID!, $email: String!) {\n addEmail(input: { userId: $userId, email: $email }) {\n status\n violations\n email {\n id\n ...UserEmail_email\n }\n }\n }\n"): typeof import('./graphql').AddEmailDocument; +export function graphql(source: "\n mutation AddEmail($email: String!, $language: String!) {\n startEmailAuthentication(input: { email: $email, language: $language }) {\n status\n violations\n authentication {\n id\n }\n }\n }\n"): typeof import('./graphql').AddEmailDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n query UserEmailList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n\n emails(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n id\n ...UserEmail_email\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n"): typeof import('./graphql').UserEmailListDocument; +export function graphql(source: "\n query UserEmailList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n viewer {\n __typename\n ... on User {\n emails(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n ...UserEmail_email\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n"): typeof import('./graphql').UserEmailListDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n fragment UserEmailList_user on User {\n id\n primaryEmail {\n id\n }\n }\n"): typeof import('./graphql').UserEmailList_UserFragmentDoc; -/** - * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. - */ -export function graphql(source: "\n fragment UserEmailList_siteConfig on SiteConfig {\n ...UserEmail_siteConfig\n }\n"): typeof import('./graphql').UserEmailList_SiteConfigFragmentDoc; +export function graphql(source: "\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n }\n"): typeof import('./graphql').UserEmailList_SiteConfigFragmentDoc; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -176,19 +160,7 @@ export function graphql(source: "\n fragment BrowserSessionsOverview_user on Us /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n fragment UserEmail_verifyEmail on UserEmail {\n id\n email\n }\n"): typeof import('./graphql').UserEmail_VerifyEmailFragmentDoc; -/** - * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. - */ -export function graphql(source: "\n mutation DoVerifyEmail($id: ID!, $code: String!) {\n verifyEmail(input: { userEmailId: $id, code: $code }) {\n status\n\n user {\n id\n primaryEmail {\n id\n }\n }\n\n email {\n id\n ...UserEmail_email\n }\n }\n }\n"): typeof import('./graphql').DoVerifyEmailDocument; -/** - * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. - */ -export function graphql(source: "\n mutation ResendVerificationEmail($id: ID!) {\n sendVerificationEmail(input: { userEmailId: $id }) {\n status\n\n user {\n id\n primaryEmail {\n id\n }\n }\n\n email {\n id\n ...UserEmail_email\n }\n }\n }\n"): typeof import('./graphql').ResendVerificationEmailDocument; -/** - * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. - */ -export function graphql(source: "\n query UserProfile {\n viewer {\n __typename\n ... on User {\n id\n primaryEmail {\n id\n ...UserEmail_email\n }\n\n ...UserEmailList_user\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n ...UserEmailList_siteConfig\n ...UserEmail_siteConfig\n ...PasswordChange_siteConfig\n }\n }\n"): typeof import('./graphql').UserProfileDocument; +export function graphql(source: "\n query UserProfile {\n viewer {\n __typename\n ... on User {\n emails(first: 0) {\n totalCount\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n ...UserEmailList_siteConfig\n ...UserEmail_siteConfig\n ...PasswordChange_siteConfig\n }\n }\n"): typeof import('./graphql').UserProfileDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -208,7 +180,7 @@ export function graphql(source: "\n query AppSessionsList(\n $before: String /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n query CurrentUserGreeting {\n viewerSession {\n __typename\n\n ... on BrowserSession {\n id\n\n user {\n ...UnverifiedEmailAlert_user\n ...UserGreeting_user\n }\n }\n }\n\n siteConfig {\n ...UserGreeting_siteConfig\n }\n }\n"): typeof import('./graphql').CurrentUserGreetingDocument; +export function graphql(source: "\n query CurrentUserGreeting {\n viewerSession {\n __typename\n\n ... on BrowserSession {\n id\n\n user {\n ...UserGreeting_user\n }\n }\n }\n\n siteConfig {\n ...UserGreeting_siteConfig\n }\n }\n"): typeof import('./graphql').CurrentUserGreetingDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -224,7 +196,15 @@ export function graphql(source: "\n query DeviceRedirect($deviceId: String!, $u /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n query VerifyEmail($id: ID!) {\n userEmail(id: $id) {\n ...UserEmail_verifyEmail\n }\n }\n"): typeof import('./graphql').VerifyEmailDocument; +export function graphql(source: "\n mutation DoVerifyEmail($id: ID!, $code: String!) {\n completeEmailAuthentication(input: { id: $id, code: $code }) {\n status\n }\n }\n"): typeof import('./graphql').DoVerifyEmailDocument; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n mutation ResendEmailAuthenticationCode($id: ID!, $language: String!) {\n resendEmailAuthenticationCode(input: { id: $id, language: $language }) {\n status\n }\n }\n"): typeof import('./graphql').ResendEmailAuthenticationCodeDocument; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query VerifyEmail($id: ID!) {\n userEmailAuthentication(id: $id) {\n id\n email\n completedAt\n }\n }\n"): typeof import('./graphql').VerifyEmailDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 3980ea976..c342bd3f4 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -326,6 +326,34 @@ export type CompatSsoLoginEdge = { node: CompatSsoLogin; }; +/** The input for the `completeEmailAuthentication` mutation */ +export type CompleteEmailAuthenticationInput = { + /** The authentication code to use */ + code: Scalars['String']['input']; + /** The ID of the authentication session to complete */ + id: Scalars['ID']['input']; +}; + +/** The payload of the `completeEmailAuthentication` mutation */ +export type CompleteEmailAuthenticationPayload = { + __typename?: 'CompleteEmailAuthenticationPayload'; + /** Status of the operation */ + status: CompleteEmailAuthenticationStatus; +}; + +/** The status of the `completeEmailAuthentication` mutation */ +export type CompleteEmailAuthenticationStatus = + /** The authentication code has expired */ + | 'CODE_EXPIRED' + /** The authentication was completed */ + | 'COMPLETED' + /** The authentication code is invalid */ + | 'INVALID_CODE' + /** The email address is already in use */ + | 'IN_USE' + /** Too many attempts to complete an email authentication */ + | 'RATE_LIMITED'; + /** The input of the `createOauth2Session` mutation. */ export type CreateOAuth2SessionInput = { /** Whether the session should issue a never-expiring access token */ @@ -474,12 +502,17 @@ export type MatrixUser = { /** The mutations root of the GraphQL interface. */ export type Mutation = { __typename?: 'Mutation'; - /** Add an email address to the specified user */ + /** + * Add an email address to the specified user + * @deprecated Use `startEmailAuthentication` instead. + */ addEmail: AddEmailPayload; /** Add a user. This is only available to administrators. */ addUser: AddUserPayload; /** Temporarily allow user to reset their cross-signing keys. */ allowUserCrossSigningReset: AllowUserCrossSigningResetPayload; + /** Complete the email authentication flow */ + completeEmailAuthentication: CompleteEmailAuthenticationPayload; /** * Create a new arbitrary OAuth 2.0 Session. * @@ -493,6 +526,8 @@ export type Mutation = { lockUser: LockUserPayload; /** Remove an email address */ removeEmail: RemoveEmailPayload; + /** Resend the email authentication code */ + resendEmailAuthenticationCode: ResendEmailAuthenticationCodePayload; /** * Resend a user recovery email * @@ -501,8 +536,6 @@ export type Mutation = { * calls this mutation. */ resendRecoveryEmail: ResendRecoveryEmailPayload; - /** Send a verification code for an email address */ - sendVerificationEmail: SendVerificationEmailPayload; /** * Set whether a user can request admin. This is only available to * administrators. @@ -521,12 +554,15 @@ export type Mutation = { setPassword: SetPasswordPayload; /** Set the password for yourself, using a recovery ticket sent by e-mail. */ setPasswordByRecovery: SetPasswordPayload; - /** Set an email address as primary */ + /** + * Set an email address as primary + * @deprecated This doesn't do anything anymore, but is kept to avoid breaking existing queries + */ setPrimaryEmail: SetPrimaryEmailPayload; + /** Start a new email authentication flow */ + startEmailAuthentication: StartEmailAuthenticationPayload; /** Unlock a user. This is only available to administrators. */ unlockUser: UnlockUserPayload; - /** Submit a verification code for an email address */ - verifyEmail: VerifyEmailPayload; }; @@ -548,6 +584,12 @@ export type MutationAllowUserCrossSigningResetArgs = { }; +/** The mutations root of the GraphQL interface. */ +export type MutationCompleteEmailAuthenticationArgs = { + input: CompleteEmailAuthenticationInput; +}; + + /** The mutations root of the GraphQL interface. */ export type MutationCreateOauth2SessionArgs = { input: CreateOAuth2SessionInput; @@ -585,14 +627,14 @@ export type MutationRemoveEmailArgs = { /** The mutations root of the GraphQL interface. */ -export type MutationResendRecoveryEmailArgs = { - input: ResendRecoveryEmailInput; +export type MutationResendEmailAuthenticationCodeArgs = { + input: ResendEmailAuthenticationCodeInput; }; /** The mutations root of the GraphQL interface. */ -export type MutationSendVerificationEmailArgs = { - input: SendVerificationEmailInput; +export type MutationResendRecoveryEmailArgs = { + input: ResendRecoveryEmailInput; }; @@ -627,14 +669,14 @@ export type MutationSetPrimaryEmailArgs = { /** The mutations root of the GraphQL interface. */ -export type MutationUnlockUserArgs = { - input: UnlockUserInput; +export type MutationStartEmailAuthenticationArgs = { + input: StartEmailAuthenticationInput; }; /** The mutations root of the GraphQL interface. */ -export type MutationVerifyEmailArgs = { - input: VerifyEmailInput; +export type MutationUnlockUserArgs = { + input: UnlockUserInput; }; /** An object with an ID. */ @@ -776,6 +818,8 @@ export type Query = { userByUsername?: Maybe; /** Fetch a user email by its ID. */ userEmail?: Maybe; + /** Fetch a user email authentication session */ + userEmailAuthentication?: Maybe; /** Fetch a user recovery ticket. */ userRecoveryTicket?: Maybe; /** @@ -867,6 +911,12 @@ export type QueryUserEmailArgs = { }; +/** The query root of the GraphQL interface. */ +export type QueryUserEmailAuthenticationArgs = { + id: Scalars['ID']['input']; +}; + + /** The query root of the GraphQL interface. */ export type QueryUserRecoveryTicketArgs = { ticket: Scalars['String']['input']; @@ -904,11 +954,33 @@ export type RemoveEmailPayload = { export type RemoveEmailStatus = /** The email address was not found */ | 'NOT_FOUND' - /** Can't remove the primary email address */ - | 'PRIMARY' /** The email address was removed */ | 'REMOVED'; +/** The input for the `resendEmailAuthenticationCode` mutation */ +export type ResendEmailAuthenticationCodeInput = { + /** The ID of the authentication session to resend the code for */ + id: Scalars['ID']['input']; + /** The language to use for the email */ + language?: Scalars['String']['input']; +}; + +/** The payload of the `resendEmailAuthenticationCode` mutation */ +export type ResendEmailAuthenticationCodePayload = { + __typename?: 'ResendEmailAuthenticationCodePayload'; + /** Status of the operation */ + status: ResendEmailAuthenticationCodeStatus; +}; + +/** The status of the `resendEmailAuthenticationCode` mutation */ +export type ResendEmailAuthenticationCodeStatus = + /** The email authentication session is already completed */ + | 'COMPLETED' + /** Too many attempts to resend an email authentication code */ + | 'RATE_LIMITED' + /** The email was resent */ + | 'RESENT'; + /** The input for the `resendRecoveryEmail` mutation. */ export type ResendRecoveryEmailInput = { /** The recovery ticket to use. */ @@ -933,30 +1005,6 @@ export type ResendRecoveryEmailStatus = /** The recovery email was sent. */ | 'SENT'; -/** The input for the `sendVerificationEmail` mutation */ -export type SendVerificationEmailInput = { - /** The ID of the email address to verify */ - userEmailId: Scalars['ID']['input']; -}; - -/** The payload of the `sendVerificationEmail` mutation */ -export type SendVerificationEmailPayload = { - __typename?: 'SendVerificationEmailPayload'; - /** The email address to which the verification email was sent */ - email: UserEmail; - /** Status of the operation */ - status: SendVerificationEmailStatus; - /** The user to whom the email address belongs */ - user: User; -}; - -/** The status of the `sendVerificationEmail` mutation */ -export type SendVerificationEmailStatus = - /** The email address is already verified */ - | 'ALREADY_VERIFIED' - /** The verification email was sent */ - | 'SENT'; - /** A client session, either compat or OAuth 2.0 */ export type Session = CompatSession | Oauth2Session; @@ -1136,6 +1184,38 @@ export type SiteConfig = Node & { tosUri?: Maybe; }; +/** The input for the `startEmailAuthentication` mutation */ +export type StartEmailAuthenticationInput = { + /** The email address to add to the account */ + email: Scalars['String']['input']; + /** The language to use for the email */ + language?: Scalars['String']['input']; +}; + +/** The payload of the `startEmailAuthentication` mutation */ +export type StartEmailAuthenticationPayload = { + __typename?: 'StartEmailAuthenticationPayload'; + /** The email authentication session that was started */ + authentication?: Maybe; + /** Status of the operation */ + status: StartEmailAuthenticationStatus; + /** The list of policy violations if the email address was denied */ + violations?: Maybe>; +}; + +/** The status of the `startEmailAuthentication` mutation */ +export type StartEmailAuthenticationStatus = + /** The email address isn't allowed by the policy */ + | 'DENIED' + /** The email address is invalid */ + | 'INVALID_EMAIL_ADDRESS' + /** The email address is already in use on this account */ + | 'IN_USE' + /** Too many attempts to start an email authentication */ + | 'RATE_LIMITED' + /** The email address was started */ + | 'STARTED'; + /** The input for the `unlockUser` mutation. */ export type UnlockUserInput = { /** The ID of the user to unlock */ @@ -1266,8 +1346,6 @@ export type User = Node & { matrix: MatrixUser; /** Get the list of OAuth 2.0 sessions, chronologically sorted */ oauth2Sessions: Oauth2SessionConnection; - /** Primary email address of the user. */ - primaryEmail?: Maybe; /** Get the list of upstream OAuth 2.0 links */ upstreamOauth2Links: UpstreamOAuth2LinkConnection; /** Username chosen by the user. */ @@ -1396,6 +1474,7 @@ export type UserEmail = CreationEvent & Node & { /** * When the email address was confirmed. Is `null` if the email was never * verified by the user. + * @deprecated Emails are always confirmed now. */ confirmedAt?: Maybe; /** When the object was created. */ @@ -1406,6 +1485,19 @@ export type UserEmail = CreationEvent & Node & { id: Scalars['ID']['output']; }; +/** A email authentication session */ +export type UserEmailAuthentication = CreationEvent & Node & { + __typename?: 'UserEmailAuthentication'; + /** When the object was last updated. */ + completedAt?: Maybe; + /** When the object was created. */ + createdAt: Scalars['DateTime']['output']; + /** The email address associated with this session */ + email: Scalars['String']['output']; + /** ID of the object. */ + id: Scalars['ID']['output']; +}; + export type UserEmailConnection = { __typename?: 'UserEmailConnection'; /** A list of edges. */ @@ -1465,34 +1557,6 @@ export type UserState = /** The user is locked. */ | 'LOCKED'; -/** The input for the `verifyEmail` mutation */ -export type VerifyEmailInput = { - /** The verification code */ - code: Scalars['String']['input']; - /** The ID of the email address to verify */ - userEmailId: Scalars['ID']['input']; -}; - -/** The payload of the `verifyEmail` mutation */ -export type VerifyEmailPayload = { - __typename?: 'VerifyEmailPayload'; - /** The email address that was verified */ - email?: Maybe; - /** Status of the operation */ - status: VerifyEmailStatus; - /** The user to whom the email address belongs */ - user?: Maybe; -}; - -/** The status of the `verifyEmail` mutation */ -export type VerifyEmailStatus = - /** The email address was already verified before */ - | 'ALREADY_VERIFIED' - /** The verification code is invalid */ - | 'INVALID_CODE' - /** The email address was just verified */ - | 'VERIFIED'; - /** Represents the current viewer */ export type Viewer = Anonymous | User; @@ -1551,9 +1615,7 @@ export type CompatSession_DetailFragment = { __typename?: 'CompatSession', id: s export type OAuth2Session_DetailFragment = { __typename?: 'Oauth2Session', id: string, scope: string, createdAt: string, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, client: { __typename?: 'Oauth2Client', id: string, clientId: string, clientName?: string | null, clientUri?: string | null, logoUri?: string | null } } & { ' $fragmentName'?: 'OAuth2Session_DetailFragment' }; -export type UnverifiedEmailAlert_UserFragment = { __typename?: 'User', unverifiedEmails: { __typename?: 'UserEmailConnection', totalCount: number } } & { ' $fragmentName'?: 'UnverifiedEmailAlert_UserFragment' }; - -export type UserEmail_EmailFragment = { __typename?: 'UserEmail', id: string, email: string, confirmedAt?: string | null } & { ' $fragmentName'?: 'UserEmail_EmailFragment' }; +export type UserEmail_EmailFragment = { __typename?: 'UserEmail', id: string, email: string } & { ' $fragmentName'?: 'UserEmail_EmailFragment' }; export type UserEmail_SiteConfigFragment = { __typename?: 'SiteConfig', emailChangeAllowed: boolean } & { ' $fragmentName'?: 'UserEmail_SiteConfigFragment' }; @@ -1564,13 +1626,6 @@ export type RemoveEmailMutationVariables = Exact<{ export type RemoveEmailMutation = { __typename?: 'Mutation', removeEmail: { __typename?: 'RemoveEmailPayload', status: RemoveEmailStatus, user?: { __typename?: 'User', id: string } | null } }; -export type SetPrimaryEmailMutationVariables = Exact<{ - id: Scalars['ID']['input']; -}>; - - -export type SetPrimaryEmailMutation = { __typename?: 'Mutation', setPrimaryEmail: { __typename?: 'SetPrimaryEmailPayload', status: SetPrimaryEmailStatus, user?: { __typename?: 'User', id: string, primaryEmail?: { __typename?: 'UserEmail', id: string } | null } | null } }; - export type UserGreeting_UserFragment = { __typename?: 'User', id: string, matrix: { __typename?: 'MatrixUser', mxid: string, displayName?: string | null } } & { ' $fragmentName'?: 'UserGreeting_UserFragment' }; export type UserGreeting_SiteConfigFragment = { __typename?: 'SiteConfig', displayNameChangeAllowed: boolean } & { ' $fragmentName'?: 'UserGreeting_SiteConfigFragment' }; @@ -1584,18 +1639,14 @@ export type SetDisplayNameMutationVariables = Exact<{ export type SetDisplayNameMutation = { __typename?: 'Mutation', setDisplayName: { __typename?: 'SetDisplayNamePayload', status: SetDisplayNameStatus } }; export type AddEmailMutationVariables = Exact<{ - userId: Scalars['ID']['input']; email: Scalars['String']['input']; + language: Scalars['String']['input']; }>; -export type AddEmailMutation = { __typename?: 'Mutation', addEmail: { __typename?: 'AddEmailPayload', status: AddEmailStatus, violations?: Array | null, email?: ( - { __typename?: 'UserEmail', id: string } - & { ' $fragmentRefs'?: { 'UserEmail_EmailFragment': UserEmail_EmailFragment } } - ) | null } }; +export type AddEmailMutation = { __typename?: 'Mutation', startEmailAuthentication: { __typename?: 'StartEmailAuthenticationPayload', status: StartEmailAuthenticationStatus, violations?: Array | null, authentication?: { __typename?: 'UserEmailAuthentication', id: string } | null } }; export type UserEmailListQueryVariables = Exact<{ - userId: Scalars['ID']['input']; first?: InputMaybe; after?: InputMaybe; last?: InputMaybe; @@ -1603,53 +1654,19 @@ export type UserEmailListQueryVariables = Exact<{ }>; -export type UserEmailListQuery = { __typename?: 'Query', user?: { __typename?: 'User', id: string, emails: { __typename?: 'UserEmailConnection', totalCount: number, edges: Array<{ __typename?: 'UserEmailEdge', cursor: string, node: ( - { __typename?: 'UserEmail', id: string } +export type UserEmailListQuery = { __typename?: 'Query', viewer: { __typename: 'Anonymous' } | { __typename: 'User', emails: { __typename?: 'UserEmailConnection', totalCount: number, edges: Array<{ __typename?: 'UserEmailEdge', cursor: string, node: ( + { __typename?: 'UserEmail' } & { ' $fragmentRefs'?: { 'UserEmail_EmailFragment': UserEmail_EmailFragment } } - ) }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } | null }; - -export type UserEmailList_UserFragment = { __typename?: 'User', id: string, primaryEmail?: { __typename?: 'UserEmail', id: string } | null } & { ' $fragmentName'?: 'UserEmailList_UserFragment' }; + ) }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } }; -export type UserEmailList_SiteConfigFragment = ( - { __typename?: 'SiteConfig' } - & { ' $fragmentRefs'?: { 'UserEmail_SiteConfigFragment': UserEmail_SiteConfigFragment } } -) & { ' $fragmentName'?: 'UserEmailList_SiteConfigFragment' }; +export type UserEmailList_SiteConfigFragment = { __typename?: 'SiteConfig', emailChangeAllowed: boolean } & { ' $fragmentName'?: 'UserEmailList_SiteConfigFragment' }; export type BrowserSessionsOverview_UserFragment = { __typename?: 'User', id: string, browserSessions: { __typename?: 'BrowserSessionConnection', totalCount: number } } & { ' $fragmentName'?: 'BrowserSessionsOverview_UserFragment' }; -export type UserEmail_VerifyEmailFragment = { __typename?: 'UserEmail', id: string, email: string } & { ' $fragmentName'?: 'UserEmail_VerifyEmailFragment' }; - -export type DoVerifyEmailMutationVariables = Exact<{ - id: Scalars['ID']['input']; - code: Scalars['String']['input']; -}>; - - -export type DoVerifyEmailMutation = { __typename?: 'Mutation', verifyEmail: { __typename?: 'VerifyEmailPayload', status: VerifyEmailStatus, user?: { __typename?: 'User', id: string, primaryEmail?: { __typename?: 'UserEmail', id: string } | null } | null, email?: ( - { __typename?: 'UserEmail', id: string } - & { ' $fragmentRefs'?: { 'UserEmail_EmailFragment': UserEmail_EmailFragment } } - ) | null } }; - -export type ResendVerificationEmailMutationVariables = Exact<{ - id: Scalars['ID']['input']; -}>; - - -export type ResendVerificationEmailMutation = { __typename?: 'Mutation', sendVerificationEmail: { __typename?: 'SendVerificationEmailPayload', status: SendVerificationEmailStatus, user: { __typename?: 'User', id: string, primaryEmail?: { __typename?: 'UserEmail', id: string } | null }, email: ( - { __typename?: 'UserEmail', id: string } - & { ' $fragmentRefs'?: { 'UserEmail_EmailFragment': UserEmail_EmailFragment } } - ) } }; - export type UserProfileQueryVariables = Exact<{ [key: string]: never; }>; -export type UserProfileQuery = { __typename?: 'Query', viewer: { __typename: 'Anonymous' } | ( - { __typename: 'User', id: string, primaryEmail?: ( - { __typename?: 'UserEmail', id: string } - & { ' $fragmentRefs'?: { 'UserEmail_EmailFragment': UserEmail_EmailFragment } } - ) | null } - & { ' $fragmentRefs'?: { 'UserEmailList_UserFragment': UserEmailList_UserFragment } } - ), siteConfig: ( +export type UserProfileQuery = { __typename?: 'Query', viewer: { __typename: 'Anonymous' } | { __typename: 'User', emails: { __typename?: 'UserEmailConnection', totalCount: number } }, siteConfig: ( { __typename?: 'SiteConfig', emailChangeAllowed: boolean, passwordLoginEnabled: boolean } & { ' $fragmentRefs'?: { 'UserEmailList_SiteConfigFragment': UserEmailList_SiteConfigFragment;'UserEmail_SiteConfigFragment': UserEmail_SiteConfigFragment;'PasswordChange_SiteConfigFragment': PasswordChange_SiteConfigFragment } } ) }; @@ -1668,7 +1685,7 @@ export type SessionDetailQuery = { __typename?: 'Query', viewerSession: { __type ) | { __typename: 'CompatSsoLogin', id: string } | { __typename: 'Oauth2Client', id: string } | ( { __typename: 'Oauth2Session', id: string } & { ' $fragmentRefs'?: { 'OAuth2Session_DetailFragment': OAuth2Session_DetailFragment } } - ) | { __typename: 'SiteConfig', id: string } | { __typename: 'UpstreamOAuth2Link', id: string } | { __typename: 'UpstreamOAuth2Provider', id: string } | { __typename: 'User', id: string } | { __typename: 'UserEmail', id: string } | { __typename: 'UserRecoveryTicket', id: string } | null }; + ) | { __typename: 'SiteConfig', id: string } | { __typename: 'UpstreamOAuth2Link', id: string } | { __typename: 'UpstreamOAuth2Provider', id: string } | { __typename: 'User', id: string } | { __typename: 'UserEmail', id: string } | { __typename: 'UserEmailAuthentication', id: string } | { __typename: 'UserRecoveryTicket', id: string } | null }; export type BrowserSessionListQueryVariables = Exact<{ first?: InputMaybe; @@ -1714,7 +1731,7 @@ export type CurrentUserGreetingQueryVariables = Exact<{ [key: string]: never; }> export type CurrentUserGreetingQuery = { __typename?: 'Query', viewerSession: { __typename: 'Anonymous' } | { __typename: 'BrowserSession', id: string, user: ( { __typename?: 'User' } - & { ' $fragmentRefs'?: { 'UnverifiedEmailAlert_UserFragment': UnverifiedEmailAlert_UserFragment;'UserGreeting_UserFragment': UserGreeting_UserFragment } } + & { ' $fragmentRefs'?: { 'UserGreeting_UserFragment': UserGreeting_UserFragment } } ) } | { __typename: 'Oauth2Session' }, siteConfig: ( { __typename?: 'SiteConfig' } & { ' $fragmentRefs'?: { 'UserGreeting_SiteConfigFragment': UserGreeting_SiteConfigFragment } } @@ -1743,15 +1760,28 @@ export type DeviceRedirectQueryVariables = Exact<{ export type DeviceRedirectQuery = { __typename?: 'Query', session?: { __typename: 'CompatSession', id: string } | { __typename: 'Oauth2Session', id: string } | null }; +export type DoVerifyEmailMutationVariables = Exact<{ + id: Scalars['ID']['input']; + code: Scalars['String']['input']; +}>; + + +export type DoVerifyEmailMutation = { __typename?: 'Mutation', completeEmailAuthentication: { __typename?: 'CompleteEmailAuthenticationPayload', status: CompleteEmailAuthenticationStatus } }; + +export type ResendEmailAuthenticationCodeMutationVariables = Exact<{ + id: Scalars['ID']['input']; + language: Scalars['String']['input']; +}>; + + +export type ResendEmailAuthenticationCodeMutation = { __typename?: 'Mutation', resendEmailAuthenticationCode: { __typename?: 'ResendEmailAuthenticationCodePayload', status: ResendEmailAuthenticationCodeStatus } }; + export type VerifyEmailQueryVariables = Exact<{ id: Scalars['ID']['input']; }>; -export type VerifyEmailQuery = { __typename?: 'Query', userEmail?: ( - { __typename?: 'UserEmail' } - & { ' $fragmentRefs'?: { 'UserEmail_VerifyEmailFragment': UserEmail_VerifyEmailFragment } } - ) | null }; +export type VerifyEmailQuery = { __typename?: 'Query', userEmailAuthentication?: { __typename?: 'UserEmailAuthentication', id: string, email: string, completedAt?: string | null } | null }; export type ChangePasswordMutationVariables = Exact<{ userId: Scalars['ID']['input']; @@ -1972,20 +2002,17 @@ export const OAuth2Session_DetailFragmentDoc = new TypedDocumentString(` } } `, {"fragmentName":"OAuth2Session_detail"}) as unknown as TypedDocumentString; -export const UnverifiedEmailAlert_UserFragmentDoc = new TypedDocumentString(` - fragment UnverifiedEmailAlert_user on User { - unverifiedEmails: emails(first: 0, state: PENDING) { - totalCount - } -} - `, {"fragmentName":"UnverifiedEmailAlert_user"}) as unknown as TypedDocumentString; export const UserEmail_EmailFragmentDoc = new TypedDocumentString(` fragment UserEmail_email on UserEmail { id email - confirmedAt } `, {"fragmentName":"UserEmail_email"}) as unknown as TypedDocumentString; +export const UserEmail_SiteConfigFragmentDoc = new TypedDocumentString(` + fragment UserEmail_siteConfig on SiteConfig { + emailChangeAllowed +} + `, {"fragmentName":"UserEmail_siteConfig"}) as unknown as TypedDocumentString; export const UserGreeting_UserFragmentDoc = new TypedDocumentString(` fragment UserGreeting_user on User { id @@ -2000,26 +2027,11 @@ export const UserGreeting_SiteConfigFragmentDoc = new TypedDocumentString(` displayNameChangeAllowed } `, {"fragmentName":"UserGreeting_siteConfig"}) as unknown as TypedDocumentString; -export const UserEmailList_UserFragmentDoc = new TypedDocumentString(` - fragment UserEmailList_user on User { - id - primaryEmail { - id - } -} - `, {"fragmentName":"UserEmailList_user"}) as unknown as TypedDocumentString; -export const UserEmail_SiteConfigFragmentDoc = new TypedDocumentString(` - fragment UserEmail_siteConfig on SiteConfig { - emailChangeAllowed -} - `, {"fragmentName":"UserEmail_siteConfig"}) as unknown as TypedDocumentString; export const UserEmailList_SiteConfigFragmentDoc = new TypedDocumentString(` fragment UserEmailList_siteConfig on SiteConfig { - ...UserEmail_siteConfig -} - fragment UserEmail_siteConfig on SiteConfig { emailChangeAllowed -}`, {"fragmentName":"UserEmailList_siteConfig"}) as unknown as TypedDocumentString; +} + `, {"fragmentName":"UserEmailList_siteConfig"}) as unknown as TypedDocumentString; export const BrowserSessionsOverview_UserFragmentDoc = new TypedDocumentString(` fragment BrowserSessionsOverview_user on User { id @@ -2028,12 +2040,6 @@ export const BrowserSessionsOverview_UserFragmentDoc = new TypedDocumentString(` } } `, {"fragmentName":"BrowserSessionsOverview_user"}) as unknown as TypedDocumentString; -export const UserEmail_VerifyEmailFragmentDoc = new TypedDocumentString(` - fragment UserEmail_verifyEmail on UserEmail { - id - email -} - `, {"fragmentName":"UserEmail_verifyEmail"}) as unknown as TypedDocumentString; export const RecoverPassword_UserRecoveryTicketFragmentDoc = new TypedDocumentString(` fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket { username @@ -2125,19 +2131,6 @@ export const RemoveEmailDocument = new TypedDocumentString(` } } `) as unknown as TypedDocumentString; -export const SetPrimaryEmailDocument = new TypedDocumentString(` - mutation SetPrimaryEmail($id: ID!) { - setPrimaryEmail(input: {userEmailId: $id}) { - status - user { - id - primaryEmail { - id - } - } - } -} - `) as unknown as TypedDocumentString; export const SetDisplayNameDocument = new TypedDocumentString(` mutation SetDisplayName($userId: ID!, $displayName: String) { setDisplayName(input: {userId: $userId, displayName: $displayName}) { @@ -2146,39 +2139,35 @@ export const SetDisplayNameDocument = new TypedDocumentString(` } `) as unknown as TypedDocumentString; export const AddEmailDocument = new TypedDocumentString(` - mutation AddEmail($userId: ID!, $email: String!) { - addEmail(input: {userId: $userId, email: $email}) { + mutation AddEmail($email: String!, $language: String!) { + startEmailAuthentication(input: {email: $email, language: $language}) { status violations - email { + authentication { id - ...UserEmail_email } } } - fragment UserEmail_email on UserEmail { - id - email - confirmedAt -}`) as unknown as TypedDocumentString; + `) as unknown as TypedDocumentString; export const UserEmailListDocument = new TypedDocumentString(` - query UserEmailList($userId: ID!, $first: Int, $after: String, $last: Int, $before: String) { - user(id: $userId) { - id - emails(first: $first, after: $after, last: $last, before: $before) { - edges { - cursor - node { - id - ...UserEmail_email + query UserEmailList($first: Int, $after: String, $last: Int, $before: String) { + viewer { + __typename + ... on User { + emails(first: $first, after: $after, last: $last, before: $before) { + edges { + cursor + node { + ...UserEmail_email + } + } + totalCount + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor } - } - totalCount - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor } } } @@ -2186,61 +2175,15 @@ export const UserEmailListDocument = new TypedDocumentString(` fragment UserEmail_email on UserEmail { id email - confirmedAt }`) as unknown as TypedDocumentString; -export const DoVerifyEmailDocument = new TypedDocumentString(` - mutation DoVerifyEmail($id: ID!, $code: String!) { - verifyEmail(input: {userEmailId: $id, code: $code}) { - status - user { - id - primaryEmail { - id - } - } - email { - id - ...UserEmail_email - } - } -} - fragment UserEmail_email on UserEmail { - id - email - confirmedAt -}`) as unknown as TypedDocumentString; -export const ResendVerificationEmailDocument = new TypedDocumentString(` - mutation ResendVerificationEmail($id: ID!) { - sendVerificationEmail(input: {userEmailId: $id}) { - status - user { - id - primaryEmail { - id - } - } - email { - id - ...UserEmail_email - } - } -} - fragment UserEmail_email on UserEmail { - id - email - confirmedAt -}`) as unknown as TypedDocumentString; export const UserProfileDocument = new TypedDocumentString(` query UserProfile { viewer { __typename ... on User { - id - primaryEmail { - id - ...UserEmail_email + emails(first: 0) { + totalCount } - ...UserEmailList_user } } siteConfig { @@ -2254,22 +2197,11 @@ export const UserProfileDocument = new TypedDocumentString(` fragment PasswordChange_siteConfig on SiteConfig { passwordChangeAllowed } -fragment UserEmail_email on UserEmail { - id - email - confirmedAt -} fragment UserEmail_siteConfig on SiteConfig { emailChangeAllowed } -fragment UserEmailList_user on User { - id - primaryEmail { - id - } -} fragment UserEmailList_siteConfig on SiteConfig { - ...UserEmail_siteConfig + emailChangeAllowed }`) as unknown as TypedDocumentString; export const SessionDetailDocument = new TypedDocumentString(` query SessionDetail($id: ID!) { @@ -2486,7 +2418,6 @@ export const CurrentUserGreetingDocument = new TypedDocumentString(` ... on BrowserSession { id user { - ...UnverifiedEmailAlert_user ...UserGreeting_user } } @@ -2495,12 +2426,7 @@ export const CurrentUserGreetingDocument = new TypedDocumentString(` ...UserGreeting_siteConfig } } - fragment UnverifiedEmailAlert_user on User { - unverifiedEmails: emails(first: 0, state: PENDING) { - totalCount - } -} -fragment UserGreeting_user on User { + fragment UserGreeting_user on User { id matrix { mxid @@ -2546,16 +2472,29 @@ export const DeviceRedirectDocument = new TypedDocumentString(` } } `) as unknown as TypedDocumentString; +export const DoVerifyEmailDocument = new TypedDocumentString(` + mutation DoVerifyEmail($id: ID!, $code: String!) { + completeEmailAuthentication(input: {id: $id, code: $code}) { + status + } +} + `) as unknown as TypedDocumentString; +export const ResendEmailAuthenticationCodeDocument = new TypedDocumentString(` + mutation ResendEmailAuthenticationCode($id: ID!, $language: String!) { + resendEmailAuthenticationCode(input: {id: $id, language: $language}) { + status + } +} + `) as unknown as TypedDocumentString; export const VerifyEmailDocument = new TypedDocumentString(` query VerifyEmail($id: ID!) { - userEmail(id: $id) { - ...UserEmail_verifyEmail + userEmailAuthentication(id: $id) { + id + email + completedAt } } - fragment UserEmail_verifyEmail on UserEmail { - id - email -}`) as unknown as TypedDocumentString; + `) as unknown as TypedDocumentString; export const ChangePasswordDocument = new TypedDocumentString(` mutation ChangePassword($userId: ID!, $oldPassword: String!, $newPassword: String!) { setPassword( @@ -2736,28 +2675,6 @@ export const mockRemoveEmailMutation = (resolver: GraphQLResponseResolver { - * const { id } = variables; - * return HttpResponse.json({ - * data: { setPrimaryEmail } - * }) - * }, - * requestOptions - * ) - */ -export const mockSetPrimaryEmailMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => - graphql.mutation( - 'SetPrimaryEmail', - resolver, - options - ) - /** * @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions)) * @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options)) @@ -2787,9 +2704,9 @@ export const mockSetDisplayNameMutation = (resolver: GraphQLResponseResolver { - * const { userId, email } = variables; + * const { email, language } = variables; * return HttpResponse.json({ - * data: { addEmail } + * data: { startEmailAuthentication } * }) * }, * requestOptions @@ -2809,9 +2726,9 @@ export const mockAddEmailMutation = (resolver: GraphQLResponseResolver { - * const { userId, first, after, last, before } = variables; + * const { first, after, last, before } = variables; * return HttpResponse.json({ - * data: { user } + * data: { viewer } * }) * }, * requestOptions @@ -2824,50 +2741,6 @@ export const mockUserEmailListQuery = (resolver: GraphQLResponseResolver { - * const { id, code } = variables; - * return HttpResponse.json({ - * data: { verifyEmail } - * }) - * }, - * requestOptions - * ) - */ -export const mockDoVerifyEmailMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => - graphql.mutation( - 'DoVerifyEmail', - resolver, - options - ) - -/** - * @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions)) - * @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options)) - * @see https://mswjs.io/docs/basics/response-resolver - * @example - * mockResendVerificationEmailMutation( - * ({ query, variables }) => { - * const { id } = variables; - * return HttpResponse.json({ - * data: { sendVerificationEmail } - * }) - * }, - * requestOptions - * ) - */ -export const mockResendVerificationEmailMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => - graphql.mutation( - 'ResendVerificationEmail', - resolver, - options - ) - /** * @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions)) * @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options)) @@ -3062,6 +2935,50 @@ export const mockDeviceRedirectQuery = (resolver: GraphQLResponseResolver { + * const { id, code } = variables; + * return HttpResponse.json({ + * data: { completeEmailAuthentication } + * }) + * }, + * requestOptions + * ) + */ +export const mockDoVerifyEmailMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => + graphql.mutation( + 'DoVerifyEmail', + resolver, + options + ) + +/** + * @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions)) + * @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options)) + * @see https://mswjs.io/docs/basics/response-resolver + * @example + * mockResendEmailAuthenticationCodeMutation( + * ({ query, variables }) => { + * const { id, language } = variables; + * return HttpResponse.json({ + * data: { resendEmailAuthenticationCode } + * }) + * }, + * requestOptions + * ) + */ +export const mockResendEmailAuthenticationCodeMutation = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => + graphql.mutation( + 'ResendEmailAuthenticationCode', + resolver, + options + ) + /** * @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions)) * @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options)) @@ -3071,7 +2988,7 @@ export const mockDeviceRedirectQuery = (resolver: GraphQLResponseResolver { * const { id } = variables; * return HttpResponse.json({ - * data: { userEmail } + * data: { userEmailAuthentication } * }) * }, * requestOptions diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index bafc24851..2c412c68c 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -25,6 +25,7 @@ import { Route as PasswordRecoveryIndexImport } from './routes/password.recovery import { Route as PasswordChangeIndexImport } from './routes/password.change.index' import { Route as AccountSessionsIndexImport } from './routes/_account.sessions.index' import { Route as EmailsIdVerifyImport } from './routes/emails.$id.verify' +import { Route as EmailsIdInUseImport } from './routes/emails.$id.in-use' import { Route as AccountSessionsBrowsersImport } from './routes/_account.sessions.browsers' import { Route as AccountSessionsIdImport } from './routes/_account.sessions.$id' @@ -127,6 +128,12 @@ const EmailsIdVerifyRoute = EmailsIdVerifyImport.update({ import('./routes/emails.$id.verify.lazy').then((d) => d.Route), ) +const EmailsIdInUseRoute = EmailsIdInUseImport.update({ + id: '/emails/$id/in-use', + path: '/emails/$id/in-use', + getParentRoute: () => rootRoute, +} as any) + const AccountSessionsBrowsersRoute = AccountSessionsBrowsersImport.update({ id: '/sessions/browsers', path: '/sessions/browsers', @@ -217,6 +224,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AccountSessionsBrowsersImport parentRoute: typeof AccountImport } + '/emails/$id/in-use': { + id: '/emails/$id/in-use' + path: '/emails/$id/in-use' + fullPath: '/emails/$id/in-use' + preLoaderRoute: typeof EmailsIdInUseImport + parentRoute: typeof rootRoute + } '/emails/$id/verify': { id: '/emails/$id/verify' path: '/emails/$id/verify' @@ -300,6 +314,7 @@ export interface FileRoutesByFullPath { '/reset-cross-signing/': typeof ResetCrossSigningIndexRoute '/sessions/$id': typeof AccountSessionsIdRoute '/sessions/browsers': typeof AccountSessionsBrowsersRoute + '/emails/$id/in-use': typeof EmailsIdInUseRoute '/emails/$id/verify': typeof EmailsIdVerifyRoute '/password/change/success': typeof PasswordChangeSuccessLazyRoute '/sessions': typeof AccountSessionsIndexRoute @@ -316,6 +331,7 @@ export interface FileRoutesByTo { '/reset-cross-signing': typeof ResetCrossSigningIndexRoute '/sessions/$id': typeof AccountSessionsIdRoute '/sessions/browsers': typeof AccountSessionsBrowsersRoute + '/emails/$id/in-use': typeof EmailsIdInUseRoute '/emails/$id/verify': typeof EmailsIdVerifyRoute '/password/change/success': typeof PasswordChangeSuccessLazyRoute '/sessions': typeof AccountSessionsIndexRoute @@ -335,6 +351,7 @@ export interface FileRoutesById { '/reset-cross-signing/': typeof ResetCrossSigningIndexRoute '/_account/sessions/$id': typeof AccountSessionsIdRoute '/_account/sessions/browsers': typeof AccountSessionsBrowsersRoute + '/emails/$id/in-use': typeof EmailsIdInUseRoute '/emails/$id/verify': typeof EmailsIdVerifyRoute '/password/change/success': typeof PasswordChangeSuccessLazyRoute '/_account/sessions/': typeof AccountSessionsIndexRoute @@ -355,6 +372,7 @@ export interface FileRouteTypes { | '/reset-cross-signing/' | '/sessions/$id' | '/sessions/browsers' + | '/emails/$id/in-use' | '/emails/$id/verify' | '/password/change/success' | '/sessions' @@ -370,6 +388,7 @@ export interface FileRouteTypes { | '/reset-cross-signing' | '/sessions/$id' | '/sessions/browsers' + | '/emails/$id/in-use' | '/emails/$id/verify' | '/password/change/success' | '/sessions' @@ -387,6 +406,7 @@ export interface FileRouteTypes { | '/reset-cross-signing/' | '/_account/sessions/$id' | '/_account/sessions/browsers' + | '/emails/$id/in-use' | '/emails/$id/verify' | '/password/change/success' | '/_account/sessions/' @@ -400,6 +420,7 @@ export interface RootRouteChildren { ResetCrossSigningRoute: typeof ResetCrossSigningRouteWithChildren ClientsIdRoute: typeof ClientsIdRoute DevicesSplatRoute: typeof DevicesSplatRoute + EmailsIdInUseRoute: typeof EmailsIdInUseRoute EmailsIdVerifyRoute: typeof EmailsIdVerifyRoute PasswordChangeSuccessLazyRoute: typeof PasswordChangeSuccessLazyRoute PasswordChangeIndexRoute: typeof PasswordChangeIndexRoute @@ -411,6 +432,7 @@ const rootRouteChildren: RootRouteChildren = { ResetCrossSigningRoute: ResetCrossSigningRouteWithChildren, ClientsIdRoute: ClientsIdRoute, DevicesSplatRoute: DevicesSplatRoute, + EmailsIdInUseRoute: EmailsIdInUseRoute, EmailsIdVerifyRoute: EmailsIdVerifyRoute, PasswordChangeSuccessLazyRoute: PasswordChangeSuccessLazyRoute, PasswordChangeIndexRoute: PasswordChangeIndexRoute, @@ -431,6 +453,7 @@ export const routeTree = rootRoute "/reset-cross-signing", "/clients/$id", "/devices/$", + "/emails/$id/in-use", "/emails/$id/verify", "/password/change/success", "/password/change/", @@ -484,6 +507,9 @@ export const routeTree = rootRoute "filePath": "_account.sessions.browsers.tsx", "parent": "/_account" }, + "/emails/$id/in-use": { + "filePath": "emails.$id.in-use.tsx" + }, "/emails/$id/verify": { "filePath": "emails.$id.verify.tsx" }, diff --git a/frontend/src/routes/_account.index.lazy.tsx b/frontend/src/routes/_account.index.lazy.tsx index fe63e0906..f831f19d3 100644 --- a/frontend/src/routes/_account.index.lazy.tsx +++ b/frontend/src/routes/_account.index.lazy.tsx @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -10,15 +10,12 @@ import { notFound, useNavigate, } from "@tanstack/react-router"; -import { Alert, Separator, Text } from "@vector-im/compound-web"; -import { Suspense } from "react"; +import { Separator, Text } from "@vector-im/compound-web"; import { useTranslation } from "react-i18next"; import AccountManagementPasswordPreview from "../components/AccountManagementPasswordPreview"; import { ButtonLink } from "../components/ButtonLink"; import * as Collapsible from "../components/Collapsible"; -import LoadingSpinner from "../components/LoadingSpinner"; -import UserEmail from "../components/UserEmail"; import AddEmailForm from "../components/UserProfile/AddEmailForm"; import UserEmailList from "../components/UserProfile/UserEmailList"; @@ -43,46 +40,36 @@ function Index(): React.ReactElement { return (
- - {viewer.primaryEmail ? ( - - ) : ( - - )} + {/* Only display this section if the user can add email addresses to their + account *or* if they have any existing email addresses */} + {(siteConfig.emailChangeAllowed || viewer.emails.totalCount > 0) && ( + <> + + - }> - - + {siteConfig.emailChangeAllowed && } + - {siteConfig.emailChangeAllowed && ( - - )} - + + + )} {siteConfig.passwordLoginEnabled && ( <> - + + )} - - {t("frontend.reset_cross_signing.description")} diff --git a/frontend/src/routes/_account.index.tsx b/frontend/src/routes/_account.index.tsx index 2addf320b..9aac0815b 100644 --- a/frontend/src/routes/_account.index.tsx +++ b/frontend/src/routes/_account.index.tsx @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -8,6 +8,7 @@ import { queryOptions } from "@tanstack/react-query"; import { createFileRoute, redirect } from "@tanstack/react-router"; import { zodSearchValidator } from "@tanstack/router-zod-adapter"; import * as z from "zod"; +import { query as userEmailListQuery } from "../components/UserProfile/UserEmailList"; import { graphql } from "../gql"; import { graphqlRequest } from "../graphql"; @@ -16,13 +17,9 @@ const QUERY = graphql(/* GraphQL */ ` viewer { __typename ... on User { - id - primaryEmail { - id - ...UserEmail_email + emails(first: 0) { + totalCount } - - ...UserEmailList_user } } @@ -105,5 +102,9 @@ export const Route = createFileRoute("/_account/")({ } }, - loader: ({ context }) => context.queryClient.ensureQueryData(query), + loader: ({ context }) => + Promise.all([ + context.queryClient.ensureQueryData(userEmailListQuery()), + context.queryClient.ensureQueryData(query), + ]), }); diff --git a/frontend/src/routes/_account.lazy.tsx b/frontend/src/routes/_account.lazy.tsx index d5390874d..3acf066fd 100644 --- a/frontend/src/routes/_account.lazy.tsx +++ b/frontend/src/routes/_account.lazy.tsx @@ -13,7 +13,6 @@ import Layout from "../components/Layout"; import NavBar from "../components/NavBar"; import NavItem from "../components/NavItem"; import EndSessionButton from "../components/Session/EndSessionButton"; -import UnverifiedEmailAlert from "../components/UnverifiedEmailAlert"; import UserGreeting from "../components/UserGreeting"; import { useSuspenseQuery } from "@tanstack/react-query"; @@ -45,8 +44,6 @@ function Account(): React.ReactElement {
- - {t("frontend.nav.settings")} {t("frontend.nav.devices")} diff --git a/frontend/src/routes/_account.tsx b/frontend/src/routes/_account.tsx index 11121bef9..e61b98aaa 100644 --- a/frontend/src/routes/_account.tsx +++ b/frontend/src/routes/_account.tsx @@ -1,4 +1,4 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only @@ -18,7 +18,6 @@ const QUERY = graphql(/* GraphQL */ ` id user { - ...UnverifiedEmailAlert_user ...UserGreeting_user } } diff --git a/frontend/src/routes/emails.$id.in-use.tsx b/frontend/src/routes/emails.$id.in-use.tsx new file mode 100644 index 000000000..c12120f5c --- /dev/null +++ b/frontend/src/routes/emails.$id.in-use.tsx @@ -0,0 +1,56 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +import { useSuspenseQuery } from "@tanstack/react-query"; +import { createFileRoute, notFound, redirect } from "@tanstack/react-router"; +import IconArrowLeft from "@vector-im/compound-design-tokens/assets/web/icons/arrow-left"; +import IconError from "@vector-im/compound-design-tokens/assets/web/icons/error"; +import { useTranslation } from "react-i18next"; +import { ButtonLink } from "../components/ButtonLink"; +import Layout from "../components/Layout"; +import PageHeading from "../components/PageHeading"; +import { query } from "./emails.$id.verify"; + +export const Route = createFileRoute("/emails/$id/in-use")({ + async loader({ context, params }): Promise { + const data = await context.queryClient.ensureQueryData(query(params.id)); + if (!data.userEmailAuthentication) { + throw notFound(); + } + + // If the user has not completed the verification process, it means they got + // to this page by mistake + if (!data.userEmailAuthentication.completedAt) { + throw redirect({ to: "/emails/$id/verify", params }); + } + }, + + component: EmailInUse, +}); + +function EmailInUse(): React.ReactElement { + const { id } = Route.useParams(); + const { + data: { userEmailAuthentication }, + } = useSuspenseQuery(query(id)); + if (!userEmailAuthentication) throw notFound(); + const { t } = useTranslation(); + + return ( + + + + + {t("action.back")} + + + ); +} diff --git a/frontend/src/routes/emails.$id.verify.lazy.tsx b/frontend/src/routes/emails.$id.verify.lazy.tsx index 30d3525a5..71c6f3d6f 100644 --- a/frontend/src/routes/emails.$id.verify.lazy.tsx +++ b/frontend/src/routes/emails.$id.verify.lazy.tsx @@ -1,17 +1,42 @@ -// Copyright 2024 New Vector Ltd. +// Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; import { createLazyFileRoute, notFound } from "@tanstack/react-router"; - +import IconArrowLeft from "@vector-im/compound-design-tokens/assets/web/icons/arrow-left"; +import IconSend from "@vector-im/compound-design-tokens/assets/web/icons/send-solid"; +import { Alert, Button, Form } from "@vector-im/compound-web"; +import { useRef } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { ButtonLink } from "../components/ButtonLink"; import Layout from "../components/Layout"; -import VerifyEmailComponent from "../components/VerifyEmail"; - +import LoadingSpinner from "../components/LoadingSpinner"; +import PageHeading from "../components/PageHeading"; +import { graphql } from "../gql"; +import { graphqlRequest } from "../graphql"; import { query } from "./emails.$id.verify"; +const VERIFY_EMAIL_MUTATION = graphql(/* GraphQL */ ` + mutation DoVerifyEmail($id: ID!, $code: String!) { + completeEmailAuthentication(input: { id: $id, code: $code }) { + status + } + } +`); + +const RESEND_EMAIL_AUTHENTICATION_CODE_MUTATION = graphql(/* GraphQL */ ` + mutation ResendEmailAuthenticationCode($id: ID!, $language: String!) { + resendEmailAuthenticationCode(input: { id: $id, language: $language }) { + status + } + } +`); + export const Route = createLazyFileRoute("/emails/$id/verify")({ component: EmailVerify, }); @@ -19,13 +44,155 @@ export const Route = createLazyFileRoute("/emails/$id/verify")({ function EmailVerify(): React.ReactElement { const { id } = Route.useParams(); const { - data: { userEmail }, + data: { userEmailAuthentication }, } = useSuspenseQuery(query(id)); - if (!userEmail) throw notFound(); + if (!userEmailAuthentication) throw notFound(); + + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const verifyEmail = useMutation({ + mutationFn: ({ id, code }: { id: string; code: string }) => + graphqlRequest({ query: VERIFY_EMAIL_MUTATION, variables: { id, code } }), + async onSuccess(data): Promise { + await queryClient.invalidateQueries({ queryKey: ["userEmails"] }); + await queryClient.invalidateQueries({ queryKey: ["verifyEmail", id] }); + + if (data.completeEmailAuthentication.status === "COMPLETED") { + await navigate({ to: "/" }); + } else if (data.completeEmailAuthentication.status === "IN_USE") { + await navigate({ to: "/emails/$id/in-use", params: { id } }); + } + }, + }); + + const resendEmailAuthenticationCode = useMutation({ + mutationFn: ({ id, language }: { id: string; language: string }) => + graphqlRequest({ + query: RESEND_EMAIL_AUTHENTICATION_CODE_MUTATION, + variables: { id, language }, + }), + onSuccess() { + fieldRef.current?.focus(); + }, + }); + + const fieldRef = useRef(null); + const { t, i18n } = useTranslation(); + + const onFormSubmit = (e: React.FormEvent): void => { + e.preventDefault(); + const form = e.currentTarget; + const formData = new FormData(form); + const code = formData.get("code") as string; + verifyEmail + .mutateAsync({ id: userEmailAuthentication.id, code }) + .finally(() => form.reset()); + }; + + const onResendClick = (): void => { + resendEmailAuthenticationCode.mutate({ + id: userEmailAuthentication.id, + language: i18n.languages[0], + }); + }; + + const emailSent = + resendEmailAuthenticationCode.data?.resendEmailAuthenticationCode.status === + "RESENT"; + const invalidCode = + verifyEmail.data?.completeEmailAuthentication.status === "INVALID_CODE"; + const codeExpired = + verifyEmail.data?.completeEmailAuthentication.status === "CODE_EXPIRED"; + const rateLimited = + verifyEmail.data?.completeEmailAuthentication.status === "RATE_LIMITED"; return ( - + }} + /> + } + /> + + + {emailSent && ( + + {t("frontend.verify_email.email_sent_alert.description")} + + )} + + {invalidCode && ( + + {t("frontend.verify_email.invalid_code_alert.description")} + + )} + + {codeExpired && ( + + {t("frontend.verify_email.code_expired_alert.description")} + + )} + + {rateLimited && ( + + )} + + + {t("frontend.verify_email.code_field_label")} + + + {invalidCode && ( + + {t("frontend.verify_email.code_field_error")} + + )} + + + {t("frontend.verify_email.code_field_wrong_shape")} + + + + + {verifyEmail.isPending && } + {t("action.continue")} + + + + + + {t("action.back")} + + ); } diff --git a/frontend/src/routes/emails.$id.verify.tsx b/frontend/src/routes/emails.$id.verify.tsx index 8ddbb19e3..d1d73121f 100644 --- a/frontend/src/routes/emails.$id.verify.tsx +++ b/frontend/src/routes/emails.$id.verify.tsx @@ -5,14 +5,16 @@ // Please see LICENSE in the repository root for full details. import { queryOptions } from "@tanstack/react-query"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, notFound, redirect } from "@tanstack/react-router"; import { graphql } from "../gql"; import { graphqlRequest } from "../graphql"; const QUERY = graphql(/* GraphQL */ ` query VerifyEmail($id: ID!) { - userEmail(id: $id) { - ...UserEmail_verifyEmail + userEmailAuthentication(id: $id) { + id + email + completedAt } } `); @@ -25,6 +27,14 @@ export const query = (id: string) => }); export const Route = createFileRoute("/emails/$id/verify")({ - loader: ({ context, params }) => - context.queryClient.ensureQueryData(query(params.id)), + async loader({ context, params }): Promise { + const data = await context.queryClient.ensureQueryData(query(params.id)); + if (!data.userEmailAuthentication) { + throw notFound(); + } + + if (data.userEmailAuthentication.completedAt) { + throw redirect({ to: "/" }); + } + }, }); diff --git a/frontend/stories/routes/index.stories.tsx b/frontend/stories/routes/index.stories.tsx index bf539fa86..9156ff0fa 100644 --- a/frontend/stories/routes/index.stories.tsx +++ b/frontend/stories/routes/index.stories.tsx @@ -6,6 +6,18 @@ import type { Meta, StoryObj } from "@storybook/react"; import { expect, userEvent, waitFor, within } from "@storybook/test"; import i18n from "i18next"; +import { type GraphQLHandler, HttpResponse } from "msw"; +import { CONFIG_FRAGMENT as PASSWORD_CHANGE_CONFIG_FRAGMENT } from "../../src/components/AccountManagementPasswordPreview/AccountManagementPasswordPreview"; +import { + CONFIG_FRAGMENT as USER_EMAIL_CONFIG_FRAGMENT, + FRAGMENT as USER_EMAIL_FRAGMENT, +} from "../../src/components/UserEmail/UserEmail"; +import { CONFIG_FRAGMENT as USER_EMAIL_LIST_CONFIG_FRAGMENT } from "../../src/components/UserProfile/UserEmailList"; +import { makeFragmentData } from "../../src/gql"; +import { + mockUserEmailListQuery, + mockUserProfileQuery, +} from "../../src/gql/graphql"; import { App } from "./app"; const meta = { @@ -17,7 +29,207 @@ const meta = { export default meta; type Story = StoryObj; -export const Index: Story = {}; +const userProfileHandler = ({ + emailChangeAllowed, + passwordLoginEnabled, + passwordChangeAllowed, + emailTotalCount, +}: { + emailChangeAllowed: boolean; + passwordLoginEnabled: boolean; + passwordChangeAllowed: boolean; + emailTotalCount: number; +}): GraphQLHandler => + mockUserProfileQuery(() => + HttpResponse.json({ + data: { + viewer: { + __typename: "User", + emails: { + totalCount: emailTotalCount, + }, + }, + + siteConfig: Object.assign( + { + emailChangeAllowed, + passwordLoginEnabled, + }, + makeFragmentData( + { + emailChangeAllowed, + }, + USER_EMAIL_CONFIG_FRAGMENT, + ), + makeFragmentData( + { + emailChangeAllowed, + }, + USER_EMAIL_LIST_CONFIG_FRAGMENT, + ), + makeFragmentData( + { + passwordChangeAllowed, + }, + PASSWORD_CHANGE_CONFIG_FRAGMENT, + ), + ), + }, + }), + ); + +const threeEmailsHandler = mockUserEmailListQuery(() => + HttpResponse.json({ + data: { + viewer: { + __typename: "User", + emails: { + edges: [ + "alice@example.com", + "bob@example.com", + "charlie@example.com", + ].map((email) => ({ + cursor: email, + node: { + ...makeFragmentData( + { + id: email, + email, + }, + USER_EMAIL_FRAGMENT, + ), + }, + })), + totalCount: 3, + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }, + }, + }, + }), +); + +export const Index: Story = { + name: "One email address, email change allowed", +}; + +export const MultipleEmails: Story = { + name: "Multiple email addresses, email change allowed", + parameters: { + msw: { + handlers: [ + userProfileHandler({ + passwordLoginEnabled: true, + passwordChangeAllowed: true, + emailChangeAllowed: true, + emailTotalCount: 3, + }), + threeEmailsHandler, + ], + }, + }, +}; + +export const NoEmails: Story = { + name: "No email address, email change not allowed", + parameters: { + msw: { + handlers: [ + userProfileHandler({ + passwordLoginEnabled: true, + passwordChangeAllowed: true, + emailChangeAllowed: false, + emailTotalCount: 0, + }), + ], + }, + }, +}; + +export const MultipleEmailsNoChange: Story = { + name: "Multiple email addresses, email change not allowed", + parameters: { + msw: { + handlers: [ + userProfileHandler({ + passwordLoginEnabled: true, + passwordChangeAllowed: true, + emailChangeAllowed: false, + emailTotalCount: 3, + }), + threeEmailsHandler, + ], + }, + }, +}; + +export const NoEmailChange: Story = { + name: "One email address, email change not allowed", + parameters: { + msw: { + handlers: [ + userProfileHandler({ + passwordLoginEnabled: true, + passwordChangeAllowed: true, + emailChangeAllowed: false, + emailTotalCount: 1, + }), + ], + }, + }, +}; + +export const NoPasswordChange: Story = { + name: "Password change not allowed", + parameters: { + msw: { + handlers: [ + userProfileHandler({ + passwordLoginEnabled: true, + passwordChangeAllowed: false, + emailChangeAllowed: true, + emailTotalCount: 1, + }), + ], + }, + }, +}; + +export const NoPasswordLogin: Story = { + name: "Password login not allowed", + parameters: { + msw: { + handlers: [ + userProfileHandler({ + passwordLoginEnabled: false, + passwordChangeAllowed: false, + emailChangeAllowed: true, + emailTotalCount: 1, + }), + ], + }, + }, +}; + +export const NoPasswordNoEmailChange: Story = { + name: "No password, no email change", + parameters: { + msw: { + handlers: [ + userProfileHandler({ + passwordLoginEnabled: false, + passwordChangeAllowed: false, + emailChangeAllowed: false, + emailTotalCount: 0, + }), + ], + }, + }, +}; export const EditProfile: Story = { play: async ({ canvasElement, globals }) => { diff --git a/frontend/tailwind.config.cjs b/frontend/tailwind.config.cjs index 5afbbca73..7e4840f00 100644 --- a/frontend/tailwind.config.cjs +++ b/frontend/tailwind.config.cjs @@ -14,6 +14,7 @@ module.exports = { theme: { colors: { white: "#FFFFFF", + primary: "var(--cpd-color-text-primary)", secondary: "var(--cpd-color-text-secondary)", critical: "var(--cpd-color-text-critical-primary)", alert: "#FF5B55", diff --git a/frontend/tests/mocks/handlers.ts b/frontend/tests/mocks/handlers.ts index 35ba2407d..dcba3b5cc 100644 --- a/frontend/tests/mocks/handlers.ts +++ b/frontend/tests/mocks/handlers.ts @@ -1,7 +1,11 @@ +// Copyright 2024, 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + import { HttpResponse } from "msw"; import { CONFIG_FRAGMENT as PASSWORD_CHANGE_CONFIG_FRAGMENT } from "../../src/components/AccountManagementPasswordPreview/AccountManagementPasswordPreview"; import { FRAGMENT as FOOTER_FRAGMENT } from "../../src/components/Footer/Footer"; -import { UNVERIFIED_EMAILS_FRAGMENT } from "../../src/components/UnverifiedEmailAlert/UnverifiedEmailAlert"; import { CONFIG_FRAGMENT as USER_EMAIL_CONFIG_FRAGMENT, FRAGMENT as USER_EMAIL_FRAGMENT, @@ -71,15 +75,6 @@ export const handlers = [ }, USER_GREETING_FRAGMENT, ), - - makeFragmentData( - { - unverifiedEmails: { - totalCount: 0, - }, - }, - UNVERIFIED_EMAILS_FRAGMENT, - ), ), }, @@ -98,17 +93,8 @@ export const handlers = [ data: { viewer: { __typename: "User", - id: "user-id", - primaryEmail: { - id: "primary-email-id", - ...makeFragmentData( - { - id: "primary-email-id", - email: "alice@example.com", - confirmedAt: new Date().toISOString(), - }, - USER_EMAIL_FRAGMENT, - ), + emails: { + totalCount: 1, }, }, @@ -124,12 +110,9 @@ export const handlers = [ USER_EMAIL_CONFIG_FRAGMENT, ), makeFragmentData( - makeFragmentData( - { - emailChangeAllowed: true, - }, - USER_EMAIL_CONFIG_FRAGMENT, - ), + { + emailChangeAllowed: true, + }, USER_EMAIL_LIST_CONFIG_FRAGMENT, ), makeFragmentData( @@ -146,11 +129,24 @@ export const handlers = [ mockUserEmailListQuery(() => HttpResponse.json({ data: { - user: { - id: "user-id", + viewer: { + __typename: "User", emails: { - edges: [], - totalCount: 0, + edges: [ + { + cursor: "primary-email-id", + node: { + ...makeFragmentData( + { + id: "primary-email-id", + email: "alice@example.com", + }, + USER_EMAIL_FRAGMENT, + ), + }, + }, + ], + totalCount: 1, pageInfo: { hasNextPage: false, hasPreviousPage: false, diff --git a/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap b/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap index 7bbdb9738..48fa1e3be 100644 --- a/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap +++ b/frontend/tests/routes/account/__snapshots__/index.test.tsx.snap @@ -2,18 +2,18 @@ exports[`Account home page > display name edit box > displays an error if the display name is invalid 1`] = ` This is what others will see wherever you’re signed in. @@ -236,13 +236,13 @@ exports[`Account home page > display name edit box > lets edit the display name > display name edit box > lets edit the display name Cancel
-
@@ -548,7 +520,7 @@ exports[`Account home page > renders the page 1`] = ` > @@ -556,9 +528,9 @@ exports[`Account home page > renders the page 1`] = ` class="_controls_1h4nb_17" > renders the page 1`] = `
Add an alternative email you can use to access this account. @@ -582,7 +554,7 @@ exports[`Account home page > renders the page 1`] = ` role="separator" />
@@ -594,14 +566,14 @@ exports[`Account home page > renders the page 1`] = ` >

Account password