diff --git a/crates/handlers/src/views/register/mod.rs b/crates/handlers/src/views/register/mod.rs index dba7e6984..7f363c071 100644 --- a/crates/handlers/src/views/register/mod.rs +++ b/crates/handlers/src/views/register/mod.rs @@ -435,6 +435,173 @@ mod tests { csrf_token.form_value().clone() } + pub(super) async fn add_registration_token( + state: &TestState, + token: &str, + username: Option<&str>, + email: Option<&str>, + passwordless: bool, + ) -> mas_data_model::UserRegistrationToken { + let mut repo = state.repository().await.unwrap(); + let token = repo + .user_registration_token() + .add( + &mut state.rng(), + &state.clock, + token.to_owned(), + None, + None, + username.map(ToOwned::to_owned), + email.map(ToOwned::to_owned), + passwordless, + ) + .await + .unwrap(); + repo.save().await.unwrap(); + token + } + + /// Start a registration in this browser, as `POST /register` and the + /// email verification step leave it + pub(super) async fn add_registration( + state: &TestState, + cookies: &CookieHelper, + username: &str, + verified_email: Option<&str>, + with_password: bool, + token: Option<&mas_data_model::UserRegistrationToken>, + ) -> mas_data_model::UserRegistration { + let mut rng = state.rng(); + let mut repo = state.repository().await.unwrap(); + + let mut registration = repo + .user_registration() + .add( + &mut rng, + &state.clock, + username.to_owned(), + None, + None, + None, + ) + .await + .unwrap(); + + if let Some(email) = verified_email { + let authentication = repo + .user_email() + .add_authentication_for_registration( + &mut rng, + &state.clock, + email.to_owned(), + ®istration, + ) + .await + .unwrap(); + let code = repo + .user_email() + .add_authentication_code( + &mut rng, + &state.clock, + chrono::Duration::minutes(5), + &authentication, + "123456".to_owned(), + ) + .await + .unwrap(); + let authentication = repo + .user_email() + .complete_authentication_with_code(&state.clock, authentication, &code) + .await + .unwrap(); + registration = repo + .user_registration() + .set_email_authentication(registration, &authentication) + .await + .unwrap(); + } + + if with_password { + registration = repo + .user_registration() + .set_password(registration, "hashed".to_owned(), 1) + .await + .unwrap(); + } + + if let Some(token) = token { + registration = repo + .user_registration() + .set_registration_token(registration, token) + .await + .unwrap(); + } + + repo.save().await.unwrap(); + + cookies.import( + super::UserRegistrationSessionsCookie::default() + .add(®istration) + .save(state.cookie_jar(), &state.clock), + ); + + registration + } + + /// Link a registration to an upstream identity, as registering through an + /// upstream provider does + pub(super) async fn link_upstream( + state: &TestState, + registration: mas_data_model::UserRegistration, + ) -> mas_data_model::UserRegistration { + let provider_id = provider(state).await; + let mut rng = state.rng(); + let mut repo = state.repository().await.unwrap(); + + let provider = repo + .upstream_oauth_provider() + .lookup(provider_id) + .await + .unwrap() + .unwrap(); + let session = repo + .upstream_oauth_session() + .add( + &mut rng, + &state.clock, + &provider, + registration.username.clone(), + None, + None, + ) + .await + .unwrap(); + let link = repo + .upstream_oauth_link() + .add( + &mut rng, + &state.clock, + &provider, + registration.username.clone(), + None, + ) + .await + .unwrap(); + let session = repo + .upstream_oauth_session() + .complete_with_link(&state.clock, session, &link, None, None, None, None) + .await + .unwrap(); + let registration = repo + .user_registration() + .set_upstream_oauth_authorization_session(registration, &session) + .await + .unwrap(); + + repo.save().await.unwrap(); + registration + } + /// Decode the upstream sessions cookie set by the given response, if any fn upstream_sessions( state: &TestState, diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs index fd1accbaf..c7e222d03 100644 --- a/crates/handlers/src/views/register/password.rs +++ b/crates/handlers/src/views/register/password.rs @@ -423,7 +423,7 @@ mod tests { use sqlx::PgPool; use ulid::Ulid; - use super::super::tests::{csrf_token, mint_csrf_token}; + use super::super::tests::{add_registration_token, csrf_token, mint_csrf_token}; use crate::{ SiteConfig, test_utils::{ @@ -1136,6 +1136,44 @@ mod tests { assert!(!repo.user().exists("mallory").await.unwrap()); } + /// An invite code which pins an email address can't be used to register + /// another + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_with_token_email_mismatch(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + add_registration_token( + &state, + "invite_alice", + None, + Some("alice@example.com"), + false, + ) + .await; + + 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": "mallory", + "email": "mallory@example.com", + "password": "correcthorsebatterystaple", + "password_confirm": "correcthorsebatterystaple", + "token": "invite_alice", + "accept_terms": "on", + }), + ); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + assert_eq!( + form_state(response.body())["fields"]["email"]["errors"], + serde_json::json!([{"kind": "invalid"}]) + ); + } + /// A passwordless invite code is a way to register on its own: it works /// with password registration off, and with passwords disabled entirely #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] diff --git a/crates/handlers/src/views/register/steps/finish.rs b/crates/handlers/src/views/register/steps/finish.rs index 11dab8cf5..1b67b79b3 100644 --- a/crates/handlers/src/views/register/steps/finish.rs +++ b/crates/handlers/src/views/register/steps/finish.rs @@ -225,6 +225,39 @@ pub(crate) async fn get( None }; + if registration_token + .as_ref() + .is_some_and(|registration_token| { + !super::token_fits( + registration_token, + ®istration, + email_authentication + .as_ref() + .map(|authentication| authentication.email.as_str()), + ) + }) + { + return Err(InternalError::from_anyhow(anyhow::anyhow!( + "Registration token doesn't fit the registration" + ))); + } + + // Without a password or an upstream link, a passwordless token and a + // verified email address are what the user registers with + if registration.password.is_none() + && registration + .upstream_oauth_authorization_session_id + .is_none() + && !(registration_token + .as_ref() + .is_some_and(|registration_token| registration_token.passwordless) + && email_authentication.is_some()) + { + return Err(InternalError::from_anyhow(anyhow::anyhow!( + "Registration has no password, no upstream link, and no passwordless registration token with a verified email address" + ))); + } + // If this registration was created from an upstream OAuth session, check // it is still valid and wasn't linked to a user in the meantime let upstream_oauth = if let Some(upstream_oauth_authorization_session_id) = @@ -391,3 +424,206 @@ pub(crate) async fn get( ) .into_response()); } + +#[cfg(test)] +mod tests { + use hyper::{Request, Response, StatusCode, header::LOCATION}; + use mas_data_model::UserRegistration; + use mas_router::Route as _; + use sqlx::PgPool; + + use crate::{ + test_utils::{CookieHelper, RequestBuilderExt, ResponseExt, TestState, setup}, + views::register::tests::{add_registration, add_registration_token, link_upstream}, + }; + + async fn finish( + state: &TestState, + cookies: &CookieHelper, + registration: &UserRegistration, + ) -> Response { + let request = + Request::get(&*mas_router::RegisterFinish::new(registration.id).path_and_query()) + .empty(); + state.request(cookies.with_cookies(request)).await + } + + /// Assert that finish refused the registration with an error naming + /// `reason` + fn assert_refused(response: &Response, reason: &str) { + response.assert_status(StatusCode::INTERNAL_SERVER_ERROR); + assert!( + response.body().contains(reason), + "response body: {}", + response.body() + ); + } + + /// A registration matching the pins of its passwordless token goes on to + /// the next step + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_with_pinned_token(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + let token = add_registration_token( + &state, + "invite_alice", + Some("alice"), + Some("alice@example.com"), + true, + ) + .await; + let registration = add_registration( + &state, + &cookies, + "alice", + Some("alice@example.com"), + false, + Some(&token), + ) + .await; + + let response = finish(&state, &cookies, ®istration).await; + response.assert_status(StatusCode::SEE_OTHER); + response.assert_header_value( + LOCATION, + &mas_router::RegisterDisplayName::new(registration.id).path_and_query(), + ); + } + + /// A registration linked upstream needs neither a password nor a + /// passwordless token + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_upstream_without_password(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + let token = add_registration_token(&state, "plain", None, None, false).await; + for (username, token) in [("alice", None), ("bob", Some(&token))] { + let registration = add_registration( + &state, + &cookies, + username, + Some(&format!("{username}@example.com")), + false, + token, + ) + .await; + let registration = link_upstream(&state, registration).await; + + let response = finish(&state, &cookies, ®istration).await; + response.assert_status(StatusCode::SEE_OTHER); + response.assert_header_value( + LOCATION, + &mas_router::RegisterDisplayName::new(registration.id).path_and_query(), + ); + } + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_refuses_token_pinning_another_username(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + let token = + add_registration_token(&state, "invite_alice", Some("alice"), None, false).await; + let registration = add_registration( + &state, + &cookies, + "mallory", + Some("mallory@example.com"), + true, + Some(&token), + ) + .await; + + let response = finish(&state, &cookies, ®istration).await; + assert_refused(&response, "fit the registration"); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_refuses_token_pinning_another_email(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + let token = add_registration_token( + &state, + "invite_alice", + Some("alice"), + Some("alice@example.com"), + true, + ) + .await; + let registration = add_registration( + &state, + &cookies, + "alice", + Some("mallory@example.com"), + false, + Some(&token), + ) + .await; + let response = finish(&state, &cookies, ®istration).await; + assert_refused(&response, "fit the registration"); + + // A registration without an email address doesn't match a pinned one + let token = + add_registration_token(&state, "pins_email", None, Some("bob@example.com"), false) + .await; + let registration = + add_registration(&state, &cookies, "bob", None, true, Some(&token)).await; + let response = finish(&state, &cookies, ®istration).await; + assert_refused(&response, "fit the registration"); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_refuses_no_password_without_passwordless_token(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + let registration = add_registration( + &state, + &cookies, + "bob", + Some("bob@example.com"), + false, + None, + ) + .await; + let response = finish(&state, &cookies, ®istration).await; + assert_refused(&response, "no passwordless registration token"); + + let token = add_registration_token(&state, "plain", None, None, false).await; + let registration = add_registration( + &state, + &cookies, + "carol", + Some("carol@example.com"), + false, + Some(&token), + ) + .await; + let response = finish(&state, &cookies, ®istration).await; + assert_refused(&response, "fit the registration"); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_finish_refuses_passwordless_token_without_verified_email(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + let token = add_registration_token(&state, "passwordless", None, None, true).await; + let registration = + add_registration(&state, &cookies, "dave", None, false, Some(&token)).await; + + let response = finish(&state, &cookies, ®istration).await; + assert_refused(&response, "no passwordless registration token"); + } +} diff --git a/crates/handlers/src/views/register/steps/mod.rs b/crates/handlers/src/views/register/steps/mod.rs index 715934d9f..dfcf1e151 100644 --- a/crates/handlers/src/views/register/steps/mod.rs +++ b/crates/handlers/src/views/register/steps/mod.rs @@ -1,9 +1,35 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +use mas_data_model::{UserRegistration, UserRegistrationToken}; + pub(crate) mod display_name; pub(crate) mod finish; pub(crate) mod registration_token; pub(crate) mod verify_email; + +/// Whether `token` can be used for `registration`, whose email address is +/// `email`: it pins no other username or email address, and it is passwordless +/// if the registration has neither a password nor an upstream link +fn token_fits( + token: &UserRegistrationToken, + registration: &UserRegistration, + email: Option<&str>, +) -> bool { + token + .username + .as_ref() + .is_none_or(|username| *username == registration.username) + && token + .email + .as_deref() + .is_none_or(|pinned| Some(pinned) == email) + && (token.passwordless + || registration.password.is_some() + || registration + .upstream_oauth_authorization_session_id + .is_some()) +} diff --git a/crates/handlers/src/views/register/steps/registration_token.rs b/crates/handlers/src/views/register/steps/registration_token.rs index f9ad5b872..abf66c0e7 100644 --- a/crates/handlers/src/views/register/steps/registration_token.rs +++ b/crates/handlers/src/views/register/steps/registration_token.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial @@ -188,6 +189,35 @@ pub(crate) async fn post( .into_response()); } + let email = match registration.email_authentication_id { + Some(email_authentication_id) => Some( + repo.user_email() + .lookup_authentication(email_authentication_id) + .await? + .context("Could not load the email authentication") + .map_err(InternalError::from_anyhow)? + .email, + ), + None => None, + }; + + if !super::token_fits(®istration_token, ®istration, email.as_deref()) { + tracing::warn!("Registration token doesn't fit the registration"); + let ctx = RegisterStepsRegistrationTokenContext::new() + .with_form_state(form.to_form_state().with_error_on_field( + RegisterStepsRegistrationTokenFormField::Token, + FieldError::Mismatch, + )) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + return Ok(( + cookie_jar, + Html(templates.render_register_steps_registration_token(&ctx)?), + ) + .into_response()); + } + // Associate the token with the registration let registration = repo .user_registration() @@ -200,3 +230,200 @@ pub(crate) async fn post( let destination = mas_router::RegisterFinish::new(registration.id); Ok((cookie_jar, url_builder.redirect(&destination)).into_response()) } + +#[cfg(test)] +mod tests { + use hyper::{Request, Response, StatusCode, header::LOCATION}; + use mas_data_model::UserRegistration; + use mas_router::Route as _; + use sqlx::PgPool; + + use crate::{ + test_utils::{CookieHelper, RequestBuilderExt, ResponseExt, TestState, setup}, + views::register::tests::{ + add_registration, add_registration_token, link_upstream, mint_csrf_token, + }, + }; + + async fn submit_token( + state: &TestState, + cookies: &CookieHelper, + registration: &UserRegistration, + token: &str, + ) -> Response { + let csrf_token = mint_csrf_token(state, cookies); + let request = + Request::post(&*mas_router::RegisterToken::new(registration.id).path_and_query()) + .form(serde_json::json!({ "csrf": csrf_token, "token": token })); + state.request(cookies.with_cookies(request)).await + } + + /// Assert that the token step refused the token as not fitting the + /// registration, and attached nothing + async fn assert_refused( + state: &TestState, + response: &Response, + registration: &UserRegistration, + ) { + response.assert_status(StatusCode::OK); + assert!( + response.body().contains(r#"data-error-kind="mismatch""#), + "response body: {}", + response.body() + ); + + let mut repo = state.repository().await.unwrap(); + let registration = repo + .user_registration() + .lookup(registration.id) + .await + .unwrap() + .unwrap(); + assert_eq!(registration.user_registration_token_id, None); + } + + /// The token step comes before the email address is verified + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_accepts_token_pinning_an_unverified_email(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + add_registration_token( + &state, + "invite_alice", + Some("alice"), + Some("alice@example.com"), + false, + ) + .await; + let registration = add_registration(&state, &cookies, "alice", None, true, None).await; + + let mut repo = state.repository().await.unwrap(); + let authentication = repo + .user_email() + .add_authentication_for_registration( + &mut state.rng(), + &state.clock, + "alice@example.com".to_owned(), + ®istration, + ) + .await + .unwrap(); + let registration = repo + .user_registration() + .set_email_authentication(registration, &authentication) + .await + .unwrap(); + repo.save().await.unwrap(); + + let response = submit_token(&state, &cookies, ®istration, "invite_alice").await; + response.assert_status(StatusCode::SEE_OTHER); + response.assert_header_value( + LOCATION, + &mas_router::RegisterFinish::new(registration.id).path_and_query(), + ); + } + + /// A registration linked upstream needs no password, so any token fits it + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_accepts_token_not_passwordless_upstream(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + add_registration_token(&state, "plain", None, None, false).await; + let registration = add_registration( + &state, + &cookies, + "alice", + Some("alice@example.com"), + false, + None, + ) + .await; + let registration = link_upstream(&state, registration).await; + + let response = submit_token(&state, &cookies, ®istration, "plain").await; + response.assert_status(StatusCode::SEE_OTHER); + response.assert_header_value( + LOCATION, + &mas_router::RegisterFinish::new(registration.id).path_and_query(), + ); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_refuses_token_pinning_another_username(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + add_registration_token(&state, "invite_alice", Some("alice"), None, false).await; + let registration = add_registration( + &state, + &cookies, + "mallory", + Some("mallory@example.com"), + true, + None, + ) + .await; + + let response = submit_token(&state, &cookies, ®istration, "invite_alice").await; + assert_refused(&state, &response, ®istration).await; + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_refuses_token_pinning_another_email(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + add_registration_token( + &state, + "invite_alice", + None, + Some("alice@example.com"), + false, + ) + .await; + + let registration = add_registration( + &state, + &cookies, + "mallory", + Some("mallory@example.com"), + true, + None, + ) + .await; + let response = submit_token(&state, &cookies, ®istration, "invite_alice").await; + assert_refused(&state, &response, ®istration).await; + + // Nor does it fit a registration without an email address + let registration = add_registration(&state, &cookies, "bob", None, true, None).await; + let response = submit_token(&state, &cookies, ®istration, "invite_alice").await; + assert_refused(&state, &response, ®istration).await; + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_refuses_token_not_passwordless_without_password(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let cookies = CookieHelper::new(); + + add_registration_token(&state, "plain", None, None, false).await; + let registration = add_registration( + &state, + &cookies, + "alice", + Some("alice@example.com"), + false, + None, + ) + .await; + + let response = submit_token(&state, &cookies, ®istration, "plain").await; + assert_refused(&state, &response, ®istration).await; + } +} diff --git a/crates/templates/src/forms.rs b/crates/templates/src/forms.rs index 94e20be3a..e5a22bad3 100644 --- a/crates/templates/src/forms.rs +++ b/crates/templates/src/forms.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // @@ -34,6 +35,9 @@ pub enum FieldError { /// That value already exists Exists, + /// The value doesn't fit what the rest of the flow already holds + Mismatch, + /// Denied by the policy Policy { /// Well-known policy code diff --git a/templates/components/field.html b/templates/components/field.html index 3270dd8c4..2a206093e 100644 --- a/templates/components/field.html +++ b/templates/components/field.html @@ -28,6 +28,8 @@ Please see LICENSE files in the repository root for full details. {{ _("mas.errors.field_required") }} {% elif error.kind == "exists" and field_name == "username" %} {{ _("mas.errors.username_taken") }} + {% elif error.kind == "mismatch" and field_name == "token" %} + {{ _("mas.errors.registration_token_mismatch") }} {% elif error.kind == "policy" %} {% if error.code == "username-too-short" %} {{ _("mas.errors.username_too_short") }} diff --git a/translations/en.json b/translations/en.json index 4d9a76f34..2ee98b86d 100644 --- a/translations/en.json +++ b/translations/en.json @@ -369,27 +369,27 @@ }, "denied_policy": "Denied by policy: %(policy)s", "@denied_policy": { - "context": "components/errors.html:17:7-58, components/field.html:53:11-62" + "context": "components/errors.html:17:7-58, components/field.html:55:11-62" }, "email_banned": "Email is banned by the server policy", "@email_banned": { - "context": "components/field.html:51:11-39" + "context": "components/field.html:53:11-39" }, "email_domain_banned": "Email domain is banned by the server policy", "@email_domain_banned": { - "context": "components/field.html:47:11-46" + "context": "components/field.html:49:11-46" }, "email_domain_not_allowed": "Email domain is not allowed by the server policy", "@email_domain_not_allowed": { - "context": "components/field.html:45:11-51" + "context": "components/field.html:47:11-51" }, "email_not_allowed": "Email is not allowed by the server policy", "@email_not_allowed": { - "context": "components/field.html:49:11-44" + "context": "components/field.html:51:11-44" }, "field_invalid": "This field is invalid", "@field_invalid": { - "context": "components/field.html:58:9-38" + "context": "components/field.html:60:9-38" }, "field_required": "This field is required", "@field_required": { @@ -401,28 +401,32 @@ }, "password_mismatch": "Password fields don't match", "@password_mismatch": { - "context": "components/errors.html:13:7-40, components/field.html:56:9-42" + "context": "components/errors.html:13:7-40, components/field.html:58:9-42" }, "rate_limit_exceeded": "You've made too many requests in a short period. Please wait a few minutes and try again.", "@rate_limit_exceeded": { "context": "components/errors.html:15:7-42, pages/recovery/progress.html:26:11-46" }, + "registration_token_mismatch": "This token can't be used to create this account", + "@registration_token_mismatch": { + "context": "components/field.html:32:9-52" + }, "username_all_numeric": "Username cannot consist solely of numbers", "@username_all_numeric": { - "context": "components/field.html:39:11-47" + "context": "components/field.html:41:11-47" }, "username_banned": "Username is banned by the server policy", "@username_banned": { - "context": "components/field.html:41:11-42", + "context": "components/field.html:43:11-42", "description": "Error message shown on registration, when the username matches a pattern that is banned by the server policy." }, "username_invalid_chars": "Username contains invalid characters. Use lowercase letters, numbers, dashes and underscores only.", "@username_invalid_chars": { - "context": "components/field.html:37:11-49" + "context": "components/field.html:39:11-49" }, "username_not_allowed": "Username is not allowed by the server policy", "@username_not_allowed": { - "context": "components/field.html:43:11-47", + "context": "components/field.html:45:11-47", "description": "Error message shown on registration, when the username *does not match* any of the patterns that are allowed by the server policy." }, "username_taken": "This username is already taken", @@ -431,11 +435,11 @@ }, "username_too_long": "Username is too long", "@username_too_long": { - "context": "components/field.html:35:11-44" + "context": "components/field.html:37:11-44" }, "username_too_short": "Username is too short", "@username_too_short": { - "context": "components/field.html:33:11-45" + "context": "components/field.html:35:11-45" } }, "legacy_consent": { @@ -525,7 +529,7 @@ }, "or_separator": "Or", "@or_separator": { - "context": "components/field.html:116:10-31", + "context": "components/field.html:118:10-31", "description": "Separator between the login methods" }, "policy_violation": {