diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 1190c0edc..f83093375 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -395,10 +395,6 @@ where mas_router::Register::route(), get(self::views::register::get).post(self::views::register::post), ) - .route( - mas_router::PasswordRegister::route(), - 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) diff --git a/crates/handlers/src/views/register/mod.rs b/crates/handlers/src/views/register/mod.rs index f45abfabb..9c02f3662 100644 --- a/crates/handlers/src/views/register/mod.rs +++ b/crates/handlers/src/views/register/mod.rs @@ -1,63 +1,95 @@ // Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. +// Copyright 2021-2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +use std::{str::FromStr, sync::Arc}; + use axum::{ - Form, - extract::State, + extract::{Form, State}, response::{Html, IntoResponse, Redirect, Response}, }; -use axum_extra::extract::Query; +use axum_extra::{extract::Query, typed_header::TypedHeader}; use hyper::StatusCode; +use lettre::Address; use mas_axum_utils::{ GenericError, InternalError, SessionInfoExt, cookies::CookieJar, - csrf::{CsrfExt as _, ProtectedForm}, + csrf::{CsrfExt as _, CsrfToken, ProtectedForm}, +}; +use mas_data_model::{BoxClock, BoxRng, CaptchaConfig, SiteConfig, UpstreamOAuthProvider}; +use mas_i18n::DataLocale; +use mas_matrix::HomeserverConnection; +use mas_policy::Policy; +use mas_router::{UpstreamOAuth2Authorize, UrlBuilder}; +use mas_storage::{ + BoxRepository, RepositoryAccess, + queue::{QueueJobRepositoryExt as _, SendEmailAuthenticationCodeJob}, + upstream_oauth2::UpstreamOAuthProviderRepository as _, + user::{UserEmailRepository, UserRepository}, +}; +use mas_templates::{ + FieldError, FormError, FormState, RegisterContext, RegisterFormField, TemplateContext, + Templates, ToFormState, }; -use mas_data_model::{BoxClock, BoxRng, SiteConfig, UpstreamOAuthProvider}; -use mas_router::{PasswordRegister, UpstreamOAuth2Authorize, UrlBuilder}; -use mas_storage::{BoxRepository, upstream_oauth2::UpstreamOAuthProviderRepository}; -use mas_templates::{RegisterContext, TemplateContext, Templates}; use serde::{Deserialize, Serialize}; use thiserror::Error; use ulid::Ulid; +use zeroize::Zeroizing; use super::shared::OptionalPostAuthAction; use crate::{ - BoundActivityTracker, MetadataCache, PreferredLanguage, impl_from_error_for_route, + BoundActivityTracker, Limiter, MetadataCache, PreferredLanguage, RequesterFingerprint, + captcha::Form as CaptchaForm, + passwords::PasswordManager, upstream_oauth2::{UpstreamSessionContext, authorize::start_authorization}, }; mod cookie; -pub(crate) mod password; pub(crate) mod steps; pub use self::cookie::UserRegistrationSessions as UserRegistrationSessionsCookie; +/// The form was submitted with a provider which doesn't exist or isn't enabled #[derive(Debug, Error)] -pub(crate) enum RouteError { - #[error("Provider not found")] - ProviderNotFound, +#[error("Upstream OAuth 2.0 provider not found")] +struct ProviderNotFound; - #[error(transparent)] - Internal(Box), +/// Every field defaults: the upstream provider buttons submit this same form, +/// and the SSO-only variant of the page has no field but the username at all. +#[derive(Debug, Deserialize, Serialize)] +pub(crate) struct RegisterForm { + #[serde(default)] + username: String, + #[serde(default)] + email: String, + #[serde(default)] + password: String, + #[serde(default)] + password_confirm: String, + #[serde(default)] + accept_terms: String, + + /// Which upstream provider the user chose, if any: each provider has its + /// own submit button + #[serde(default, skip_serializing)] + provider: Option, + + #[serde(flatten, skip_serializing)] + captcha: CaptchaForm, } -impl_from_error_for_route!(mas_axum_utils::csrf::CsrfError); -impl_from_error_for_route!(mas_storage::RepositoryError); -impl_from_error_for_route!(crate::upstream_oauth2::authorize::StartAuthorizationError); +impl ToFormState for RegisterForm { + type Field = RegisterFormField; +} -impl IntoResponse for RouteError { - fn into_response(self) -> Response { - match self { - e @ Self::ProviderNotFound => { - GenericError::new(StatusCode::NOT_FOUND, e).into_response() - } - Self::Internal(e) => InternalError::new(e).into_response(), - } - } +#[derive(Deserialize)] +pub(crate) struct QueryParams { + username: Option, + #[serde(flatten)] + action: OptionalPostAuthAction, } #[tracing::instrument(name = "handlers.views.register.get", skip_all)] @@ -70,7 +102,7 @@ pub(crate) async fn get( State(site_config): State, mut repo: BoxRepository, activity_tracker: BoundActivityTracker, - Query(query): Query, + Query(query): Query, cookie_jar: CookieJar, ) -> Result { let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); @@ -83,98 +115,112 @@ pub(crate) async fn get( .record_browser_session(&clock, &session) .await; - let reply = query.go_next(&url_builder); + let reply = query.action.go_next(&url_builder); return Ok((cookie_jar, reply).into_response()); } let providers = repo.upstream_oauth_provider().all_enabled().await?; - // If password-based login is disabled, and there is only one upstream provider, - // we can directly start an authorization flow - if !site_config.password_registration_enabled && providers.len() == 1 { - let provider = providers.into_iter().next().unwrap(); + if !site_config.password_registration_enabled { + // If password-based registration is disabled, and there is only one upstream + // provider, we can directly start an authorization flow + if providers.len() == 1 { + let provider = providers.into_iter().next().unwrap(); - let mut destination = UpstreamOAuth2Authorize::new(provider.id); + let mut destination = UpstreamOAuth2Authorize::new(provider.id); - if let Some(action) = query.post_auth_action { - destination = destination.and_then(action); + if let Some(action) = query.action.post_auth_action { + destination = destination.and_then(action); + } + + return Ok((cookie_jar, url_builder.redirect(&destination)).into_response()); } - return Ok((cookie_jar, url_builder.redirect(&destination)).into_response()); - } - - // If password-based registration is enabled and there are no upstream - // providers, we redirect to the password registration page - if site_config.password_registration_enabled && providers.is_empty() { - let mut destination = PasswordRegister::default(); - - if let Some(action) = query.post_auth_action { - destination = destination.and_then(action); + // With no way to register at all, there is nothing to show on this page + if providers.is_empty() { + return Ok(( + cookie_jar, + url_builder.redirect(&mas_router::Login::from(query.action.post_auth_action)), + ) + .into_response()); } - - return Ok((cookie_jar, url_builder.redirect(&destination)).into_response()); } - let mut ctx = RegisterContext::new(providers); - let post_action = query - .load_context(&mut repo) - .await - .map_err(InternalError::from_anyhow)?; - if let Some(action) = post_action { - ctx = ctx.with_post_action(action); + let mut ctx = RegisterContext::new( + &url_builder, + providers, + query.action.post_auth_action.as_ref(), + ); + + // If we got a username from the query string, use it to prefill the form + if let Some(username) = query.username { + let mut form_state = FormState::default(); + form_state.set_value(RegisterFormField::Username, Some(username)); + ctx = ctx.with_form_state(form_state); } - let ctx = ctx.with_csrf(csrf_token.form_value()).with_language(locale); - - let content = templates.render_register(&ctx)?; + let content = render( + locale, + ctx, + query.action, + csrf_token, + &mut repo, + &templates, + site_config.captcha.clone(), + ) + .await?; Ok((cookie_jar, Html(content)).into_response()) } -#[derive(Debug, Deserialize, Serialize)] -pub(crate) struct RegisterForm { - #[serde(default)] - username: String, - - /// Which upstream provider the user chose, if any: each provider has its - /// own submit button - #[serde(default)] - provider: Option, - - #[serde(flatten)] - action: OptionalPostAuthAction, -} - #[tracing::instrument(name = "handlers.views.register.post", skip_all)] +#[expect(clippy::too_many_arguments)] pub(crate) async fn post( mut rng: BoxRng, clock: BoxClock, - State(metadata_cache): State, + PreferredLanguage(locale): PreferredLanguage, + State(password_manager): State, + State(templates): State, State(url_builder): State, - State(http_client): State, + State(site_config): State, + State(homeserver): State>, + (State(http_client), State(metadata_cache)): (State, State), + (State(limiter), requester): (State, RequesterFingerprint), + mut policy: Policy, mut repo: BoxRepository, + (user_agent, activity_tracker): ( + Option>, + BoundActivityTracker, + ), + Query(query): Query, cookie_jar: CookieJar, Form(form): Form>, -) -> Result { +) -> Result { + let query = query.action; + let user_agent = user_agent.map(|ua| ua.as_str().to_owned()); + + let ip_address = activity_tracker.ip(); + let form = cookie_jar.verify_form(&clock, form)?; - let username = form.username.trim(); - let post_auth_action = form.action.post_auth_action; // The user chose an upstream provider: start an authorization flow with it, // carrying the username they typed along so that we can prefill it if they // get to choose one when they come back - if let Some(provider_id) = form.provider { - let provider_id: Ulid = provider_id - .parse() - .map_err(|_| RouteError::ProviderNotFound)?; + if let Some(provider_id) = &form.provider { + let provider = match provider_id.parse::() { + Ok(provider_id) => repo + .upstream_oauth_provider() + .lookup(provider_id) + .await? + .filter(UpstreamOAuthProvider::enabled), + Err(_) => None, + }; - let provider = repo - .upstream_oauth_provider() - .lookup(provider_id) - .await? - .filter(UpstreamOAuthProvider::enabled) - .ok_or(RouteError::ProviderNotFound)?; + let Some(provider) = provider else { + return Ok(GenericError::new(StatusCode::NOT_FOUND, ProviderNotFound).into_response()); + }; + let username = form.username.trim(); let context = (!username.is_empty()).then(|| UpstreamSessionContext { username: Some(username.to_owned()), }); @@ -188,7 +234,7 @@ pub(crate) async fn post( &mut repo, cookie_jar, &provider, - post_auth_action, + query.post_auth_action, context, ) .await?; @@ -198,40 +244,379 @@ pub(crate) async fn post( return Ok((cookie_jar, Redirect::to(url.as_str())).into_response()); } - // Else the user wants to register with a password: redirect to that page, - // carrying the username in the query string so that the page can be - // reloaded or bookmarked - let mut destination = PasswordRegister::from(post_auth_action); - if !username.is_empty() { - destination = destination.with_username(username.to_owned()); + if !site_config.password_registration_enabled { + return Ok(StatusCode::METHOD_NOT_ALLOWED.into_response()); } - Ok((cookie_jar, url_builder.redirect(&destination)).into_response()) + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + + // Validate the captcha + // TODO: display a nice error message to the user + let passed_captcha = form + .captcha + .verify( + &activity_tracker, + &http_client, + url_builder.public_hostname(), + site_config.captcha.as_ref(), + ) + .await + .is_ok(); + + let state = form.to_form_state(); + + // The email form is only shown if the server requires it + let email = site_config + .password_registration_email_required + .then_some(form.email); + + // Validate the form + let state = { + let mut state = state; + + if !passed_captcha { + state.add_error_on_form(FormError::Captcha); + } + + let mut homeserver_denied_username = false; + if form.username.is_empty() { + state.add_error_on_field(RegisterFormField::Username, FieldError::Required); + } else if repo.user().exists(&form.username).await? { + // The user already exists in the database + state.add_error_on_field(RegisterFormField::Username, FieldError::Exists); + } else if !homeserver + .is_localpart_available(&form.username) + .await + .map_err(InternalError::from_anyhow)? + { + // The user already exists on the homeserver + tracing::warn!( + username = &form.username, + "Homeserver denied username provided by user" + ); + + // We defer adding the error on the field, until we know whether we had another + // error from the policy, to avoid showing both + homeserver_denied_username = true; + } + + if let Some(email) = &email { + // 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 email.is_empty() { + state.add_error_on_field(RegisterFormField::Email, FieldError::Required); + } else if Address::from_str(email).is_err() { + state.add_error_on_field(RegisterFormField::Email, FieldError::Invalid); + } + } + + if form.password.is_empty() { + state.add_error_on_field(RegisterFormField::Password, FieldError::Required); + } + + if form.password_confirm.is_empty() { + state.add_error_on_field(RegisterFormField::PasswordConfirm, FieldError::Required); + } + + if form.password != form.password_confirm { + state.add_error_on_field(RegisterFormField::Password, FieldError::Unspecified); + state.add_error_on_field( + RegisterFormField::PasswordConfirm, + FieldError::PasswordMismatch, + ); + } + + if !password_manager.is_password_complex_enough(&form.password)? { + // TODO localise this error + state.add_error_on_field( + RegisterFormField::Password, + FieldError::Policy { + code: None, + message: "Password is too weak".to_owned(), + }, + ); + } + + // If the site has terms of service, the user must accept them + if site_config.tos_uri.is_some() && form.accept_terms != "on" { + state.add_error_on_field(RegisterFormField::AcceptTerms, FieldError::Required); + } + + let res = policy + .evaluate_register(mas_policy::RegisterInput { + registration_method: mas_policy::RegistrationMethod::Password, + username: &form.username, + email: email.as_deref(), + requester: mas_policy::Requester { + ip_address: activity_tracker.ip(), + user_agent: user_agent.clone(), + }, + }) + .await?; + + for violation in res.violations { + match violation.field.as_deref() { + Some("email") => state.add_error_on_field( + RegisterFormField::Email, + FieldError::Policy { + code: violation.variant.map(|c| c.as_str()), + message: violation.msg, + }, + ), + Some("username") => { + // If the homeserver denied the username, but we also had an error on the policy + // side, we don't want to show both, so we reset the state here + homeserver_denied_username = false; + state.add_error_on_field( + RegisterFormField::Username, + FieldError::Policy { + code: violation.variant.map(|c| c.as_str()), + message: violation.msg, + }, + ); + } + Some("password") => state.add_error_on_field( + RegisterFormField::Password, + FieldError::Policy { + code: violation.variant.map(|c| c.as_str()), + message: violation.msg, + }, + ), + _ => state.add_error_on_form(FormError::Policy { + code: violation.variant.map(|c| c.as_str()), + message: violation.msg, + }), + } + } + + if homeserver_denied_username { + // XXX: we may want to return different errors like "this username is reserved" + state.add_error_on_field(RegisterFormField::Username, FieldError::Exists); + } + + if state.is_valid() { + // Check the rate limit if we are about to process the form + if let Err(e) = limiter.check_registration(requester) { + tracing::warn!(error = &e as &dyn std::error::Error); + state.add_error_on_form(FormError::RateLimitExceeded); + } + + if let Some(email) = &email + && let Err(e) = limiter.check_email_authentication_email(requester, email) + { + tracing::warn!(error = &e as &dyn std::error::Error); + state.add_error_on_form(FormError::RateLimitExceeded); + } + } + + state + }; + + if !state.is_valid() { + let providers = repo.upstream_oauth_provider().all_enabled().await?; + let ctx = RegisterContext::new(&url_builder, providers, query.post_auth_action.as_ref()) + .with_form_state(state); + + let content = render( + locale, + ctx, + query, + csrf_token, + &mut repo, + &templates, + site_config.captcha.clone(), + ) + .await?; + + return Ok((cookie_jar, Html(content)).into_response()); + } + + 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 registration = if let Some(tos_uri) = &site_config.tos_uri { + repo.user_registration() + .set_terms_url(registration, tos_uri.clone()) + .await? + } else { + registration + }; + + let registration = if let Some(email) = email { + // Create a new user email authentication session + let user_email_authentication = repo + .user_email() + .add_authentication_for_registration(&mut rng, &clock, email, ®istration) + .await?; + + // Schedule a job to verify the email + repo.queue_job() + .schedule_job( + &mut rng, + &clock, + SendEmailAuthenticationCodeJob::new(&user_email_authentication, locale.to_string()), + ) + .await?; + + repo.user_registration() + .set_email_authentication(registration, &user_email_authentication) + .await? + } else { + registration + }; + + // Hash the password + let password = Zeroizing::new(form.password); + let (version, hashed_password) = password_manager + .hash(&mut rng, password) + .await + .map_err(InternalError::from_anyhow)?; + + // Add the password to the registration + let registration = repo + .user_registration() + .set_password(registration, hashed_password, version) + .await?; + + repo.save().await?; + + let cookie_jar = UserRegistrationSessionsCookie::load(&cookie_jar) + .add(®istration) + .save(cookie_jar, &clock); + + Ok(( + cookie_jar, + url_builder.redirect(&mas_router::RegisterFinish::new(registration.id)), + ) + .into_response()) +} + +async fn render( + locale: DataLocale, + ctx: RegisterContext, + action: OptionalPostAuthAction, + csrf_token: CsrfToken, + repo: &mut impl RepositoryAccess, + templates: &Templates, + captcha_config: Option, +) -> Result { + let next = action + .load_context(repo) + .await + .map_err(InternalError::from_anyhow)?; + let ctx = if let Some(next) = next { + ctx.with_post_action(next) + } else { + ctx + }; + let ctx = ctx + .with_captcha(captcha_config) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let content = templates.render_register(&ctx)?; + Ok(content) } #[cfg(test)] mod tests { - use hyper::{Request, StatusCode, header::LOCATION}; + use hyper::{ + Request, StatusCode, + header::{CONTENT_TYPE, LOCATION}, + }; + use mas_axum_utils::csrf::CsrfExt as _; use mas_data_model::{ - Clock, UlidExt, UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, - UpstreamOAuthProviderOnBackchannelLogout, UpstreamOAuthProviderPkceMode, - UpstreamOAuthProviderTokenAuthMethod, + Clock as _, UlidExt as _, UpstreamOAuthProviderClaimsImports, + UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderOnBackchannelLogout, + UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderTokenAuthMethod, }; use mas_iana::jose::JsonWebSignatureAlg; + use mas_router::Route; use mas_storage::{ - RepositoryAccess, - upstream_oauth2::{UpstreamOAuthProviderParams, UpstreamOAuthSessionRepository}, + RepositoryAccess as _, + upstream_oauth2::{UpstreamOAuthProviderParams, UpstreamOAuthSessionRepository as _}, }; use oauth2_types::scope::{OPENID, Scope}; use sqlx::PgPool; use ulid::Ulid; - use crate::test_utils::{CookieHelper, RequestBuilderExt, ResponseExt, TestState, setup}; + use crate::{ + SiteConfig, + test_utils::{ + CookieHelper, RequestBuilderExt, ResponseExt, TestState, setup, test_site_config, + }, + }; - /// Provision an upstream provider which needs no network access to start an - /// authorization flow: discovery is disabled and the authorization endpoint - /// is set explicitly - async fn provider(state: &TestState) -> Ulid { + /// Extract the CSRF token the form island was booted with + fn csrf_token(body: &str) -> &str { + body.split("data-csrf-token=\"") + .nth(1) + .unwrap() + .split('"') + .next() + .unwrap() + } + + /// Mint a CSRF token out of band, for the configurations where the page + /// doesn't render a form to read one from + fn mint_csrf_token(state: &TestState, cookies: &CookieHelper) -> String { + let (csrf_token, cookie_jar) = state.cookie_jar().csrf_token(&state.clock, state.rng()); + cookies.import(cookie_jar); + csrf_token.form_value().to_owned() + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_password_disabled(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_login_enabled: false, + password_registration_enabled: false, + ..test_site_config() + }, + ) + .await + .unwrap(); + let cookies = CookieHelper::new(); + + let request = Request::get(&*mas_router::Register::default().path_and_query()).empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::SEE_OTHER); + response.assert_header_value(LOCATION, "/login"); + + let csrf_token = mint_csrf_token(&state, &cookies); + let request = Request::post(&*mas_router::Register::default().path_and_query()).form( + serde_json::json!({ + "csrf": csrf_token, + "username": "john", + "email": "john@example.com", + "password": "hunter2", + "password_confirm": "hunter2", + }), + ); + let response = state.request(cookies.with_cookies(request)).await; + response.assert_status(StatusCode::METHOD_NOT_ALLOWED); + } + + /// Add an enabled upstream provider with the given human name. Discovery is + /// disabled and the authorization endpoint set explicitly, so that starting + /// a flow with it needs no network access. + async fn add_provider(state: &TestState, human_name: &str) -> Ulid { let mut rng = state.rng(); let mut repo = state.repository().await.unwrap(); let provider = repo @@ -241,22 +626,23 @@ mod tests { &state.clock, UpstreamOAuthProviderParams { issuer: Some("https://upstream.example.com/".to_owned()), - human_name: Some("Upstream Ltd.".to_owned()), + human_name: Some(human_name.to_owned()), brand_name: None, scope: Scope::from_iter([OPENID]), - token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::None, + token_endpoint_auth_method: + UpstreamOAuthProviderTokenAuthMethod::ClientSecretBasic, token_endpoint_signing_alg: None, id_token_signed_response_alg: JsonWebSignatureAlg::Rs256, + fetch_userinfo: false, + userinfo_signed_response_alg: None, client_id: "client".to_owned(), - encrypted_client_secret: None, + encrypted_client_secret: Some("secret".to_owned()), claims_imports: UpstreamOAuthProviderClaimsImports::default(), authorization_endpoint_override: Some( "https://upstream.example.com/authorize".parse().unwrap(), ), token_endpoint_override: None, userinfo_endpoint_override: None, - fetch_userinfo: false, - userinfo_signed_response_alg: None, jwks_uri_override: None, discovery_mode: UpstreamOAuthProviderDiscoveryMode::Disabled, pkce_mode: UpstreamOAuthProviderPkceMode::Disabled, @@ -274,25 +660,71 @@ mod tests { provider.id } - /// Render the registration page, saving its cookies and returning its CSRF - /// token and body - async fn render_page(state: &TestState, cookies: &CookieHelper) -> (String, String) { - let request = cookies.with_cookies(Request::get("/register").empty()); - let response = state.request(request).await; - cookies.save_cookies(&response); - response.assert_status(StatusCode::OK); - - let csrf_token = response - .body() - .split("name=\"csrf\" value=\"") + /// Extract and parse a JSON data attribute the island was booted with + fn json_attribute(body: &str, attribute: &str) -> serde_json::Value { + let raw = body + .split(&format!("{attribute}='")) .nth(1) - .expect("the page should have a CSRF token") - .split('\"') + .unwrap_or_else(|| panic!("no {attribute} attribute in body: {body}")) + .split('\'') .next() - .unwrap() - .to_owned(); + .unwrap(); + serde_json::from_str(raw).unwrap() + } - (csrf_token, response.body().clone()) + /// The registration page hands the upstream providers to the island + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_shows_upstream_providers(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let provider_id = add_provider(&state, "Example").await; + + let request = Request::get(&*mas_router::Register::default().path_and_query()).empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body = response.body(); + assert!( + body.contains(r#"id="register-form""#), + "response body: {body}" + ); + assert_eq!( + json_attribute(body, "data-providers"), + serde_json::json!([{ + "name": "Example", + "brand": null, + "id": provider_id.to_string(), + }]) + ); + } + + /// With password registration disabled, the island still mounts to render + /// the upstream providers, as long as there is more than one + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_sso_only_registration(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_login_enabled: false, + password_registration_enabled: false, + ..test_site_config() + }, + ) + .await + .unwrap(); + add_provider(&state, "First").await; + add_provider(&state, "Second").await; + + let request = Request::get(&*mas_router::Register::default().path_and_query()).empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body = response.body(); + assert!( + body.contains(r#"id="register-form""#), + "response body: {body}" + ); + let providers = json_attribute(body, "data-providers"); + assert_eq!(providers.as_array().unwrap().len(), 2, "{providers:?}"); } /// Decode the upstream sessions cookie set by the given response, if any @@ -310,6 +742,24 @@ mod tests { .expect("the upstream sessions cookie should decode") } + /// Render the registration page, saving its cookies and returning its CSRF + /// token and body + async fn render_page( + state: &TestState, + cookies: &CookieHelper, + path: &str, + ) -> (String, String) { + let request = cookies.with_cookies(Request::get(path).empty()); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + + ( + csrf_token(response.body()).to_owned(), + response.body().clone(), + ) + } + /// Submitting the form with a provider starts an upstream authorization /// flow, carrying the username along in the cookie #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] @@ -318,11 +768,8 @@ mod tests { let state = TestState::from_pool(pool).await.unwrap(); let cookies = CookieHelper::new(); - let provider_id = provider(&state).await; - let (csrf_token, body) = render_page(&state, &cookies).await; - - // The provider is rendered as a submit button of the form - assert!(body.contains(&format!(r#"name="provider" value="{provider_id}""#))); + let provider_id = add_provider(&state, "Example").await; + let (csrf_token, _body) = render_page(&state, &cookies, "/register").await; let request = cookies.with_cookies(Request::post("/register").form(serde_json::json!({ "csrf": csrf_token, @@ -355,46 +802,67 @@ mod tests { assert_eq!(session.provider_id, provider_id); } - /// Submitting the form without a provider redirects to the password - /// registration page, keeping the username and the post-auth action + /// The post-auth action travels in the query string, since the island form + /// posts to the page's own URL #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_post_without_provider(pool: PgPool) { + async fn test_post_with_provider_keeps_post_auth_action(pool: PgPool) { setup(); let state = TestState::from_pool(pool).await.unwrap(); let cookies = CookieHelper::new(); - provider(&state).await; - let (csrf_token, _body) = render_page(&state, &cookies).await; + let provider_id = add_provider(&state, "Example").await; + let path = mas_router::Register::from(Some(mas_router::PostAuthAction::ChangePassword)) + .path_and_query(); + let (csrf_token, _body) = render_page(&state, &cookies, &path).await; - let request = cookies.with_cookies(Request::post("/register").form(serde_json::json!({ + let request = cookies.with_cookies(Request::post(&*path).form(serde_json::json!({ "csrf": csrf_token, "username": "alice", + "provider": provider_id.to_string(), }))); let response = state.request(request).await; response.assert_status(StatusCode::SEE_OTHER); - response.assert_header_value(LOCATION, "/register/password?username=alice"); - // The post-auth action travels in the form, as hidden inputs - let request = cookies.with_cookies(Request::post("/register").form(serde_json::json!({ - "csrf": csrf_token, - "username": "alice", - "kind": "change_password", - }))); - let response = state.request(request).await; - response.assert_status(StatusCode::SEE_OTHER); - response.assert_header_value( - LOCATION, - "/register/password?username=alice&kind=change_password", + let sessions = upstream_sessions(&state, &response) + .expect("the upstream sessions cookie should be set"); + assert_eq!( + sessions[0]["post_auth_action"], + serde_json::json!({ "kind": "change_password" }) ); + } - // An empty username is not carried over + /// The provider buttons work when password registration is disabled, where + /// they are the only thing on the page + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_post_with_provider_sso_only(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_login_enabled: false, + password_registration_enabled: false, + ..test_site_config() + }, + ) + .await + .unwrap(); + let cookies = CookieHelper::new(); + + let provider_id = add_provider(&state, "First").await; + add_provider(&state, "Second").await; + let (csrf_token, _body) = render_page(&state, &cookies, "/register").await; + + // No username field on that page, so the form is just the CSRF token let request = cookies.with_cookies(Request::post("/register").form(serde_json::json!({ "csrf": csrf_token, - "username": " ", + "provider": provider_id.to_string(), }))); let response = state.request(request).await; response.assert_status(StatusCode::SEE_OTHER); - response.assert_header_value(LOCATION, "/register/password"); + + let sessions = upstream_sessions(&state, &response) + .expect("the upstream sessions cookie should be set"); + assert_eq!(sessions[0]["context"], serde_json::Value::Null); } /// A form submitted with an invalid CSRF token is rejected @@ -404,8 +872,8 @@ mod tests { let state = TestState::from_pool(pool).await.unwrap(); let cookies = CookieHelper::new(); - let provider_id = provider(&state).await; - let (csrf_token, _body) = render_page(&state, &cookies).await; + let provider_id = add_provider(&state, "Example").await; + let (csrf_token, _body) = render_page(&state, &cookies, "/register").await; let request = cookies.with_cookies(Request::post("/register").form(serde_json::json!({ "csrf": format!("{csrf_token}invalid"), @@ -424,8 +892,8 @@ mod tests { let state = TestState::from_pool(pool).await.unwrap(); let cookies = CookieHelper::new(); - provider(&state).await; - let (csrf_token, _body) = render_page(&state, &cookies).await; + add_provider(&state, "Example").await; + let (csrf_token, _body) = render_page(&state, &cookies, "/register").await; let unknown = Ulid::from_datetime_with_rng(state.clock.now(), &mut state.rng()); for provider in [unknown.to_string(), "not-a-ulid".to_owned()] { @@ -439,4 +907,521 @@ mod tests { response.assert_status(StatusCode::NOT_FOUND); } } + + /// Test the registration happy path + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = Request::get(&*mas_router::Register::default().path_and_query()).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"); + let csrf_token = csrf_token(response.body()); + + // Submit the registration form + let request = Request::post(&*mas_router::Register::default().path_and_query()).form( + serde_json::json!({ + "csrf": csrf_token, + "username": "john", + "email": "john@example.com", + "password": "correcthorsebatterystaple", + "password_confirm": "correcthorsebatterystaple", + "accept_terms": "on", + }), + ); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::SEE_OTHER); + let location = response.headers().get(LOCATION).unwrap(); + + // 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 + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_password_mismatch(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = Request::get(&*mas_router::Register::default().path_and_query()).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"); + let csrf_token = csrf_token(response.body()); + + // Submit the registration form + let request = Request::post(&*mas_router::Register::default().path_and_query()).form( + serde_json::json!({ + "csrf": csrf_token, + "username": "john", + "email": "john@example.com", + "password": "hunter2", + "password_confirm": "mismatch", + "accept_terms": "on", + }), + ); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + // The form state is handed to the client-side form as JSON + assert!( + response.body().contains("password_mismatch"), + "response body: {}", + response.body() + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_username_too_long(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = Request::get(&*mas_router::Register::default().path_and_query()).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"); + let csrf_token = csrf_token(response.body()); + + // Submit the registration form + let request = Request::post(&*mas_router::Register::default().path_and_query()).form( + serde_json::json!({ + "csrf": csrf_token, + "username": "a".repeat(256), + "email": "john@example.com", + "password": "hunter2", + "password_confirm": "hunter2", + "accept_terms": "on", + }), + ); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + assert!( + response.body().contains("\"code\":\"username-too-long\""), + "response body: {}", + response.body() + ); + } + + /// When the user already exists in the database, it should give an error + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_user_exists(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let mut rng = state.rng(); + let cookies = CookieHelper::new(); + + // Insert a user in the database first + let mut repo = state.repository().await.unwrap(); + repo.user() + .add(&mut rng, &state.clock, "john".to_owned()) + .await + .unwrap(); + repo.save().await.unwrap(); + + // Render the registration page and get the CSRF token + let request = Request::get(&*mas_router::Register::default().path_and_query()).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"); + let csrf_token = csrf_token(response.body()); + + // Submit the registration form + let request = Request::post(&*mas_router::Register::default().path_and_query()).form( + serde_json::json!({ + "csrf": csrf_token, + "username": "john", + "email": "john@example.com", + "password": "hunter2", + "password_confirm": "hunter2", + "accept_terms": "on", + }), + ); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + assert!( + response + .body() + .contains("\"username\":{\"errors\":[{\"kind\":\"exists\"}]"), + "response body: {}", + response.body() + ); + } + + /// When the username is already reserved on the homeserver, it should give + /// an error + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_user_reserved(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = Request::get(&*mas_router::Register::default().path_and_query()).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"); + let csrf_token = csrf_token(response.body()); + + // Reserve "john" on the homeserver + state.homeserver_connection.reserve_localpart("john").await; + + // Submit the registration form + let request = Request::post(&*mas_router::Register::default().path_and_query()).form( + serde_json::json!({ + "csrf": csrf_token, + "username": "john", + "email": "john@example.com", + "password": "hunter2", + "password_confirm": "hunter2", + "accept_terms": "on", + }), + ); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + assert!( + response + .body() + .contains("\"username\":{\"errors\":[{\"kind\":\"exists\"}]"), + "response body: {}", + response.body() + ); + } + + /// Test registration without email when email is not required + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_without_email_when_not_required(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_registration_email_required: false, + ..test_site_config() + }, + ) + .await + .unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = Request::get(&*mas_router::Register::default().path_and_query()).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"); + let csrf_token = csrf_token(response.body()); + + // Submit the registration form without email + let request = Request::post(&*mas_router::Register::default().path_and_query()).form( + serde_json::json!({ + "csrf": csrf_token, + "username": "alice", + "password": "correcthorsebatterystaple", + "password_confirm": "correcthorsebatterystaple", + "accept_terms": "on", + }), + ); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::SEE_OTHER); + let location = response.headers().get(LOCATION).unwrap(); + + // 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, "alice".to_owned()); + assert!(registration.password.is_some()); + // Email authentication should be None when email is not required and not + // provided + assert!(registration.email_authentication_id.is_none()); + } + + /// Test registration with valid email when email is not required + /// (email input is ignored completely when not required) + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_with_email_when_not_required(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_registration_email_required: false, + ..test_site_config() + }, + ) + .await + .unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = Request::get(&*mas_router::Register::default().path_and_query()).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"); + let csrf_token = csrf_token(response.body()); + + // Submit the registration form with valid email + let request = Request::post(&*mas_router::Register::default().path_and_query()).form( + serde_json::json!({ + "csrf": csrf_token, + "username": "charlie", + "email": "charlie@example.com", + "password": "correcthorsebatterystaple", + "password_confirm": "correcthorsebatterystaple", + "accept_terms": "on", + }), + ); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::SEE_OTHER); + let location = response.headers().get(LOCATION).unwrap(); + + // 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, "charlie".to_owned()); + assert!(registration.password.is_some()); + + // Email authentication should be None when email is not required + // (email input is completely ignored in this case) + assert!(registration.email_authentication_id.is_none()); + } + + /// Test registration fails when email is required but not provided + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_fails_without_email_when_required(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_registration_email_required: true, + ..test_site_config() + }, + ) + .await + .unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = Request::get(&*mas_router::Register::default().path_and_query()).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"); + let csrf_token = csrf_token(response.body()); + + // Submit the registration form without email + let request = Request::post(&*mas_router::Register::default().path_and_query()).form( + serde_json::json!({ + "csrf": csrf_token, + "username": "david", + "password": "correcthorsebatterystaple", + "password_confirm": "correcthorsebatterystaple", + "accept_terms": "on", + }), + ); + 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"); + + // Check that the response contains an error on the email field + assert!( + response + .body() + .contains("\"email\":{\"errors\":[{\"kind\":\"required\"}]"), + "response body: {}", + response.body() + ); + + // Ensure no registration was created + let mut repo = state.repository().await.unwrap(); + let user_exists = repo.user().exists("david").await.unwrap(); + assert!(!user_exists); + } + + /// Test registration fails when email is required but empty + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_fails_with_empty_email_when_required(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_registration_email_required: true, + ..test_site_config() + }, + ) + .await + .unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = Request::get(&*mas_router::Register::default().path_and_query()).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"); + let csrf_token = csrf_token(response.body()); + + // Submit the registration form with empty email + let request = Request::post(&*mas_router::Register::default().path_and_query()).form( + serde_json::json!({ + "csrf": csrf_token, + "username": "eve", + "email": "", + "password": "correcthorsebatterystaple", + "password_confirm": "correcthorsebatterystaple", + "accept_terms": "on", + }), + ); + 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"); + + // Check that the response contains an error on the email field + assert!( + response + .body() + .contains("\"email\":{\"errors\":[{\"kind\":\"required\"}]"), + "response body: {}", + response.body() + ); + + // Ensure no registration was created + let mut repo = state.repository().await.unwrap(); + let user_exists = repo.user().exists("eve").await.unwrap(); + assert!(!user_exists); + } + + /// Test registration fails with invalid email when email is required + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_fails_with_invalid_email_when_required(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_registration_email_required: true, + ..test_site_config() + }, + ) + .await + .unwrap(); + let cookies = CookieHelper::new(); + + // Render the registration page and get the CSRF token + let request = Request::get(&*mas_router::Register::default().path_and_query()).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"); + let csrf_token = csrf_token(response.body()); + + // Submit the registration form with invalid email + let request = Request::post(&*mas_router::Register::default().path_and_query()).form( + serde_json::json!({ + "csrf": csrf_token, + "username": "grace", + "email": "not-an-email", + "password": "correcthorsebatterystaple", + "password_confirm": "correcthorsebatterystaple", + "accept_terms": "on", + }), + ); + 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"); + + // Check that the response contains an error on the email field + assert!( + response + .body() + .contains("\"email\":{\"errors\":[{\"kind\":\"invalid\"}]"), + "response body: {}", + response.body() + ); + + // Ensure no registration was created + let mut repo = state.repository().await.unwrap(); + let user_exists = repo.user().exists("grace").await.unwrap(); + assert!(!user_exists); + } } diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs deleted file mode 100644 index c0516d25d..000000000 --- a/crates/handlers/src/views/register/password.rs +++ /dev/null @@ -1,1009 +0,0 @@ -// Copyright 2025, 2026 Element Creations Ltd. -// Copyright 2024, 2025 New Vector Ltd. -// Copyright 2021-2024 The Matrix.org Foundation C.I.C. -// -// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial -// Please see LICENSE files in the repository root for full details. - -use std::{str::FromStr, sync::Arc}; - -use axum::{ - extract::{Form, State}, - response::{Html, IntoResponse, Response}, -}; -use axum_extra::{extract::Query, typed_header::TypedHeader}; -use hyper::StatusCode; -use lettre::Address; -use mas_axum_utils::{ - InternalError, SessionInfoExt, - cookies::CookieJar, - csrf::{CsrfExt, CsrfToken, ProtectedForm}, -}; -use mas_data_model::{BoxClock, BoxRng, CaptchaConfig}; -use mas_i18n::DataLocale; -use mas_matrix::HomeserverConnection; -use mas_policy::Policy; -use mas_router::UrlBuilder; -use mas_storage::{ - BoxRepository, RepositoryAccess, - queue::{QueueJobRepositoryExt as _, SendEmailAuthenticationCodeJob}, - user::{UserEmailRepository, UserRepository}, -}; -use mas_templates::{ - FieldError, FormError, FormState, PasswordRegisterContext, RegisterFormField, TemplateContext, - Templates, ToFormState, -}; -use serde::{Deserialize, Serialize}; -use zeroize::Zeroizing; - -use super::cookie::UserRegistrationSessions; -use crate::{ - BoundActivityTracker, Limiter, PreferredLanguage, RequesterFingerprint, SiteConfig, - captcha::Form as CaptchaForm, passwords::PasswordManager, - views::shared::OptionalPostAuthAction, -}; - -#[derive(Debug, Deserialize, Serialize)] -pub(crate) struct RegisterForm { - username: String, - #[serde(default)] - email: String, - password: String, - password_confirm: String, - #[serde(default)] - accept_terms: String, - - #[serde(flatten, skip_serializing)] - captcha: CaptchaForm, -} - -impl ToFormState for RegisterForm { - type Field = RegisterFormField; -} - -#[derive(Deserialize)] -pub struct QueryParams { - username: Option, - #[serde(flatten)] - action: OptionalPostAuthAction, -} - -#[tracing::instrument(name = "handlers.views.password_register.get", skip_all)] -pub(crate) async fn get( - mut rng: BoxRng, - clock: BoxClock, - PreferredLanguage(locale): PreferredLanguage, - State(templates): State, - State(url_builder): State, - State(site_config): State, - mut repo: BoxRepository, - Query(query): Query, - 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_active_session(&mut repo).await?; - - if maybe_session.is_some() { - let reply = query.action.go_next(&url_builder); - return Ok((cookie_jar, reply).into_response()); - } - - if !site_config.password_registration_enabled { - // If password-based registration is disabled, redirect to the login page here - return Ok(url_builder - .redirect(&mas_router::Login::from(query.action.post_auth_action)) - .into_response()); - } - - let mut ctx = PasswordRegisterContext::new(&url_builder); - - // If we got a username from the query string, use it to prefill the form - if let Some(username) = query.username { - let mut form_state = FormState::default(); - form_state.set_value(RegisterFormField::Username, Some(username)); - ctx = ctx.with_form_state(form_state); - } - - let content = render( - locale, - ctx, - query.action, - csrf_token, - &mut repo, - &templates, - site_config.captcha.clone(), - ) - .await?; - - Ok((cookie_jar, Html(content)).into_response()) -} - -#[tracing::instrument(name = "handlers.views.password_register.post", skip_all)] -#[expect(clippy::too_many_arguments)] -pub(crate) async fn post( - mut rng: BoxRng, - clock: BoxClock, - PreferredLanguage(locale): PreferredLanguage, - State(password_manager): State, - State(templates): State, - State(url_builder): State, - State(site_config): State, - State(homeserver): State>, - State(http_client): State, - (State(limiter), requester): (State, RequesterFingerprint), - mut policy: Policy, - mut repo: BoxRepository, - (user_agent, activity_tracker): ( - Option>, - BoundActivityTracker, - ), - Query(query): Query, - cookie_jar: CookieJar, - Form(form): Form>, -) -> Result { - let user_agent = user_agent.map(|ua| 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()); - } - - let form = cookie_jar.verify_form(&clock, form)?; - - let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); - - // Validate the captcha - // TODO: display a nice error message to the user - let passed_captcha = form - .captcha - .verify( - &activity_tracker, - &http_client, - url_builder.public_hostname(), - site_config.captcha.as_ref(), - ) - .await - .is_ok(); - - let state = form.to_form_state(); - - // The email form is only shown if the server requires it - let email = site_config - .password_registration_email_required - .then_some(form.email); - - // Validate the form - let state = { - let mut state = state; - - if !passed_captcha { - state.add_error_on_form(FormError::Captcha); - } - - let mut homeserver_denied_username = false; - if form.username.is_empty() { - state.add_error_on_field(RegisterFormField::Username, FieldError::Required); - } else if repo.user().exists(&form.username).await? { - // The user already exists in the database - state.add_error_on_field(RegisterFormField::Username, FieldError::Exists); - } else if !homeserver - .is_localpart_available(&form.username) - .await - .map_err(InternalError::from_anyhow)? - { - // The user already exists on the homeserver - tracing::warn!( - username = &form.username, - "Homeserver denied username provided by user" - ); - - // We defer adding the error on the field, until we know whether we had another - // error from the policy, to avoid showing both - homeserver_denied_username = true; - } - - if let Some(email) = &email { - // 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 email.is_empty() { - state.add_error_on_field(RegisterFormField::Email, FieldError::Required); - } else if Address::from_str(email).is_err() { - state.add_error_on_field(RegisterFormField::Email, FieldError::Invalid); - } - } - - if form.password.is_empty() { - state.add_error_on_field(RegisterFormField::Password, FieldError::Required); - } - - if form.password_confirm.is_empty() { - state.add_error_on_field(RegisterFormField::PasswordConfirm, FieldError::Required); - } - - if form.password != form.password_confirm { - state.add_error_on_field(RegisterFormField::Password, FieldError::Unspecified); - state.add_error_on_field( - RegisterFormField::PasswordConfirm, - FieldError::PasswordMismatch, - ); - } - - if !password_manager.is_password_complex_enough(&form.password)? { - // TODO localise this error - state.add_error_on_field( - RegisterFormField::Password, - FieldError::Policy { - code: None, - message: "Password is too weak".to_owned(), - }, - ); - } - - // If the site has terms of service, the user must accept them - if site_config.tos_uri.is_some() && form.accept_terms != "on" { - state.add_error_on_field(RegisterFormField::AcceptTerms, FieldError::Required); - } - - let res = policy - .evaluate_register(mas_policy::RegisterInput { - registration_method: mas_policy::RegistrationMethod::Password, - username: &form.username, - email: email.as_deref(), - requester: mas_policy::Requester { - ip_address: activity_tracker.ip(), - user_agent: user_agent.clone(), - }, - }) - .await?; - - for violation in res.violations { - match violation.field.as_deref() { - Some("email") => state.add_error_on_field( - RegisterFormField::Email, - FieldError::Policy { - code: violation.variant.map(|c| c.as_str()), - message: violation.msg, - }, - ), - Some("username") => { - // If the homeserver denied the username, but we also had an error on the policy - // side, we don't want to show both, so we reset the state here - homeserver_denied_username = false; - state.add_error_on_field( - RegisterFormField::Username, - FieldError::Policy { - code: violation.variant.map(|c| c.as_str()), - message: violation.msg, - }, - ); - } - Some("password") => state.add_error_on_field( - RegisterFormField::Password, - FieldError::Policy { - code: violation.variant.map(|c| c.as_str()), - message: violation.msg, - }, - ), - _ => state.add_error_on_form(FormError::Policy { - code: violation.variant.map(|c| c.as_str()), - message: violation.msg, - }), - } - } - - if homeserver_denied_username { - // XXX: we may want to return different errors like "this username is reserved" - state.add_error_on_field(RegisterFormField::Username, FieldError::Exists); - } - - if state.is_valid() { - // Check the rate limit if we are about to process the form - if let Err(e) = limiter.check_registration(requester) { - tracing::warn!(error = &e as &dyn std::error::Error); - state.add_error_on_form(FormError::RateLimitExceeded); - } - - if let Some(email) = &email - && let Err(e) = limiter.check_email_authentication_email(requester, email) - { - tracing::warn!(error = &e as &dyn std::error::Error); - state.add_error_on_form(FormError::RateLimitExceeded); - } - } - - state - }; - - if !state.is_valid() { - let content = render( - locale, - PasswordRegisterContext::new(&url_builder).with_form_state(state), - query, - csrf_token, - &mut repo, - &templates, - site_config.captcha.clone(), - ) - .await?; - - return Ok((cookie_jar, Html(content)).into_response()); - } - - 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 registration = if let Some(tos_uri) = &site_config.tos_uri { - repo.user_registration() - .set_terms_url(registration, tos_uri.clone()) - .await? - } else { - registration - }; - - let registration = if let Some(email) = email { - // Create a new user email authentication session - let user_email_authentication = repo - .user_email() - .add_authentication_for_registration(&mut rng, &clock, email, ®istration) - .await?; - - // Schedule a job to verify the email - repo.queue_job() - .schedule_job( - &mut rng, - &clock, - SendEmailAuthenticationCodeJob::new(&user_email_authentication, locale.to_string()), - ) - .await?; - - repo.user_registration() - .set_email_authentication(registration, &user_email_authentication) - .await? - } else { - registration - }; - - // Hash the password - let password = Zeroizing::new(form.password); - let (version, hashed_password) = password_manager - .hash(&mut rng, password) - .await - .map_err(InternalError::from_anyhow)?; - - // Add the password to the registration - let registration = repo - .user_registration() - .set_password(registration, hashed_password, version) - .await?; - - repo.save().await?; - - let cookie_jar = UserRegistrationSessions::load(&cookie_jar) - .add(®istration) - .save(cookie_jar, &clock); - - Ok(( - cookie_jar, - url_builder.redirect(&mas_router::RegisterFinish::new(registration.id)), - ) - .into_response()) -} - -async fn render( - locale: DataLocale, - ctx: PasswordRegisterContext, - action: OptionalPostAuthAction, - csrf_token: CsrfToken, - repo: &mut impl RepositoryAccess, - templates: &Templates, - captcha_config: Option, -) -> Result { - let next = action - .load_context(repo) - .await - .map_err(InternalError::from_anyhow)?; - let ctx = if let Some(next) = next { - ctx.with_post_action(next) - } else { - ctx - }; - let ctx = ctx - .with_captcha(captcha_config) - .with_csrf(csrf_token.form_value()) - .with_language(locale); - - let content = templates.render_password_register(&ctx)?; - Ok(content) -} - -#[cfg(test)] -mod tests { - use hyper::{ - Request, StatusCode, - header::{CONTENT_TYPE, LOCATION}, - }; - use mas_router::Route; - use sqlx::PgPool; - - use crate::{ - SiteConfig, - test_utils::{ - CookieHelper, RequestBuilderExt, ResponseExt, TestState, setup, test_site_config, - }, - }; - - /// Extract the CSRF token the form island was booted with - fn csrf_token(body: &str) -> &str { - body.split("data-csrf-token=\"") - .nth(1) - .unwrap() - .split('"') - .next() - .unwrap() - } - - #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_password_disabled(pool: PgPool) { - setup(); - let state = TestState::from_pool_with_site_config( - pool, - SiteConfig { - password_login_enabled: false, - password_registration_enabled: false, - ..test_site_config() - }, - ) - .await - .unwrap(); - - let request = - Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty(); - let response = state.request(request).await; - response.assert_status(StatusCode::SEE_OTHER); - response.assert_header_value(LOCATION, "/login"); - - let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) - .form(serde_json::json!({ - "csrf": "abc", - "username": "john", - "email": "john@example.com", - "password": "hunter2", - "password_confirm": "hunter2", - })); - let response = state.request(request).await; - response.assert_status(StatusCode::METHOD_NOT_ALLOWED); - } - - /// Test the registration happy path - #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_register(pool: PgPool) { - setup(); - let state = TestState::from_pool(pool).await.unwrap(); - let cookies = CookieHelper::new(); - - // Render the registration page and get the CSRF token - let request = - Request::get(&*mas_router::PasswordRegister::default().path_and_query()).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"); - let csrf_token = csrf_token(response.body()); - - // Submit the registration form - let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) - .form(serde_json::json!({ - "csrf": csrf_token, - "username": "john", - "email": "john@example.com", - "password": "correcthorsebatterystaple", - "password_confirm": "correcthorsebatterystaple", - "accept_terms": "on", - })); - let request = cookies.with_cookies(request); - let response = state.request(request).await; - cookies.save_cookies(&response); - response.assert_status(StatusCode::SEE_OTHER); - let location = response.headers().get(LOCATION).unwrap(); - - // 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 - #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_register_password_mismatch(pool: PgPool) { - setup(); - let state = TestState::from_pool(pool).await.unwrap(); - let cookies = CookieHelper::new(); - - // Render the registration page and get the CSRF token - let request = - Request::get(&*mas_router::PasswordRegister::default().path_and_query()).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"); - let csrf_token = csrf_token(response.body()); - - // Submit the registration form - let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) - .form(serde_json::json!({ - "csrf": csrf_token, - "username": "john", - "email": "john@example.com", - "password": "hunter2", - "password_confirm": "mismatch", - "accept_terms": "on", - })); - let request = cookies.with_cookies(request); - let response = state.request(request).await; - cookies.save_cookies(&response); - response.assert_status(StatusCode::OK); - // The form state is handed to the client-side form as JSON - assert!( - response.body().contains("password_mismatch"), - "response body: {}", - response.body() - ); - } - - #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_register_username_too_long(pool: PgPool) { - setup(); - let state = TestState::from_pool(pool).await.unwrap(); - let cookies = CookieHelper::new(); - - // Render the registration page and get the CSRF token - let request = - Request::get(&*mas_router::PasswordRegister::default().path_and_query()).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"); - let csrf_token = csrf_token(response.body()); - - // Submit the registration form - let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) - .form(serde_json::json!({ - "csrf": csrf_token, - "username": "a".repeat(256), - "email": "john@example.com", - "password": "hunter2", - "password_confirm": "hunter2", - "accept_terms": "on", - })); - let request = cookies.with_cookies(request); - let response = state.request(request).await; - cookies.save_cookies(&response); - response.assert_status(StatusCode::OK); - assert!( - response.body().contains("\"code\":\"username-too-long\""), - "response body: {}", - response.body() - ); - } - - /// When the user already exists in the database, it should give an error - #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_register_user_exists(pool: PgPool) { - setup(); - let state = TestState::from_pool(pool).await.unwrap(); - let mut rng = state.rng(); - let cookies = CookieHelper::new(); - - // Insert a user in the database first - let mut repo = state.repository().await.unwrap(); - repo.user() - .add(&mut rng, &state.clock, "john".to_owned()) - .await - .unwrap(); - repo.save().await.unwrap(); - - // Render the registration page and get the CSRF token - let request = - Request::get(&*mas_router::PasswordRegister::default().path_and_query()).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"); - let csrf_token = csrf_token(response.body()); - - // Submit the registration form - let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) - .form(serde_json::json!({ - "csrf": csrf_token, - "username": "john", - "email": "john@example.com", - "password": "hunter2", - "password_confirm": "hunter2", - "accept_terms": "on", - })); - let request = cookies.with_cookies(request); - let response = state.request(request).await; - cookies.save_cookies(&response); - response.assert_status(StatusCode::OK); - assert!( - response - .body() - .contains("\"username\":{\"errors\":[{\"kind\":\"exists\"}]"), - "response body: {}", - response.body() - ); - } - - /// When the username is already reserved on the homeserver, it should give - /// an error - #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_register_user_reserved(pool: PgPool) { - setup(); - let state = TestState::from_pool(pool).await.unwrap(); - let cookies = CookieHelper::new(); - - // Render the registration page and get the CSRF token - let request = - Request::get(&*mas_router::PasswordRegister::default().path_and_query()).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"); - let csrf_token = csrf_token(response.body()); - - // Reserve "john" on the homeserver - state.homeserver_connection.reserve_localpart("john").await; - - // Submit the registration form - let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) - .form(serde_json::json!({ - "csrf": csrf_token, - "username": "john", - "email": "john@example.com", - "password": "hunter2", - "password_confirm": "hunter2", - "accept_terms": "on", - })); - let request = cookies.with_cookies(request); - let response = state.request(request).await; - cookies.save_cookies(&response); - response.assert_status(StatusCode::OK); - assert!( - response - .body() - .contains("\"username\":{\"errors\":[{\"kind\":\"exists\"}]"), - "response body: {}", - response.body() - ); - } - - /// Test registration without email when email is not required - #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_register_without_email_when_not_required(pool: PgPool) { - setup(); - let state = TestState::from_pool_with_site_config( - pool, - SiteConfig { - password_registration_email_required: false, - ..test_site_config() - }, - ) - .await - .unwrap(); - let cookies = CookieHelper::new(); - - // Render the registration page and get the CSRF token - let request = - Request::get(&*mas_router::PasswordRegister::default().path_and_query()).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"); - let csrf_token = csrf_token(response.body()); - - // Submit the registration form without email - let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) - .form(serde_json::json!({ - "csrf": csrf_token, - "username": "alice", - "password": "correcthorsebatterystaple", - "password_confirm": "correcthorsebatterystaple", - "accept_terms": "on", - })); - let request = cookies.with_cookies(request); - let response = state.request(request).await; - cookies.save_cookies(&response); - response.assert_status(StatusCode::SEE_OTHER); - let location = response.headers().get(LOCATION).unwrap(); - - // 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, "alice".to_owned()); - assert!(registration.password.is_some()); - // Email authentication should be None when email is not required and not - // provided - assert!(registration.email_authentication_id.is_none()); - } - - /// Test registration with valid email when email is not required - /// (email input is ignored completely when not required) - #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_register_with_email_when_not_required(pool: PgPool) { - setup(); - let state = TestState::from_pool_with_site_config( - pool, - SiteConfig { - password_registration_email_required: false, - ..test_site_config() - }, - ) - .await - .unwrap(); - let cookies = CookieHelper::new(); - - // Render the registration page and get the CSRF token - let request = - Request::get(&*mas_router::PasswordRegister::default().path_and_query()).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"); - let csrf_token = csrf_token(response.body()); - - // Submit the registration form with valid email - let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) - .form(serde_json::json!({ - "csrf": csrf_token, - "username": "charlie", - "email": "charlie@example.com", - "password": "correcthorsebatterystaple", - "password_confirm": "correcthorsebatterystaple", - "accept_terms": "on", - })); - let request = cookies.with_cookies(request); - let response = state.request(request).await; - cookies.save_cookies(&response); - response.assert_status(StatusCode::SEE_OTHER); - let location = response.headers().get(LOCATION).unwrap(); - - // 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, "charlie".to_owned()); - assert!(registration.password.is_some()); - - // Email authentication should be None when email is not required - // (email input is completely ignored in this case) - assert!(registration.email_authentication_id.is_none()); - } - - /// Test registration fails when email is required but not provided - #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_register_fails_without_email_when_required(pool: PgPool) { - setup(); - let state = TestState::from_pool_with_site_config( - pool, - SiteConfig { - password_registration_email_required: true, - ..test_site_config() - }, - ) - .await - .unwrap(); - let cookies = CookieHelper::new(); - - // Render the registration page and get the CSRF token - let request = - Request::get(&*mas_router::PasswordRegister::default().path_and_query()).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"); - let csrf_token = csrf_token(response.body()); - - // Submit the registration form without email - let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) - .form(serde_json::json!({ - "csrf": csrf_token, - "username": "david", - "password": "correcthorsebatterystaple", - "password_confirm": "correcthorsebatterystaple", - "accept_terms": "on", - })); - 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"); - - // Check that the response contains an error on the email field - assert!( - response - .body() - .contains("\"email\":{\"errors\":[{\"kind\":\"required\"}]"), - "response body: {}", - response.body() - ); - - // Ensure no registration was created - let mut repo = state.repository().await.unwrap(); - let user_exists = repo.user().exists("david").await.unwrap(); - assert!(!user_exists); - } - - /// Test registration fails when email is required but empty - #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_register_fails_with_empty_email_when_required(pool: PgPool) { - setup(); - let state = TestState::from_pool_with_site_config( - pool, - SiteConfig { - password_registration_email_required: true, - ..test_site_config() - }, - ) - .await - .unwrap(); - let cookies = CookieHelper::new(); - - // Render the registration page and get the CSRF token - let request = - Request::get(&*mas_router::PasswordRegister::default().path_and_query()).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"); - let csrf_token = csrf_token(response.body()); - - // Submit the registration form with empty email - let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) - .form(serde_json::json!({ - "csrf": csrf_token, - "username": "eve", - "email": "", - "password": "correcthorsebatterystaple", - "password_confirm": "correcthorsebatterystaple", - "accept_terms": "on", - })); - 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"); - - // Check that the response contains an error on the email field - assert!( - response - .body() - .contains("\"email\":{\"errors\":[{\"kind\":\"required\"}]"), - "response body: {}", - response.body() - ); - - // Ensure no registration was created - let mut repo = state.repository().await.unwrap(); - let user_exists = repo.user().exists("eve").await.unwrap(); - assert!(!user_exists); - } - - /// Test registration fails with invalid email when email is required - #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] - async fn test_register_fails_with_invalid_email_when_required(pool: PgPool) { - setup(); - let state = TestState::from_pool_with_site_config( - pool, - SiteConfig { - password_registration_email_required: true, - ..test_site_config() - }, - ) - .await - .unwrap(); - let cookies = CookieHelper::new(); - - // Render the registration page and get the CSRF token - let request = - Request::get(&*mas_router::PasswordRegister::default().path_and_query()).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"); - let csrf_token = csrf_token(response.body()); - - // Submit the registration form with invalid email - let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query()) - .form(serde_json::json!({ - "csrf": csrf_token, - "username": "grace", - "email": "not-an-email", - "password": "correcthorsebatterystaple", - "password_confirm": "correcthorsebatterystaple", - "accept_terms": "on", - })); - 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"); - - // Check that the response contains an error on the email field - assert!( - response - .body() - .contains("\"email\":{\"errors\":[{\"kind\":\"invalid\"}]"), - "response body: {}", - response.body() - ); - - // Ensure no registration was created - let mut repo = state.repository().await.unwrap(); - let user_exists = repo.user().exists("grace").await.unwrap(); - assert!(!user_exists); - } -} diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index 9bddff410..7178ccd31 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -331,82 +331,6 @@ impl From> for Register { } } -/// `GET|POST /register/password` -#[derive(Default, Debug, Clone, Serialize, Deserialize)] -pub struct PasswordRegister { - username: Option, - - #[serde(flatten)] - post_auth_action: Option, -} - -impl PasswordRegister { - #[must_use] - pub fn and_then(mut self, action: PostAuthAction) -> Self { - self.post_auth_action = Some(action); - self - } - - /// Prefill the form with the given username - #[must_use] - pub fn with_username(mut self, username: String) -> Self { - self.username = Some(username); - self - } - - #[must_use] - pub fn and_continue_grant(mut self, data: Ulid) -> Self { - self.post_auth_action = Some(PostAuthAction::continue_grant(data)); - self - } - - #[must_use] - pub fn and_continue_compat_sso_login(mut self, data: Ulid) -> Self { - self.post_auth_action = Some(PostAuthAction::continue_compat_sso_login(data)); - self - } - - /// Get a reference to the post auth action. - #[must_use] - pub fn post_auth_action(&self) -> Option<&PostAuthAction> { - self.post_auth_action.as_ref() - } - - /// Get a reference to the username chosen by the user. - #[must_use] - pub fn username(&self) -> Option<&str> { - self.username.as_deref() - } - - pub fn go_next(&self, url_builder: &UrlBuilder) -> axum::response::Redirect { - match &self.post_auth_action { - Some(action) => action.go_next(url_builder), - None => url_builder.redirect(&Index), - } - } -} - -impl Route for PasswordRegister { - type Query = Self; - - fn route() -> &'static str { - "/register/password" - } - - fn query(&self) -> Option<&Self::Query> { - Some(self) - } -} - -impl From> for PasswordRegister { - fn from(post_auth_action: Option) -> Self { - Self { - username: None, - post_auth_action, - } - } -} - /// `GET|POST /register/steps/{id}/token` #[derive(Debug, Clone)] pub struct RegisterToken { diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 6f7176876..08ac4db9f 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -31,7 +31,7 @@ use mas_data_model::{ use mas_i18n::DataLocale; use mas_iana::jose::JsonWebSignatureAlg; use mas_policy::{Violation, ViolationVariant}; -use mas_router::{Account, GraphQL, PostAuthAction, UrlBuilder}; +use mas_router::{Account, GraphQL, Login, PostAuthAction, UrlBuilder}; use oauth2_types::scope::{OPENID, Scope}; use rand::{ Rng, SeedableRng, @@ -645,11 +645,44 @@ impl FormField for RegisterFormField { } } -/// Context used by the `register.html` template -#[derive(Serialize, Default)] +/// An upstream OAuth 2.0 provider, as rendered by the registration page island +#[derive(Serialize)] +struct RegisterPageProvider { + name: String, + brand: Option, + /// Submitted back as the `provider` field of the registration form + id: String, +} + +impl RegisterPageProvider { + fn new(provider: UpstreamOAuthProvider) -> Self { + let name = provider + .human_name + .or_else(|| { + provider + .issuer + .as_deref() + .map(|issuer| crate::functions::simplify_url(issuer, true)) + }) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| provider.id.to_string()); + + Self { + name, + brand: provider.brand_name, + id: provider.id.to_string(), + } + } +} + +/// Context used by the `register/index.html` template +#[derive(Serialize)] pub struct RegisterContext { - providers: Vec, + providers: Vec, + login_link: String, + form: FormState, next: Option, + graphql_endpoint: String, } impl TemplateContext for RegisterContext { @@ -661,69 +694,34 @@ impl TemplateContext for RegisterContext { where Self: Sized, { - sample_list(vec![RegisterContext { - providers: Vec::new(), - next: None, - }]) + let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None); + // TODO: samples with errors and with upstream providers + sample_list(vec![RegisterContext::new(&url_builder, Vec::new(), None)]) } } impl RegisterContext { - /// Create a new context with the given upstream providers + /// Create a new context with the given upstream providers, resolving the + /// URLs used by the client-side island from the given [`UrlBuilder`] #[must_use] - pub fn new(providers: Vec) -> Self { - Self { - providers, - next: None, - } - } - - /// Add a post authentication action to the context - #[must_use] - pub fn with_post_action(self, next: PostAuthContext) -> Self { - Self { - next: Some(next), - ..self - } - } -} - -/// Context used by the `password_register.html` template -#[derive(Serialize)] -pub struct PasswordRegisterContext { - form: FormState, - next: Option, - graphql_endpoint: String, -} - -impl TemplateContext for PasswordRegisterContext { - fn sample( - _now: chrono::DateTime, - _rng: &mut R, - _locales: &[DataLocale], - ) -> BTreeMap - where - Self: Sized, - { - let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None); - // TODO: samples with errors - sample_list(vec![PasswordRegisterContext::new(&url_builder)]) - } -} - -impl PasswordRegisterContext { - /// Create a new context, resolving the GraphQL endpoint used by the - /// client-side form from the given [`UrlBuilder`] - #[must_use] - pub fn new(url_builder: &UrlBuilder) -> Self { + pub fn new( + url_builder: &UrlBuilder, + providers: Vec, + post_auth_action: Option<&PostAuthAction>, + ) -> Self { Self { + providers: providers + .into_iter() + .map(RegisterPageProvider::new) + .collect(), + login_link: url_builder.relative_url_for(&Login::from(post_auth_action.cloned())), form: FormState::default(), next: None, graphql_endpoint: url_builder.relative_url_for(&GraphQL), } } - /// Add an error on the registration form + /// Set the state of the registration form #[must_use] pub fn with_form_state(self, form: FormState) -> Self { Self { form, ..self } diff --git a/crates/templates/src/functions.rs b/crates/templates/src/functions.rs index 16515572e..62c3ff543 100644 --- a/crates/templates/src/functions.rs +++ b/crates/templates/src/functions.rs @@ -1,4 +1,4 @@ -// Copyright 2026 Element Creations Ltd. +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // @@ -105,11 +105,11 @@ fn filter_to_params(params: &Value, kwargs: Kwargs) -> Result { } } -/// Filter which simplifies a URL to its domain name for HTTP(S) URLs -fn filter_simplify_url(url: &str, kwargs: Kwargs) -> Result { +/// Simplify a URL to its domain name for HTTP(S) URLs +pub(crate) fn simplify_url(url: &str, keep_path: bool) -> String { // Do nothing if the URL is not valid let Ok(mut url) = Url::from_str(url) else { - return Ok(url.to_owned()); + return url.to_owned(); }; // Always at least remove the query parameters and fragment @@ -118,28 +118,28 @@ fn filter_simplify_url(url: &str, kwargs: Kwargs) -> Result>("keep_path")?.unwrap_or_default(); - kwargs.assert_all_used()?; - // Only return the domain name let Some(domain) = url.domain() else { - return Ok(url.to_string()); + return url.to_string(); }; if keep_path { - Ok(format!( - "{domain}{path}", - domain = domain, - path = url.path(), - )) + format!("{domain}{path}", domain = domain, path = url.path()) } else { - Ok(domain.to_owned()) + domain.to_owned() } } +fn filter_simplify_url(url: &str, kwargs: Kwargs) -> Result { + let keep_path = kwargs.get::>("keep_path")?.unwrap_or_default(); + kwargs.assert_all_used()?; + + Ok(simplify_url(url, keep_path)) +} + /// Filter which computes a hash between 1 and 6 of an input string, identitical /// to compound-web's `useIdColorHash` fn filter_id_color_hash(input: &str) -> u32 { diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 8bd14b294..aa872a4a9 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -42,11 +42,10 @@ pub use self::{ CompatSsoContext, ConsentContext, DeviceConsentContext, DeviceLinkContext, DeviceLinkFormField, DeviceNameContext, EmailRecoveryContext, EmailVerificationContext, EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField, - NotFoundContext, PasswordRegisterContext, PolicyViolationContext, PostAuthContext, - PostAuthContextInner, RecoveryExpiredContext, RecoveryFinishContext, - RecoveryFinishFormField, RecoveryProgressContext, RecoveryStartContext, - RecoveryStartFormField, RegisterContext, RegisterFormField, - RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, + NotFoundContext, PolicyViolationContext, PostAuthContext, PostAuthContextInner, + RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField, + RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext, + RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, RegisterStepsEmailInUseContext, RegisterStepsRegistrationTokenContext, RegisterStepsRegistrationTokenFormField, RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures, @@ -372,10 +371,7 @@ register_templates! { pub fn render_login(WithLanguage>) { "pages/login.html" } /// Render the registration page - pub fn render_register(WithLanguage>) { "pages/register/index.html" } - - /// Render the password registration page - pub fn render_password_register(WithLanguage>>) { "pages/register/password.html" } + pub fn render_register(WithLanguage>>) { "pages/register/index.html" } /// Render the email verification page pub fn render_register_steps_verify_email(WithLanguage>) { "pages/register/steps/verify_email.html" } diff --git a/frontend/locales/en.json b/frontend/locales/en.json index bdd267494..84dbecadc 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -250,9 +250,13 @@ } }, "register": { - "call_to_login": "Already have an account? Sign in", + "call_to_login": "Already have an account?", "captcha_incomplete": "Please complete the CAPTCHA challenge before continuing", "captcha_loading": "The CAPTCHA is still loading, please wait a moment", + "continue_with_email": "Continue with email address", + "continue_with_password": "Continue with password", + "continue_with_provider": "Continue with {{provider}}", + "or_separator": "Or", "password_confirm_label": "Confirm password", "password_label": "Password", "terms_of_service": "I agree to the Terms and Conditions", diff --git a/frontend/src/components/ProviderLogo.tsx b/frontend/src/components/ProviderLogo.tsx new file mode 100644 index 000000000..a8aa62748 --- /dev/null +++ b/frontend/src/components/ProviderLogo.tsx @@ -0,0 +1,198 @@ +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +// Brand logos for upstream OAuth 2.0 providers. Must be kept in sync with +// templates/components/idp_brand.html, which the server-rendered login page +// still uses. +const LOGOS: Record = { + google: ( + + ), + + gitlab: ( + + ), + + twitter: ( + + ), + + github: ( + + ), + + facebook: ( + + ), + + apple: ( + + ), + + discord: ( + + ), +}; + +/** Whether a brand has a logo, i.e. whether the button needs icon spacing. */ +export const hasProviderLogo = (brand: string | null): boolean => + brand !== null && brand in LOGOS; + +const ProviderLogo: React.FC<{ brand: string | null }> = ({ brand }) => + brand !== null && brand in LOGOS ? LOGOS[brand] : null; + +export default ProviderLogo; diff --git a/frontend/src/entrypoints/password-register.tsx b/frontend/src/entrypoints/register.tsx similarity index 52% rename from frontend/src/entrypoints/password-register.tsx rename to frontend/src/entrypoints/register.tsx index df6ccb91a..5aabf23ee 100644 --- a/frontend/src/entrypoints/password-register.tsx +++ b/frontend/src/entrypoints/register.tsx @@ -4,12 +4,13 @@ // Please see LICENSE files in the repository root for full details. import { QueryClient, useQuery } from "@tanstack/react-query"; -import { Button, Form, InlineSpinner } from "@vector-im/compound-web"; -import { useCallback, useRef, useState } from "react"; +import { Form, InlineSpinner } from "@vector-im/compound-web"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import * as v from "valibot"; import { CaptchaSection } from "../components/Captcha"; import PasswordComplexityFeedback from "../components/PasswordComplexityFeedback"; +import ProviderLogo, { hasProviderLogo } from "../components/ProviderLogo"; import { graphql } from "../gql"; import { graphqlRequest } from "../graphql"; import { mountIsland } from "../utils/mountIsland"; @@ -43,12 +44,22 @@ const fieldStateSchema = v.object({ errors: v.array(serverErrorSchema), }); +const providerSchema = v.object({ + /** Display name, already resolved server-side */ + name: v.string(), + /** Raw `brand_name`; only the brands we have a logo for get an icon */ + brand: v.nullable(v.string()), + /** Submitted back as the `provider` field of the form */ + id: v.string(), +}); + +type Provider = v.InferOutput; + // Parsed from the mount node's `data-*` attributes; structured values are // JSON-encoded by the template. const schema = v.object({ csrfToken: v.string(), graphqlEndpoint: v.string(), - loginLink: v.string(), captchaConfig: v.optional( v.pipe( v.string(), @@ -75,6 +86,7 @@ const schema = v.object({ v.string(), v.parseJson(), v.object({ + password_registration: v.boolean(), password_registration_email_required: v.boolean(), minimum_password_complexity: v.number(), }), @@ -87,6 +99,9 @@ const schema = v.object({ fields: v.record(v.string(), fieldStateSchema), }), ), + providers: v.pipe(v.string(), v.parseJson(), v.array(providerSchema)), + /** Href for the "already have an account?" call to action */ + loginLink: v.string(), }); type Data = v.InferOutput; @@ -177,7 +192,9 @@ const UsernameField: React.FC<{ serverName: string; defaultValue: string; serverErrors: ServerError[]; -}> = ({ serverName, defaultValue, serverErrors }) => { + /** Reports the settled verdict, which is what holds the chooser step back */ + onAvailabilityChange: (available: boolean | undefined) => void; +}> = ({ serverName, defaultValue, serverErrors, onAvailabilityChange }) => { const { t } = useTranslation(); const [username, setUsername] = useState(defaultValue); // Until the user edits the field, what the POST came back with is the truth @@ -203,6 +220,11 @@ const UsernameField: React.FC<{ dirty && isUsernameCheckable(normalized) && (isFetching || isDebouncePending); + const availability = settled ? data?.usernameAvailable : undefined; + + useEffect(() => { + onAvailabilityChange(availability?.available); + }, [availability, onAvailabilityChange]); return ( @@ -339,25 +361,171 @@ const PasswordFields: React.FC<{ ); }; +/** Same look as the SSR `field.separator()` macro. */ +const OrSeparator: React.FC = () => { + const { t } = useTranslation(); + return ( +
+
+

