diff --git a/crates/handlers/src/views/register/mod.rs b/crates/handlers/src/views/register/mod.rs index 3e9528985..88ca47729 100644 --- a/crates/handlers/src/views/register/mod.rs +++ b/crates/handlers/src/views/register/mod.rs @@ -95,16 +95,7 @@ pub(crate) async fn get( } } - 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 ctx = ctx + let ctx = RegisterContext::new(&url_builder, providers, query.post_auth_action.as_ref()) .with_captcha(site_config.captcha.clone()) .with_csrf(csrf_token.form_value()) .with_language(locale); @@ -330,6 +321,28 @@ mod tests { provider.id } + /// Extract the CSRF token the form island was booted with + pub(super) fn csrf_token(body: &str) -> &str { + body.split("data-csrf-token=\"") + .nth(1) + .expect("the page should have a CSRF token") + .split('"') + .next() + .unwrap() + } + + /// 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) + .unwrap_or_else(|| panic!("no {attribute} attribute in body: {body}")) + .split('\'') + .next() + .unwrap(); + serde_json::from_str(raw).unwrap() + } + /// Render the registration page, saving its cookies and returning its CSRF /// token and body async fn render_page(state: &TestState, cookies: &CookieHelper) -> (String, String) { @@ -338,17 +351,10 @@ mod tests { cookies.save_cookies(&response); response.assert_status(StatusCode::OK); - let csrf_token = response - .body() - .split("name=\"csrf\" value=\"") - .nth(1) - .expect("the page should have a CSRF token") - .split('\"') - .next() - .unwrap() - .to_owned(); - - (csrf_token, response.body().clone()) + ( + csrf_token(response.body()).to_owned(), + response.body().clone(), + ) } /// Mint a CSRF token out of band, for the configurations where the page @@ -374,7 +380,7 @@ mod tests { .expect("the upstream sessions cookie should decode") } - /// With no upstream provider, the page is the password registration form + /// With no upstream provider, the page is the bare form island #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_get_without_provider(pool: PgPool) { setup(); @@ -382,9 +388,22 @@ mod tests { let cookies = CookieHelper::new(); let (_csrf_token, body) = render_page(&state, &cookies).await; - assert!(body.contains(r#"name="username""#)); - assert!(body.contains(r#"name="password""#)); - assert!(body.contains(r#"name="password_confirm""#)); + assert!( + body.contains(r#"id="register-form""#), + "response body: {body}" + ); + assert_eq!( + json_attribute(&body, "data-providers"), + serde_json::json!([]) + ); + assert_eq!( + json_attribute(&body, "data-form"), + serde_json::json!({"errors": [], "fields": {}}) + ); + assert!( + body.contains(r#"data-login-link="/login""#), + "response body: {body}" + ); } /// The page renders with a CAPTCHA configured @@ -407,9 +426,9 @@ mod tests { let cookies = CookieHelper::new(); let (_csrf_token, body) = render_page(&state, &cookies).await; - assert!( - body.contains(r#"data-captcha-site-key="site-key""#), - "response body: {body}" + assert_eq!( + json_attribute(&body, "data-captcha-config")["site_key"], + serde_json::json!("site-key") ); } @@ -433,9 +452,17 @@ mod tests { let second = provider(&state).await; let (_csrf_token, body) = render_page(&state, &cookies).await; - assert!(body.contains(&format!(r#"name="provider" value="{first}""#))); - assert!(body.contains(&format!(r#"name="provider" value="{second}""#))); - assert!(!body.contains(r#"name="password""#)); + assert_eq!( + json_attribute(&body, "data-providers"), + serde_json::json!([ + {"name": "Upstream Ltd.", "brand": null, "id": first.to_string()}, + {"name": "Upstream Ltd.", "brand": null, "id": second.to_string()}, + ]) + ); + assert_eq!( + json_attribute(&body, "data-features")["password_registration"], + serde_json::json!(false) + ); } /// With both password registration and a provider, the page carries the @@ -449,8 +476,18 @@ mod tests { let provider_id = provider(&state).await; let (csrf_token, body) = render_page(&state, &cookies).await; - assert!(body.contains(r#"name="password""#)); - assert!(body.contains(&format!(r#"name="provider" value="{provider_id}""#))); + assert_eq!( + json_attribute(&body, "data-features")["password_registration"], + serde_json::json!(true) + ); + assert_eq!( + json_attribute(&body, "data-providers"), + serde_json::json!([{ + "name": "Upstream Ltd.", + "brand": null, + "id": provider_id.to_string(), + }]) + ); let request = cookies.with_cookies(Request::post("/register").form(serde_json::json!({ "csrf": csrf_token, @@ -462,11 +499,18 @@ mod tests { }))); let response = state.request(request).await; response.assert_status(StatusCode::OK); - assert!(response.body().contains("Password fields don't match")); - assert!( - response - .body() - .contains(&format!(r#"name="provider" value="{provider_id}""#)) + let body = response.body(); + assert_eq!( + json_attribute(body, "data-form")["fields"]["password_confirm"]["errors"], + serde_json::json!([{"kind": "password_mismatch"}]) + ); + assert_eq!( + json_attribute(body, "data-providers"), + serde_json::json!([{ + "name": "Upstream Ltd.", + "brand": null, + "id": provider_id.to_string(), + }]) ); } @@ -532,8 +576,15 @@ mod tests { 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}""#))); + // The island renders the provider as a submit button of the form + assert_eq!( + json_attribute(&body, "data-providers"), + serde_json::json!([{ + "name": "Upstream Ltd.", + "brand": null, + "id": provider_id.to_string(), + }]) + ); let grant = Ulid::from_datetime_with_rng(state.clock.now(), &mut state.rng()); let request = cookies.with_cookies( @@ -635,10 +686,9 @@ mod tests { }))); let response = state.request(request).await; response.assert_status(StatusCode::OK); - assert!( - response.body().contains("Username is too long"), - "response body: {}", - response.body() + assert_eq!( + json_attribute(response.body(), "data-form")["fields"]["username"]["errors"][0]["code"], + serde_json::json!("username-too-long") ); } diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs index 20a7090a5..da9a509f1 100644 --- a/crates/handlers/src/views/register/password.rs +++ b/crates/handlers/src/views/register/password.rs @@ -233,10 +233,11 @@ pub(super) async fn register( let content = render( locale, state, - query, + &query, csrf_token, &mut repo, templates, + url_builder, site_config.captcha.clone(), ) .await?; @@ -318,28 +319,20 @@ pub(super) async fn register( } /// Render the registration page again, with the errors the form collected +#[expect(clippy::too_many_arguments)] async fn render( locale: DataLocale, form_state: FormState, - action: OptionalPostAuthAction, + action: &OptionalPostAuthAction, csrf_token: CsrfToken, repo: &mut BoxRepository, templates: &Templates, + url_builder: &UrlBuilder, captcha_config: Option, ) -> Result { let providers = repo.upstream_oauth_provider().all_enabled().await?; - let ctx = RegisterContext::new(providers).with_form_state(form_state); - - 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 + let ctx = RegisterContext::new(url_builder, providers, action.post_auth_action.as_ref()) + .with_form_state(form_state) .with_captcha(captcha_config) .with_csrf(csrf_token.form_value()) .with_language(locale); @@ -357,6 +350,7 @@ mod tests { use mas_router::Route; use sqlx::PgPool; + use super::super::tests::csrf_token; use crate::{ SiteConfig, test_utils::{ @@ -364,6 +358,18 @@ mod tests { }, }; + /// Extract and parse the form state the island was booted with + fn form_state(body: &str) -> serde_json::Value { + let raw = body + .split("data-form='") + .nth(1) + .unwrap_or_else(|| panic!("no data-form attribute in body: {body}")) + .split('\'') + .next() + .unwrap(); + serde_json::from_str(raw).unwrap() + } + /// The username is trimmed and lowercased before anything checks it #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_register_normalizes_username(pool: PgPool) { @@ -376,14 +382,7 @@ mod tests { let response = state.request(request).await; cookies.save_cookies(&response); response.assert_status(StatusCode::OK); - let csrf_token = response - .body() - .split("name=\"csrf\" value=\"") - .nth(1) - .unwrap() - .split('\"') - .next() - .unwrap(); + let csrf_token = csrf_token(response.body()); let request = Request::post(&*mas_router::Register::default().path_and_query()).form( serde_json::json!({ @@ -427,15 +426,7 @@ mod tests { cookies.save_cookies(&response); response.assert_status(StatusCode::OK); response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); - // Extract the CSRF token from the response body - let csrf_token = response - .body() - .split("name=\"csrf\" value=\"") - .nth(1) - .unwrap() - .split('\"') - .next() - .unwrap(); + let csrf_token = csrf_token(response.body()); // Submit the registration form let request = Request::post(&*mas_router::Register::default().path_and_query()).form( @@ -493,15 +484,7 @@ mod tests { cookies.save_cookies(&response); response.assert_status(StatusCode::OK); response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); - // Extract the CSRF token from the response body - let csrf_token = response - .body() - .split("name=\"csrf\" value=\"") - .nth(1) - .unwrap() - .split('\"') - .next() - .unwrap(); + let csrf_token = csrf_token(response.body()); // Submit the registration form let request = Request::post(&*mas_router::Register::default().path_and_query()).form( @@ -518,7 +501,10 @@ mod tests { let response = state.request(request).await; cookies.save_cookies(&response); response.assert_status(StatusCode::OK); - assert!(response.body().contains("Password fields don't match")); + assert_eq!( + form_state(response.body())["fields"]["password_confirm"]["errors"], + serde_json::json!([{"kind": "password_mismatch"}]) + ); } #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] @@ -534,15 +520,7 @@ mod tests { cookies.save_cookies(&response); response.assert_status(StatusCode::OK); response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); - // Extract the CSRF token from the response body - let csrf_token = response - .body() - .split("name=\"csrf\" value=\"") - .nth(1) - .unwrap() - .split('\"') - .next() - .unwrap(); + let csrf_token = csrf_token(response.body()); // Submit the registration form let request = Request::post(&*mas_router::Register::default().path_and_query()).form( @@ -559,10 +537,9 @@ mod tests { let response = state.request(request).await; cookies.save_cookies(&response); response.assert_status(StatusCode::OK); - assert!( - response.body().contains("Username is too long"), - "response body: {}", - response.body() + assert_eq!( + form_state(response.body())["fields"]["username"]["errors"][0]["code"], + serde_json::json!("username-too-long") ); } @@ -589,15 +566,7 @@ mod tests { cookies.save_cookies(&response); response.assert_status(StatusCode::OK); response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); - // Extract the CSRF token from the response body - let csrf_token = response - .body() - .split("name=\"csrf\" value=\"") - .nth(1) - .unwrap() - .split('\"') - .next() - .unwrap(); + let csrf_token = csrf_token(response.body()); // Submit the registration form let request = Request::post(&*mas_router::Register::default().path_and_query()).form( @@ -614,7 +583,10 @@ mod tests { let response = state.request(request).await; cookies.save_cookies(&response); response.assert_status(StatusCode::OK); - assert!(response.body().contains("This username is already taken")); + assert_eq!( + form_state(response.body())["fields"]["username"]["errors"], + serde_json::json!([{"kind": "exists"}]) + ); } /// When the username is already reserved on the homeserver, it should give @@ -632,15 +604,7 @@ mod tests { cookies.save_cookies(&response); response.assert_status(StatusCode::OK); response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); - // Extract the CSRF token from the response body - let csrf_token = response - .body() - .split("name=\"csrf\" value=\"") - .nth(1) - .unwrap() - .split('\"') - .next() - .unwrap(); + let csrf_token = csrf_token(response.body()); // Reserve "john" on the homeserver state.homeserver_connection.reserve_localpart("john").await; @@ -660,7 +624,10 @@ mod tests { let response = state.request(request).await; cookies.save_cookies(&response); response.assert_status(StatusCode::OK); - assert!(response.body().contains("This username is already taken")); + assert_eq!( + form_state(response.body())["fields"]["username"]["errors"], + serde_json::json!([{"kind": "exists"}]) + ); } /// Test registration without email when email is not required @@ -685,15 +652,7 @@ mod tests { cookies.save_cookies(&response); response.assert_status(StatusCode::OK); response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); - // Extract the CSRF token from the response body - let csrf_token = response - .body() - .split("name=\"csrf\" value=\"") - .nth(1) - .unwrap() - .split('\"') - .next() - .unwrap(); + 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( @@ -754,15 +713,7 @@ mod tests { cookies.save_cookies(&response); response.assert_status(StatusCode::OK); response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); - // Extract the CSRF token from the response body - let csrf_token = response - .body() - .split("name=\"csrf\" value=\"") - .nth(1) - .unwrap() - .split('\"') - .next() - .unwrap(); + 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( @@ -824,15 +775,7 @@ mod tests { cookies.save_cookies(&response); response.assert_status(StatusCode::OK); response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); - // Extract the CSRF token from the response body - let csrf_token = response - .body() - .split("name=\"csrf\" value=\"") - .nth(1) - .unwrap() - .split('\"') - .next() - .unwrap(); + 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( @@ -850,9 +793,10 @@ mod tests { response.assert_status(StatusCode::OK); response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); - // Check that the response contains an error about the email field - let body = response.body(); - assert!(body.contains("email") || body.contains("Email")); + assert_eq!( + form_state(response.body())["fields"]["email"]["errors"], + serde_json::json!([{"kind": "required"}]) + ); // Ensure no registration was created let mut repo = state.repository().await.unwrap(); @@ -882,15 +826,7 @@ mod tests { cookies.save_cookies(&response); response.assert_status(StatusCode::OK); response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); - // Extract the CSRF token from the response body - let csrf_token = response - .body() - .split("name=\"csrf\" value=\"") - .nth(1) - .unwrap() - .split('\"') - .next() - .unwrap(); + 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( @@ -909,9 +845,10 @@ mod tests { response.assert_status(StatusCode::OK); response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); - // Check that the response contains an error about the email field - let body = response.body(); - assert!(body.contains("email") || body.contains("Email")); + assert_eq!( + form_state(response.body())["fields"]["email"]["errors"], + serde_json::json!([{"kind": "required"}]) + ); // Ensure no registration was created let mut repo = state.repository().await.unwrap(); @@ -941,15 +878,7 @@ mod tests { cookies.save_cookies(&response); response.assert_status(StatusCode::OK); response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); - // Extract the CSRF token from the response body - let csrf_token = response - .body() - .split("name=\"csrf\" value=\"") - .nth(1) - .unwrap() - .split('\"') - .next() - .unwrap(); + 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( @@ -968,9 +897,10 @@ mod tests { response.assert_status(StatusCode::OK); response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); - // Check that the response contains an error about the email field - let body = response.body(); - assert!(body.contains("email") || body.contains("Email")); + assert_eq!( + form_state(response.body())["fields"]["email"]["errors"], + serde_json::json!([{"kind": "invalid"}]) + ); // Ensure no registration was created let mut repo = state.repository().await.unwrap(); diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 45e14b035..6a4699076 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::{ requests::ResponseMode, scope::{OPENID, Scope}, @@ -648,12 +648,42 @@ 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, } impl TemplateContext for RegisterContext { @@ -665,39 +695,36 @@ impl TemplateContext for RegisterContext { where Self: Sized, { - // TODO: samples with errors - sample_list(vec![RegisterContext { - providers: Vec::new(), - form: FormState::default(), - 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 { + pub fn new( + url_builder: &UrlBuilder, + providers: Vec, + post_auth_action: Option<&PostAuthAction>, + ) -> Self { Self { - providers, - ..Self::default() + providers: providers + .into_iter() + .map(RegisterPageProvider::new) + .collect(), + login_link: url_builder.relative_url_for(&Login::from(post_auth_action.cloned())), + form: FormState::default(), } } - /// Add the state of the registration form to the context + /// Set the state of the registration form #[must_use] pub fn with_form_state(self, form: FormState) -> Self { Self { form, ..self } } - - /// 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 `consent.html` template diff --git a/crates/templates/src/context/ext.rs b/crates/templates/src/context/ext.rs index 679ad91a7..620f0abe4 100644 --- a/crates/templates/src/context/ext.rs +++ b/crates/templates/src/context/ext.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // @@ -49,6 +50,7 @@ impl SiteConfigExt for SiteConfig { password_login: self.password_login_enabled, account_recovery: self.account_recovery_allowed, login_with_email_allowed: self.login_with_email_allowed, + minimum_password_complexity: self.minimum_password_complexity, } } } diff --git a/crates/templates/src/context/features.rs b/crates/templates/src/context/features.rs index 9870b5169..0925e7355 100644 --- a/crates/templates/src/context/features.rs +++ b/crates/templates/src/context/features.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // @@ -29,6 +30,9 @@ pub struct SiteFeatures { /// Whether users can log in with their email address. pub login_with_email_allowed: bool, + + /// The minimum password complexity score required for new passwords. + pub minimum_password_complexity: u8, } impl Object for SiteFeatures { @@ -41,6 +45,7 @@ impl Object for SiteFeatures { "password_login" => Some(Value::from(self.password_login)), "account_recovery" => Some(Value::from(self.account_recovery)), "login_with_email_allowed" => Some(Value::from(self.login_with_email_allowed)), + "minimum_password_complexity" => Some(Value::from(self.minimum_password_complexity)), _ => None, } } @@ -52,6 +57,7 @@ impl Object for SiteFeatures { "password_login", "account_recovery", "login_with_email_allowed", + "minimum_password_complexity", ]) } } diff --git a/crates/templates/src/functions.rs b/crates/templates/src/functions.rs index 05bc0f313..ea28b3751 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 3d0aeedb1..136945f2c 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -513,6 +513,7 @@ mod tests { password_registration_email_required: true, account_recovery: true, login_with_email_allowed: true, + minimum_password_complexity: 3, }; let vite_manifest_path = Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend/dist/manifest.json"); diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 163acb2d3..17de00430 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -26,12 +26,14 @@ }, "common": { "e2ee": "End-to-end encryption", + "email_address": "Email address", "loading": "Loading…", "next": "Next", "password": "Password", "previous": "Previous", "saved": "Saved", - "saving": "Saving…" + "saving": "Saving…", + "username": "Username" }, "frontend": { "account": { @@ -113,8 +115,25 @@ "title": "Something went wrong" }, "errors": { + "captcha": "CAPTCHA verification failed, please try again", + "email_banned": "Email is banned by the server policy", + "email_domain_banned": "Email domain is banned by the server policy", + "email_domain_not_allowed": "Email domain is not allowed by the server policy", + "email_not_allowed": "Email is not allowed by the server policy", + "field_invalid": "This field is invalid", "field_required": "This field is required", - "rate_limit_exceeded": "You've made too many requests in a short period. Please wait a few minutes and try again." + "invalid_email": "Please enter a valid email address", + "password_mismatch": "Password fields don't match", + "rate_limit_exceeded": "You've made too many requests in a short period. Please wait a few minutes and try again.", + "unspecified": "Something went wrong. Please try again.", + "username_all_numeric": "Username cannot consist solely of numbers", + "username_banned": "Username is banned by the server policy", + "username_invalid": "Username can only contain lowercase letters, numbers, and the characters . _ - + / =", + "username_invalid_chars": "Username contains invalid characters. Use lowercase letters, numbers, dashes and underscores only.", + "username_not_allowed": "Username is not allowed by the server policy", + "username_taken": "This username is already taken", + "username_too_long": "Username is too long", + "username_too_short": "Username is too short" }, "last_active": { "active_date": "Active {{relativeDate}}", @@ -226,6 +245,18 @@ "word_by_itself": "Single words are easy to guess." } }, + "register": { + "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" + }, "reset_cross_signing": { "cancelled": { "description_1": "You can close this window and go back to the app to continue.", diff --git a/frontend/src/components/OrSeparator/OrSeparator.tsx b/frontend/src/components/OrSeparator/OrSeparator.tsx new file mode 100644 index 000000000..7c46996ac --- /dev/null +++ b/frontend/src/components/OrSeparator/OrSeparator.tsx @@ -0,0 +1,17 @@ +// 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. + +import styles from "./OrSeparator.module.css"; + +/** A horizontal rule with a label in the middle, typically "Or". */ +const OrSeparator: React.FC = ({ children }) => ( +
+
+