{t("frontend.register.or_separator")}

+
+
+ ); +}; + +/** + * Each provider is a submit button of the enclosing form, so that whatever was + * typed in the username field travels with the request which starts the + * upstream flow. + */ +const ProviderButtons: React.FC<{ providers: Provider[] }> = ({ + providers, +}) => { + const { t } = useTranslation(); + return ( + <> + {providers.map((provider) => ( + + ))} + + ); +}; + +const LoginLink: React.FC<{ href: string }> = ({ href }) => { + const { t } = useTranslation(); + return ( + + {t("frontend.register.call_to_login")} + + ); +}; + +/** + * Which half of the flow is on screen: the chooser, where the username is + * picked and the way to continue is chosen, or the details the account needs. + */ +type Step = 1 | 2; + +/** Reads the step back out of a history entry, defaulting to the chooser. */ +const stepFromHistory = (state: unknown): Step => + (state as { registerStep?: unknown } | null)?.registerStep === 2 ? 2 : 1; + const PasswordRegisterForm: React.FC<{ data: Data }> = ({ data }) => { const { t } = useTranslation(); const { fields, errors: formErrors } = data.form; + const { providers } = data; // `null` until the widget has mounted and told us it is ready; `true` right // away when there is no captcha to solve. const [captchaValid, setCaptchaValid] = useState( data.captchaConfig ? null : true, ); const [captchaError, setCaptchaError] = useState(null); + const [usernameAvailable, setUsernameAvailable] = useState< + boolean | undefined + >(undefined); const onCaptchaValidChange = useCallback((valid: boolean) => { setCaptchaValid(valid); if (valid) setCaptchaError(null); }, []); + // With no provider to pick from there is nothing to choose, so the whole form + // is shown at once + const twoStep = providers.length > 0; + + // A render carrying a failed POST means the user has already been past the + // chooser, and putting them back in front of it would hide the errors + const submitted = + formErrors.length > 0 || + Object.values(fields).some( + (field) => field.errors.length > 0 || !!field.value, + ); + + const initialStep: Step = twoStep && !submitted ? 1 : 2; + const [step, setStep] = useState(initialStep); + const details = useRef(null); + + // The chooser used to be a page of its own, so give it a history entry: the + // back button then goes back to it rather than off the page + useEffect(() => { + if (!twoStep) return; + history.replaceState({ registerStep: initialStep }, ""); + const onPopState = (e: PopStateEvent) => setStep(stepFromHistory(e.state)); + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, [twoStep, initialStep]); + + const showDetails = useCallback(() => { + setStep(2); + history.pushState({ registerStep: 2 }, ""); + }, []); + + // Uncovering the details is a navigation of sorts: hand over the first field + // that just appeared, but leave a first render alone — the server errors it + // may carry get the focus instead + const shown = useRef(step); + useEffect(() => { + const revealed = shown.current === 1 && step === 2; + shown.current = step; + if (!revealed) return; + details.current + ?.querySelector("input:not([type='hidden'])") + ?.focus(); + }, [step]); + + // Captcha widgets size themselves to their container, which a hidden one + // doesn't have. Once mounted it stays, so a solved challenge survives a trip + // back to the chooser. + const [mountCaptcha, setMountCaptcha] = useState(initialStep === 2); + useEffect(() => { + if (step === 2) setMountCaptcha(true); + }, [step]); + return ( { + // A provider button submits the form as-is: let the browser POST it, + // carrying the username along to the server, which starts the upstream + // flow from there + const { submitter } = e.nativeEvent as SubmitEvent; + if ( + submitter instanceof HTMLButtonElement && + submitter.name === "provider" + ) { + return; + } + + if (step === 1) { + e.preventDefault(); + // Native validation has vetted the username already; all that is left + // is a settled verdict against it, which the field displays itself. + // Moving the focus into the details blurs the field, which is what + // normalizes whatever was typed in it. + if (usernameAvailable !== false) showDetails(); + return; + } + // Enter-to-submit bypasses the field's onBlur, so normalize here too. // Writing to the DOM is safe: the page navigates away right after. const username = e.currentTarget.elements.namedItem("username"); @@ -397,96 +565,145 @@ const PasswordRegisterForm: React.FC<{ data: Data }> = ({ data }) => { serverName={data.branding.server_name} defaultValue={fields.username?.value ?? ""} serverErrors={fields.username?.errors ?? []} + onAvailabilityChange={setUsernameAvailable} /> - {data.features.password_registration_email_required && ( - - {t("common.email_address")} - - - {t("frontend.errors.invalid_email")} - - - {t("frontend.errors.field_required")} - - {fields.email?.errors.map((error, index) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static - - {fieldErrorMessage(t, error)} - - ))} - + {/* Ahead of the details, so that hitting Enter in the username field + reaches this button and not the disabled final submit */} + {step === 1 && ( + <> + + {data.features.password_registration_email_required + ? t("frontend.register.continue_with_email") + : t("frontend.register.continue_with_password")} + + + + + + )} - - - {data.branding.tos_uri && ( - } - serverInvalid={!!fields.accept_terms?.errors.length} - > - - - ), - }} + {/* Kept mounted across steps so that nothing typed into it is lost; + `disabled` is what keeps the browser from validating, and the password + manager from filling, fields nobody can see */} + + + {/* The sign-in link is part of the chooser; without one it simply sits + under the form, where it has always been */} + {(step === 1 || !twoStep) && } ); }; +const RegisterPage: React.FC<{ data: Data }> = ({ data }) => { + // Without password registration there is nothing to fill in: the providers + // and the sign-in link are the whole page. The form is still what carries the + // provider buttons, so it stays, with nothing in it but the CSRF token. + if (!data.features.password_registration) { + return ( +
+ + + + + ); + } + + return ; +}; + void mountIsland({ - id: "password-register-form", + id: "register-form", schema, queryClient, - children: (data) => , + children: (data) => , }); diff --git a/templates/pages/register/index.html b/templates/pages/register/index.html index e19092b01..62b623419 100644 --- a/templates/pages/register/index.html +++ b/templates/pages/register/index.html @@ -9,60 +9,50 @@ Please see LICENSE files in the repository root for full details. {% extends "base.html" %} -{% from "components/idp_brand.html" import logo %} +{% block head %} + {{ include_asset('src/entrypoints/register.tsx') | indent(4) | safe }} + {# Pre-load the locale data for the current language #} + {{ include_asset('locales/' ~ lang ~ '.json') | indent(4) | safe }} +{% endblock head %} {% block content %} -
-
-
- {{ icon.user_profile_solid() }} -
- -
-

{{ _("mas.register.create_account.heading") }}

- - {% if features.password_registration %} -

{{ _("mas.register.create_account.description") }}

- {% endif %} -
-
- - {% if features.password_registration %} - {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %} - -
- @username:{{ branding.server_name }} -
- {% endcall %} - {% endif %} - -
- - - {% for key, value in next["params"] | default({}) | items %} - - {% endfor %} - - {% if features.password_registration %} - {% if features.password_registration_email_required %} - {{ button.button(text=_("mas.register.continue_with_email")) }} - {% else %} - {{ button.button(text=_("mas.register.continue_with_password")) }} - {% endif %} - {% endif %} - - {% if providers %} - {% for provider in providers %} - {% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %} - - {% endfor %} - {% endif %} - - {% set params = next["params"] | default({}) | to_params(prefix="?") %} - {{ button.link_tertiary(text=_("mas.register.call_to_login"), href="/login" ~ params) }} +
+
+ {{ icon.user_profile_solid() }}
- + +
+

{{ _("mas.register.create_account.heading") }}

+
+
+ +
+

{{ _("mas.loading") }}

+ + {# Rough outline of the form, to keep the page from jumping once it mounts #} + +
+ + + + {% endblock content %} diff --git a/templates/pages/register/password.html b/templates/pages/register/password.html deleted file mode 100644 index 3601c2ce7..000000000 --- a/templates/pages/register/password.html +++ /dev/null @@ -1,64 +0,0 @@ -{# -Copyright 2025, 2026 Element Creations Ltd. -Copyright 2024, 2025 New Vector Ltd. -Copyright 2021-2024 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. --#} - -{% extends "base.html" %} - -{% block head %} - {{ include_asset('src/entrypoints/password-register.tsx') | indent(4) | safe }} - {# Pre-load the locale data for the current language #} - {{ include_asset('locales/' ~ lang ~ '.json') | indent(4) | safe }} -{% endblock head %} - -{% block content %} -
-
- {{ icon.user_profile_solid() }} -
- -
-

{{ _("mas.register.create_account.heading") }}

-
-
- - - {% set params = next["params"] | default({}) | to_params(prefix="?") %} - -
-

{{ _("mas.loading") }}

- - {# Rough outline of the form, to keep the page from jumping once it mounts #} - -
- - - - -{% endblock content %} diff --git a/translations/en.json b/translations/en.json index 310e4e824..065a79720 100644 --- a/translations/en.json +++ b/translations/en.json @@ -95,7 +95,7 @@ }, "username": "Username", "@username": { - "context": "pages/login.html:51:39-59, pages/register/index.html:30:35-55, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:107:39-59" + "context": "pages/login.html:51:39-59, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:107:39-59" } }, "error": { @@ -442,12 +442,12 @@ }, "loading": "Loading…", "@loading": { - "context": "pages/register/password.html:40:26-42", + "context": "pages/register/index.html:38:26-42", "description": "Announced to screen readers while a page loads" }, "loading_failed": "Something went wrong while loading the page. Try reloading it.", "@loading_failed": { - "context": "app.html:28:9-32, pages/register/password.html:51:7-30" + "context": "app.html:28:9-32, pages/register/index.html:49:7-30" }, "login": { "call_to_register": "Don't have an account yet?", @@ -456,7 +456,7 @@ }, "continue_with_provider": "Continue with %(provider)s", "@continue_with_provider": { - "context": "pages/login.html:81:15-67, pages/register/index.html:57:15-67", + "context": "pages/login.html:81:15-67", "description": "Button to log in with an upstream provider" }, "description": "Please sign in to continue:", @@ -659,30 +659,17 @@ "register": { "call_to_login": "Already have an account?", "@call_to_login": { - "context": "pages/register/index.html:63:35-66, pages/register/password.html:61:35-66", "description": "Displayed on the registration page to suggest to log in instead" }, - "continue_with_email": "Continue with email address", - "@continue_with_email": { - "context": "pages/register/index.html:45:32-69" - }, - "continue_with_password": "Continue with password", - "@continue_with_password": { - "context": "pages/register/index.html:47:32-72" - }, "create_account": { - "description": "Choose a username to continue.", - "@description": { - "context": "pages/register/index.html:24:29-73" - }, "heading": "Create an account", "@heading": { - "context": "pages/register/index.html:21:29-69, pages/register/password.html:25:27-67" + "context": "pages/register/index.html:25:27-67" } }, "javascript_required": "JavaScript is required to create an account. Please enable JavaScript in your browser and reload this page.", "@javascript_required": { - "context": "pages/register/password.html:59:36-73" + "context": "pages/register/index.html:56:34-71" }, "terms_of_service": "I agree to the Terms and Conditions", "@terms_of_service": {