{children}

+
+
+); + +export default OrSeparator; diff --git a/frontend/src/components/OrSeparator/index.ts b/frontend/src/components/OrSeparator/index.ts new file mode 100644 index 000000000..6a19141ce --- /dev/null +++ b/frontend/src/components/OrSeparator/index.ts @@ -0,0 +1,6 @@ +// 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. + +export { default } from "./OrSeparator"; diff --git a/frontend/src/components/ProviderLogo.tsx b/frontend/src/components/ProviderLogo.tsx new file mode 100644 index 000000000..fd61924c0 --- /dev/null +++ b/frontend/src/components/ProviderLogo.tsx @@ -0,0 +1,204 @@ +// 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. +type Logo = React.FC>; + +const GoogleLogo: Logo = (props) => ( + +); + +const GitLabLogo: Logo = (props) => ( + +); + +const TwitterLogo: Logo = (props) => ( + +); + +const GitHubLogo: Logo = (props) => ( + +); + +const FacebookLogo: Logo = (props) => ( + +); + +const AppleLogo: Logo = (props) => ( + +); + +const DiscordLogo: Logo = (props) => ( + +); + +/** The logo of a brand, sized and labelled by the caller. */ +export const providerLogo = (brand: string | null): Logo | undefined => { + switch (brand) { + case "google": + return GoogleLogo; + case "gitlab": + return GitLabLogo; + case "twitter": + return TwitterLogo; + case "github": + return GitHubLogo; + case "facebook": + return FacebookLogo; + case "apple": + return AppleLogo; + case "discord": + return DiscordLogo; + default: + return undefined; + } +}; diff --git a/frontend/src/components/RegisterForm/RegisterFields.tsx b/frontend/src/components/RegisterForm/RegisterFields.tsx new file mode 100644 index 000000000..f5427726a --- /dev/null +++ b/frontend/src/components/RegisterForm/RegisterFields.tsx @@ -0,0 +1,154 @@ +// 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. + +import { Form } from "@vector-im/compound-web"; +import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + fieldErrorMessage, + normalizeUsername, + type ServerError, + VALID_LOCALPART_RE, +} from "../../utils/registration"; +import FieldMessageSlot from "../FieldMessageSlot"; +import PasswordComplexityFeedback from "../PasswordComplexityFeedback"; + +export const UsernameField: React.FC<{ + serverName: string; + defaultValue: string; + serverErrors: ServerError[]; +}> = ({ serverName, defaultValue, serverErrors }) => { + const { t } = useTranslation(); + const [username, setUsername] = useState(defaultValue); + + const normalized = normalizeUsername(username); + + return ( + 0}> + {t("common.username")} + { + // Lowercase as the user types, so what is shown is what gets sent + setUsername(e.target.value.toLocaleLowerCase()); + }} + onBlur={() => setUsername(normalizeUsername(username))} + /> + + {`@${normalized || "—"}:${serverName}`} + + + {t("frontend.errors.field_required")} + + { + const n = normalizeUsername(value); + return n.length > 0 && !VALID_LOCALPART_RE.test(n); + }} + > + {t("frontend.errors.username_invalid")} + + + {serverErrors.map((error, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static + + {fieldErrorMessage(t, error)} + + ))} + + ); +}; + +export const PasswordFields: React.FC<{ + autoFocus?: boolean; + minimumPasswordComplexity: number; + serverErrors: ServerError[]; + confirmServerErrors: ServerError[]; +}> = ({ + autoFocus, + minimumPasswordComplexity, + serverErrors, + confirmServerErrors, +}) => { + const { t } = useTranslation(); + const confirmRef = useRef(null); + const [password, setPassword] = useState(""); + + return ( + <> + 0}> + {t("frontend.register.password_label")} + + + confirmRef.current?.value && confirmRef.current.reportValidity() + } + onChange={(e) => setPassword(e.target.value)} + /> + + + + + {t("frontend.errors.field_required")} + + + {serverErrors.map((error, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static + + {fieldErrorMessage(t, error)} + + ))} + + + 0} + > + {t("frontend.register.password_confirm_label")} + + + + + + {t("frontend.errors.field_required")} + + + value !== form.get("password")} + > + {t("frontend.password_change.passwords_no_match")} + + + + {t("frontend.password_change.passwords_match")} + + + {confirmServerErrors.map((error, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static + + {fieldErrorMessage(t, error)} + + ))} + + + + ); +}; diff --git a/frontend/src/components/RegisterForm/RegisterForm.module.css b/frontend/src/components/RegisterForm/RegisterForm.module.css new file mode 100644 index 000000000..c25fd3d18 --- /dev/null +++ b/frontend/src/components/RegisterForm/RegisterForm.module.css @@ -0,0 +1,11 @@ +/* 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. + */ + +/* compound-web sizes link buttons to their content; the extra specificity + * overrides it so the link stretches like the buttons above it */ +a.login-link[href] { + inline-size: initial; +} diff --git a/frontend/src/components/RegisterForm/RegisterForm.tsx b/frontend/src/components/RegisterForm/RegisterForm.tsx new file mode 100644 index 000000000..b4313cb7d --- /dev/null +++ b/frontend/src/components/RegisterForm/RegisterForm.tsx @@ -0,0 +1,318 @@ +// 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. + +import { Button, Form, Link } from "@vector-im/compound-web"; +import { Suspense, useRef, useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { + type FieldState, + fieldErrorMessage, + formErrorMessage, + type Provider, + type ServerError, +} from "../../utils/registration"; +import { + Captcha, + type CaptchaConfig, + type CaptchaHandle, + CaptchaPlaceholder, +} from "../Captcha"; +import OrSeparator from "../OrSeparator"; +import { providerLogo } from "../ProviderLogo"; +import { PasswordFields, UsernameField } from "./RegisterFields"; +import styles from "./RegisterForm.module.css"; + +/** + * 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 ( + + ); +}; + +/** + * With upstream providers on offer, the username comes first, so that it can + * travel with a provider button as well as lead on to the password form. + */ +type Step = "username" | "password"; + +export const PasswordRegisterForm: React.FC<{ + csrfToken: string; + serverName: string; + tosUri?: string | null; + emailRequired: boolean; + minimumPasswordComplexity: number; + captchaConfig?: CaptchaConfig; + formErrors: ServerError[]; + fields: Partial>; + providers: Provider[]; + loginLink: string; +}> = ({ + csrfToken, + serverName, + tosUri, + emailRequired, + minimumPasswordComplexity, + captchaConfig, + formErrors, + fields, + providers, + loginLink, +}) => { + const { t } = useTranslation(); + // `null` until the widget has mounted; the handle reports `valid: true` + // straight away when there is no captcha to solve. + const captchaRef = useRef(null); + const [captchaError, setCaptchaError] = useState(null); + // Server errors from the last POST, dropped for each field as it is edited + const [fieldErrors, setFieldErrors] = useState(() => + Object.fromEntries( + Object.entries(fields).map(([name, field]) => [name, field?.errors]), + ), + ); + const serverErrors = (field: string): ServerError[] => + fieldErrors[field] ?? []; + // A submission which came back with errors went through both steps already + const [initialStep] = useState(() => + providers.length > 0 && + formErrors.length === 0 && + Object.values(fields).every((field) => !field?.errors.length) + ? "username" + : "password", + ); + const [step, setStep] = useState(initialStep); + // Only move the focus along when the user got here from the first step + const advanced = initialStep === "username" && step === "password"; + + return ( + { + // Typed as the form, but this is the input which changed + const input: EventTarget = e.target; + if (input instanceof HTMLInputElement && fieldErrors[input.name]) { + const { [input.name]: _cleared, ...rest } = fieldErrors; + setFieldErrors(rest); + } + }} + onClearServerErrors={() => setFieldErrors({})} + onKeyDown={(e) => { + // Enter would otherwise submit the form with the first provider button + if ( + step === "username" && + e.key === "Enter" && + e.target instanceof HTMLInputElement && + e.target.name === "username" + ) { + e.preventDefault(); + setStep("password"); + } + }} + onSubmit={(e) => { + // 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 (captchaRef.current === null) { + e.preventDefault(); + setCaptchaError(t("frontend.register.captcha_loading")); + return; + } + + if (!captchaRef.current.valid) { + e.preventDefault(); + setCaptchaError(t("frontend.register.captcha_incomplete")); + return; + } + + setCaptchaError(null); + }} + > + + + {formErrors.map((error, index) => ( +
+ {formErrorMessage(t, error)} +
+ ))} + + + + {step === "password" && ( + <> + {emailRequired && ( + 0} + > + {t("common.email_address")} + + + {t("frontend.errors.invalid_email")} + + + {t("frontend.errors.field_required")} + + {serverErrors("email").map((error, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static + + {fieldErrorMessage(t, error)} + + ))} + + )} + + + + {tosUri && ( + } + serverInvalid={serverErrors("accept_terms").length > 0} + > + + , + }} + /> + + + {t("frontend.errors.field_required")} + + {serverErrors("accept_terms").map((error, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static + + {fieldErrorMessage(t, error)} + + ))} + + )} + + + ) : null + } + > + + + + {captchaError && ( +
+ {captchaError} +
+ )} + + {/* The form's first submit button, so that Enter in any of the fields + registers rather than picking a provider */} + {t("action.continue")} + + )} + + {step === "username" && ( + <> + + + {t("frontend.register.or_separator")} + + + )} + + +
+ ); +}; + +/** + * 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. + */ +export const ProvidersOnlyForm: React.FC<{ + csrfToken: string; + providers: Provider[]; + loginLink: string; +}> = ({ csrfToken, providers, loginLink }) => ( + + + + + +); diff --git a/frontend/src/components/RegisterForm/index.ts b/frontend/src/components/RegisterForm/index.ts new file mode 100644 index 000000000..8f098b708 --- /dev/null +++ b/frontend/src/components/RegisterForm/index.ts @@ -0,0 +1,6 @@ +// 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. + +export { PasswordRegisterForm, ProvidersOnlyForm } from "./RegisterForm"; diff --git a/frontend/src/entrypoints/captcha.tsx b/frontend/src/entrypoints/captcha.tsx deleted file mode 100644 index 77de0db91..000000000 --- a/frontend/src/entrypoints/captcha.tsx +++ /dev/null @@ -1,48 +0,0 @@ -// 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. - -// Entrypoint which mounts the React `Captcha` component as an inline -// "island" on server-rendered pages. The server templates emit a -// placeholder element carrying the CAPTCHA configuration: -// -//
-// -// and include this entrypoint, which mounts the widget onto every such -// element. This is the pattern we intend to use for progressively -// enhancing server-rendered pages with React components. - -import { StrictMode, Suspense } from "react"; -import { createRoot } from "react-dom/client"; -import * as v from "valibot"; -import { Captcha, CaptchaPlaceholder } from "../components/Captcha"; - -const schema = v.object({ - captchaService: v.picklist([ - "recaptcha_v2", - "cloudflare_turnstile", - "hcaptcha", - ]), - captchaSiteKey: v.string(), -}); - -for (const node of document.querySelectorAll( - "[data-captcha-service]", -)) { - const config = v.parse(schema, node.dataset); - createRoot(node).render( - - } - > - - - , - ); -} diff --git a/frontend/src/entrypoints/register.tsx b/frontend/src/entrypoints/register.tsx new file mode 100644 index 000000000..86cbbe4ac --- /dev/null +++ b/frontend/src/entrypoints/register.tsx @@ -0,0 +1,95 @@ +// 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. + +import * as v from "valibot"; +import { + PasswordRegisterForm, + ProvidersOnlyForm, +} from "../components/RegisterForm"; +import { mountIsland } from "../utils/mountIsland"; +import { + fieldStateSchema, + providerSchema, + serverErrorSchema, +} from "../utils/registration"; +import "./shared.css"; + +// Parsed from the mount node's `data-*` attributes; structured values are +// JSON-encoded by the template. +const schema = v.object({ + csrfToken: v.string(), + captchaConfig: v.optional( + v.pipe( + v.string(), + v.parseJson(), + v.object({ + service: v.picklist([ + "recaptcha_v2", + "cloudflare_turnstile", + "hcaptcha", + ]), + site_key: v.string(), + }), + ), + ), + branding: v.pipe( + v.string(), + v.parseJson(), + v.object({ + server_name: v.string(), + tos_uri: v.optional(v.nullable(v.string())), + }), + ), + features: v.pipe( + v.string(), + v.parseJson(), + v.object({ + password_registration: v.boolean(), + password_registration_email_required: v.boolean(), + minimum_password_complexity: v.number(), + }), + ), + form: v.pipe( + v.string(), + v.parseJson(), + v.object({ + errors: v.array(serverErrorSchema), + 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; + +const RegisterPage: React.FC<{ data: Data }> = ({ data }) => + data.features.password_registration ? ( + + ) : ( + + ); + +void mountIsland({ + id: "register-form", + schema, + render: (data) => , +}); diff --git a/frontend/src/entrypoints/templates.css b/frontend/src/entrypoints/templates.css index b017feae8..c58c4c2c7 100644 --- a/frontend/src/entrypoints/templates.css +++ b/frontend/src/entrypoints/templates.css @@ -74,15 +74,64 @@ color: var(--cpd-color-text-secondary); } -.captcha-noscript { - color: var(--cpd-color-text-critical-primary); - font: var(--cpd-font-body-md-semibold); - letter-spacing: var(--cpd-font-letter-spacing-body-md); - background-color: var(--cpd-color-bg-critical-subtle); - border: 1px solid var(--cpd-color-border-critical-subtle); - border-radius: var(--cpd-space-2x); - text-align: justify; - padding: var(--cpd-space-4x); +@keyframes skeleton-pulse { + 50% { + opacity: 0.45; + } +} + +/* Rough stand-in for the React registration form: a few field rows and a submit + * button, so the page has something of about the right height to show while the + * island boots. */ +.register-skeleton { + display: flex; + flex-direction: column; + gap: var(--cpd-space-5x); + + & .field { + display: flex; + flex-direction: column; + gap: var(--cpd-space-1x); + } + + & .label, + & .control, + & .submit { + display: block; + background-color: var(--cpd-color-bg-subtle-secondary); + border-radius: var(--cpd-space-2x); + animation: skeleton-pulse 1.6s ease-in-out infinite; + } + + & .label { + inline-size: 30%; + block-size: calc( + var(--cpd-font-size-body-md) * + var(--cpd-font-line-height-regular) + ); + } + + & .control { + /* Matches .cpd-text-control: line height, plus its padding and border */ + block-size: calc( + var(--cpd-font-size-body-md) * + var(--cpd-font-line-height-regular) + + 2 * + var(--cpd-space-3x) + + 2px + ); + } + + & .submit { + block-size: var(--cpd-space-12x); + border-radius: var(--cpd-radius-pill-effect); + } +} + +@media (prefers-reduced-motion: reduce) { + .register-skeleton :is(.label, .control, .submit) { + animation: none; + } } .consent-client-icon { diff --git a/frontend/src/utils/registration.test.ts b/frontend/src/utils/registration.test.ts new file mode 100644 index 000000000..0bf0fcb48 --- /dev/null +++ b/frontend/src/utils/registration.test.ts @@ -0,0 +1,106 @@ +// 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. + +import type { TFunction } from "i18next"; +import { describe, expect, it } from "vitest"; + +import { + fieldErrorMessage, + formErrorMessage, + normalizeUsername, + policyCodeMessage, +} from "./registration"; + +// Echoes back the key, so the tests assert on which key was picked +const t = ((key: string) => key) as unknown as TFunction; + +describe("normalizeUsername()", () => { + it("trims and lowercases", () => { + expect(normalizeUsername(" Alice ")).toBe("alice"); + expect(normalizeUsername("ÉLODIE")).toBe("élodie"); + expect(normalizeUsername("")).toBe(""); + }); +}); + +describe("policyCodeMessage()", () => { + it("translates known codes", () => { + expect(policyCodeMessage(t, "username-too-short")).toBe( + "frontend.errors.username_too_short", + ); + expect(policyCodeMessage(t, "password-too-weak")).toBe( + "frontend.password_strength.too_weak", + ); + }); + + it("returns undefined for unknown codes", () => { + expect(policyCodeMessage(t, "something-new")).toBeUndefined(); + }); +}); + +describe("fieldErrorMessage()", () => { + it("maps well-known kinds", () => { + expect(fieldErrorMessage(t, { kind: "required" })).toBe( + "frontend.errors.field_required", + ); + expect(fieldErrorMessage(t, { kind: "exists" })).toBe( + "frontend.errors.username_taken", + ); + expect(fieldErrorMessage(t, { kind: "invalid" })).toBe( + "frontend.errors.field_invalid", + ); + }); + + it("prefers the policy code over the server message", () => { + expect( + fieldErrorMessage(t, { + kind: "policy", + code: "username-banned", + message: "nope", + }), + ).toBe("frontend.errors.username_banned"); + }); + + it("falls back to the server message for unknown policy codes", () => { + expect( + fieldErrorMessage(t, { + kind: "policy", + code: "something-new", + message: "nope", + }), + ).toBe("nope"); + expect( + fieldErrorMessage(t, { kind: "policy", code: "something-new" }), + ).toBe("frontend.errors.field_invalid"); + }); + + it("falls back to the server message for unknown kinds", () => { + expect(fieldErrorMessage(t, { kind: "brand_new", message: "nope" })).toBe( + "nope", + ); + expect(fieldErrorMessage(t, { kind: "brand_new" })).toBe( + "frontend.errors.field_invalid", + ); + }); +}); + +describe("formErrorMessage()", () => { + it("maps well-known kinds", () => { + expect(formErrorMessage(t, { kind: "captcha" })).toBe( + "frontend.errors.captcha", + ); + expect(formErrorMessage(t, { kind: "rate_limit_exceeded" })).toBe( + "frontend.errors.rate_limit_exceeded", + ); + }); + + it("falls back to the server message, then to a generic one", () => { + expect(formErrorMessage(t, { kind: "brand_new", message: "nope" })).toBe( + "nope", + ); + expect(formErrorMessage(t, { kind: "brand_new" })).toBe( + "frontend.errors.unspecified", + ); + }); +}); diff --git a/frontend/src/utils/registration.ts b/frontend/src/utils/registration.ts new file mode 100644 index 000000000..29fbe8359 --- /dev/null +++ b/frontend/src/utils/registration.ts @@ -0,0 +1,115 @@ +// 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. + +import type { TFunction } from "i18next"; +import * as v from "valibot"; + +/** Shape of both the form-level and the field-level errors the server sends. */ +export const serverErrorSchema = v.object({ + kind: v.string(), + code: v.optional(v.nullable(v.string())), + message: v.optional(v.string()), +}); + +export type ServerError = v.InferOutput; + +export const fieldStateSchema = v.object({ + value: v.optional(v.nullable(v.string())), + errors: v.array(serverErrorSchema), +}); + +export 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(), +}); + +export type FieldState = v.InferOutput; +export type Provider = v.InferOutput; + +/** Valid Matrix localpart: lowercase ascii, digits, and a few special chars */ +export const VALID_LOCALPART_RE = /^[a-z0-9._=/+-]+$/; + +/** Normalize a username: trim and lowercase. */ +export const normalizeUsername = (value: string): string => + value.trim().toLocaleLowerCase(); + +/** + * Well-known policy violation codes, mapped to the key of their translated + * message. Codes we don't know about fall back to the server's own message. + */ +const POLICY_CODE_MESSAGES = { + "username-too-short": "frontend.errors.username_too_short", + "username-too-long": "frontend.errors.username_too_long", + "username-invalid-chars": "frontend.errors.username_invalid_chars", + "username-all-numeric": "frontend.errors.username_all_numeric", + "username-banned": "frontend.errors.username_banned", + "username-not-allowed": "frontend.errors.username_not_allowed", + "email-domain-not-allowed": "frontend.errors.email_domain_not_allowed", + "email-domain-banned": "frontend.errors.email_domain_banned", + "email-not-allowed": "frontend.errors.email_not_allowed", + "email-banned": "frontend.errors.email_banned", + "password-too-weak": "frontend.password_strength.too_weak", +} as const; + +/** + * Translate a policy violation code, or return `undefined` if the code is + * unknown to this version of the frontend. + */ +export const policyCodeMessage = ( + t: TFunction, + code: string, +): string | undefined => { + const key = POLICY_CODE_MESSAGES[code as keyof typeof POLICY_CODE_MESSAGES]; + return key ? t(key) : undefined; +}; + +/** Translate a server-side field error. */ +export const fieldErrorMessage = (t: TFunction, error: ServerError): string => { + switch (error.kind) { + case "required": + return t("frontend.errors.field_required"); + case "exists": + return t("frontend.errors.username_taken"); + case "password_mismatch": + return t("frontend.errors.password_mismatch"); + case "policy": + return ( + (error.code ? policyCodeMessage(t, error.code) : undefined) ?? + error.message ?? + t("frontend.errors.field_invalid") + ); + // The server marks a malformed email as 'invalid', and uses + // 'unspecified' for errors it doesn't want to detail + case "invalid": + case "unspecified": + return t("frontend.errors.field_invalid"); + default: + return error.message ?? t("frontend.errors.field_invalid"); + } +}; + +/** Translate a server-side form error. */ +export const formErrorMessage = (t: TFunction, error: ServerError): string => { + switch (error.kind) { + case "captcha": + return t("frontend.errors.captcha"); + case "rate_limit_exceeded": + return t("frontend.errors.rate_limit_exceeded"); + case "password_mismatch": + return t("frontend.errors.password_mismatch"); + case "policy": + return ( + (error.code ? policyCodeMessage(t, error.code) : undefined) ?? + error.message ?? + t("frontend.errors.unspecified") + ); + default: + return error.message ?? t("frontend.errors.unspecified"); + } +}; diff --git a/templates/base.html b/templates/base.html index f7ed20bb6..b2bd476fc 100644 --- a/templates/base.html +++ b/templates/base.html @@ -16,7 +16,6 @@ Please see LICENSE files in the repository root for full details. {% import "components/errors.html" as errors %} {% import "components/icon.html" as icon %} {% import "components/scope.html" as scope %} -{% import "components/captcha.html" as captcha %} @@ -28,6 +27,7 @@ Please see LICENSE files in the repository root for full details. {{ include_asset('src/entrypoints/shared.css') | indent(4) | safe }} {{ include_asset('src/entrypoints/templates.css') | indent(4) | safe }} {{ include_asset('src/entrypoints/templates.ts') | indent(4) | safe }} + {% block head %}{% endblock head %} -
- {% for error in form.errors %} - {# Special case for the captcha error, as we want to put it at the bottom #} - {% if error.kind != "captcha" %} -
- {{ errors.form_error_message(error=error) }} -
- {% endif %} - {% endfor %} +
+

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

- + {# Rough outline of the form, to keep the page from jumping once it mounts #} + +
- {% if features.password_registration %} - {% call(f) field.field(label=_("common.username"), name="username", form_state=form) %} - - {{ field.error(error={"kind": "policy", "code": "username-invalid-chars"}, hidden=true) }} -
- @username:{{ branding.server_name }} -
- {% endcall %} + - {% if features.password_registration_email_required %} - {% call(f) field.field(label=_("common.email_address"), name="email", form_state=form) %} - - {% endcall %} - {% endif %} +
{% endblock content %} diff --git a/translations/en.json b/translations/en.json index 951d71386..85a6f67fd 100644 --- a/translations/en.json +++ b/translations/en.json @@ -10,7 +10,7 @@ }, "continue": "Continue", "@continue": { - "context": "form_post.html:26:28-48, pages/consent.html:71:28-48, pages/device_link.html:54:28-48, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/index.html:83:28-48, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:52:28-48" + "context": "form_post.html:26:28-48, pages/consent.html:71:28-48, pages/device_link.html:54:28-48, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:52:28-48" }, "create_account": "Create Account", "@create_account": { @@ -41,7 +41,7 @@ }, "name": "matrix-authentication-service", "@name": { - "context": "app.html:18:14-27, base.html:25:31-44", + "context": "app.html:18:14-27, base.html:24:31-44", "description": "Name of the application" }, "technical_description": "OpenID Connect discovery document: %(discovery_url)s", @@ -79,7 +79,7 @@ }, "email_address": "Email address", "@email_address": { - "context": "pages/recovery/start.html:34:33-58, pages/register/index.html:47:37-62, pages/upstream_oauth2/do_register.html:115:37-62" + "context": "pages/recovery/start.html:34:33-58, pages/upstream_oauth2/do_register.html:115:37-62" }, "loading": "Loading…", "@loading": { @@ -91,15 +91,11 @@ }, "password": "Password", "@password": { - "context": "pages/login.html:56:37-57, pages/reauth.html:28:35-55, pages/register/index.html:52:35-55" - }, - "password_confirm": "Confirm password", - "@password_confirm": { - "context": "pages/register/index.html:56:35-63" + "context": "pages/login.html:56:37-57, pages/reauth.html:28:35-55" }, "username": "Username", "@username": { - "context": "pages/login.html:51:39-59, pages/register/index.html:38: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": { @@ -146,12 +142,6 @@ "@back_to_homepage": { "context": "pages/404.html:16:29-54" }, - "captcha": { - "noscript": "This form is protected by a CAPTCHA and requires JavaScript to be enabled to submit it. Please enable JavaScript in your browser and reload this page.", - "@noscript": { - "context": "components/captcha.html:14:11-36" - } - }, "change_password": { "change": "Change password", "@change": { @@ -450,9 +440,13 @@ "context": "pages/sso.html:28:11-109" } }, + "loading": "Loading…", + "@loading": { + "context": "pages/register/index.html:37:26-42" + }, "loading_failed": "Something went wrong while loading the page. Try reloading it.", "@loading_failed": { - "context": "app.html:28:9-32" + "context": "app.html:28:9-32, pages/register/index.html:49:7-30" }, "login": { "call_to_register": "Don't have an account yet?", @@ -461,7 +455,7 @@ }, "continue_with_provider": "Continue with %(provider)s", "@continue_with_provider": { - "context": "pages/login.html:81:15-67, pages/register/index.html:95:11-63", + "context": "pages/login.html:81:15-67", "description": "Button to log in with an upstream provider" }, "description": "Please sign in to continue:", @@ -686,20 +680,19 @@ } }, "register": { - "call_to_login": "Already have an account?", - "@call_to_login": { - "context": "pages/register/index.html:100:33-64", - "description": "Displayed on the registration page to suggest to log in instead" - }, "create_account": { "heading": "Create an account", "@heading": { - "context": "pages/register/index.html:21: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/index.html:56:34-71" + }, "terms_of_service": "I agree to the Terms and Conditions", "@terms_of_service": { - "context": "pages/register/index.html:61:37-97, pages/upstream_oauth2/do_register.html:180:35-95" + "context": "pages/upstream_oauth2/do_register.html:180:35-95" } }, "registration_token": {