Render the registration form with React

The server-rendered registration form is replaced by a React island mounted
on `/register`, which renders the CAPTCHA in-page, gives live password
complexity feedback, and lays out the upstream provider buttons and the
sign-in link the way `/login` does. The island owns the whole form, so every
variant of the page is rendered from the mount data alone, and the user fills
it in one step. Server-side validation errors are threaded back into it
through the mount node, so a rejected submission re-renders with the errors
attached to the fields they belong to, until the user edits them.

`RegisterContext` resolves the provider names and the sign-in link server-side
so that the island only has to render them. The sign-in link is derived from
the post-auth action in the query string instead of the `PostAuthContext`
loaded from the database, which drops a round trip from both the GET and the
invalid-form re-render.

Registering now requires JavaScript, including on the variant with upstream
providers only and on the one without a CAPTCHA: the <noscript> block shows a
notice instead of a duplicate form, which could not work anyway when a CAPTCHA
is configured. While the script loads, the page shows a skeleton of the form
to avoid layout shifts, and a hidden error notice is revealed if it fails to
boot. The server-rendered CAPTCHA component and its entrypoint are removed
with their only consumer.
This commit is contained in:
Quentin Gliech authored and GitHub committed 2026-10-16 11:17:12 +02:00
1 parent 8e1d4e0f6d
commit 7232057685
24 files changed
+1399 -391

No files matched your search

+94 -44
View File
@@ -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="&#x2f;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")
);
}
+58 -128
View File
@@ -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<RegisterFormField>,
action: OptionalPostAuthAction,
action: &OptionalPostAuthAction,
csrf_token: CsrfToken,
repo: &mut BoxRepository,
templates: &Templates,
url_builder: &UrlBuilder,
captcha_config: Option<CaptchaConfig>,
) -> Result<String, InternalError> {
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();
+52 -25
View File
@@ -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<String>,
/// 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<UpstreamOAuthProvider>,
providers: Vec<RegisterPageProvider>,
login_link: String,
form: FormState<RegisterFormField>,
next: Option<PostAuthContext>,
}
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<UpstreamOAuthProvider>) -> Self {
pub fn new(
url_builder: &UrlBuilder,
providers: Vec<UpstreamOAuthProvider>,
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<RegisterFormField>) -> 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
+2
View File
@@ -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,
}
}
}
+6
View File
@@ -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",
])
}
}
+15 -15
View File
@@ -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<String, Error> {
}
}
/// Filter which simplifies a URL to its domain name for HTTP(S) URLs
fn filter_simplify_url(url: &str, kwargs: Kwargs) -> Result<String, minijinja::Error> {
/// 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<String, minijinja::E
// Do nothing else for non-HTTPS URLs
if url.scheme() != "https" {
return Ok(url.to_string());
return url.to_string();
}
let keep_path = kwargs.get::<Option<bool>>("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<String, minijinja::Error> {
let keep_path = kwargs.get::<Option<bool>>("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 {
+1
View File
@@ -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");
+33 -2
View File
@@ -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 <a>Terms and Conditions</a>"
},
"reset_cross_signing": {
"cancelled": {
"description_1": "You can close this window and go back to the app to continue.",
@@ -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<React.PropsWithChildren> = ({ children }) => (
<div className={styles.orSeparator}>
<hr />
<p>{children}</p>
<hr />
</div>
);
export default OrSeparator;
@@ -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";
+204
View File
@@ -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<React.SVGAttributes<SVGElement>>;
const GoogleLogo: Logo = (props) => (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path
d="M24.0022 12.276C24.0022 11.4603 23.9348 10.6402 23.7908 9.83771H12.2429V14.4585H18.8559C18.5814 15.9488 17.6997 17.2672 16.4086 18.1049V21.1032H20.3539C22.6707 19.0132 24.0022 15.9268 24.0022 12.276Z"
fill="#4285F4"
/>
<path
d="M12.2428 24C15.5448 24 18.3294 22.9374 20.3583 21.1032L16.413 18.1049C15.3154 18.8369 13.8983 19.2513 12.2473 19.2513C9.05332 19.2513 6.34517 17.1393 5.37347 14.2998H1.30225V17.3906C3.3806 21.4427 7.61377 24 12.2428 24Z"
fill="#34A853"
/>
<path
d="M5.36907 14.2998C4.85623 12.8095 4.85623 11.1957 5.36907 9.7054V6.61456H1.30234C-0.434114 10.0052 -0.434114 13.9999 1.30234 17.3906L5.36907 14.2998Z"
fill="#FBBC04"
/>
<path
d="M12.2428 4.7495C13.9883 4.72305 15.6753 5.36679 16.9394 6.54845L20.4348 3.12251C18.2215 1.08547 15.2839 -0.0344648 12.2428 0.000808639C7.61377 0.000808639 3.3806 2.55814 1.30225 6.61459L5.36898 9.70543C6.33617 6.8615 9.04883 4.7495 12.2428 4.7495Z"
fill="#EA4335"
/>
</svg>
);
const GitLabLogo: Logo = (props) => (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.0002 23.1079L16.4193 9.50749H7.58116L12.0002 23.1079Z"
fill="#E24329"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.9999 23.1079L7.58081 9.50749H1.38759L11.9999 23.1079Z"
fill="#FC6D26"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M1.38772 9.50749L0.0448467 13.6405C-0.0776384 14.0175 0.0565119 14.4305 0.377192 14.6634L12 23.1079L1.38772 9.50749Z"
fill="#FCA326"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M1.38759 9.50752H7.58081L4.91919 1.31612C4.78229 0.89457 4.18599 0.894684 4.0491 1.31612L1.38759 9.50752Z"
fill="#E24329"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.0001 23.1079L16.4192 9.50749H22.6124L12.0001 23.1079Z"
fill="#FC6D26"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M22.6124 9.50749L23.9552 13.6405C24.0777 14.0175 23.9436 14.4305 23.6229 14.6634L12.0001 23.1079L22.6124 9.50749Z"
fill="#FCA326"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M22.6122 9.50752H16.4189L19.0806 1.31612C19.2175 0.89457 19.8138 0.894684 19.9507 1.31612L22.6122 9.50752Z"
fill="#E24329"
/>
</svg>
);
const TwitterLogo: Logo = (props) => (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 25 24"
fill="none"
{...props}
>
<path
d="M9.04155 21C6.6153 21 4.35363 20.2943 2.45 19.0767C4.06624 19.1813 6.91855 18.9308 8.69268 17.2386C6.0238 17.1161 4.82019 15.0692 4.6632 14.1945C4.88997 14.2819 5.97147 14.3869 6.582 14.142C3.51192 13.3722 3.04094 10.678 3.1456 9.85573C3.72124 10.2581 4.69809 10.3981 5.08185 10.3631C2.22109 8.31618 3.25027 5.23707 3.75613 4.57226C5.80911 7.4165 8.8859 9.01393 12.6923 9.10278C12.6205 8.78802 12.5826 8.46032 12.5826 8.12373C12.5826 5.70819 14.5351 3.75 16.9435 3.75C18.2019 3.75 19.3358 4.28457 20.1318 5.13963C20.9727 4.94258 22.2382 4.4813 22.8569 4.0824C22.5451 5.20208 21.5742 6.13612 20.9869 6.48231C20.9918 6.49408 20.9821 6.47048 20.9869 6.48231C21.5028 6.40428 22.8986 6.13603 23.45 5.76192C23.1773 6.39094 22.148 7.4368 21.3033 8.02232C21.4604 14.9535 16.1574 21 9.04155 21Z"
fill="#1D9BF0"
/>
</svg>
);
const GitHubLogo: Logo = (props) => (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.9642 0C5.34833 0 0 5.38776 0 12.0531C0 17.3811 3.42686 21.8912 8.18082 23.4874C8.77518 23.6074 8.9929 23.2281 8.9929 22.909C8.9929 22.6296 8.97331 21.6718 8.97331 20.6738C5.64514 21.3923 4.95208 19.237 4.95208 19.237C4.41722 17.8401 3.62473 17.4811 3.62473 17.4811C2.53543 16.7427 3.70408 16.7427 3.70408 16.7427C4.91241 16.8225 5.54645 17.9799 5.54645 17.9799C6.61592 19.8157 8.33926 19.297 9.03257 18.9776C9.13151 18.1993 9.44865 17.6606 9.78539 17.3613C7.13094 17.0819 4.33812 16.0442 4.33812 11.4144C4.33812 10.0974 4.81322 9.01984 5.56604 8.1818C5.44727 7.88253 5.03118 6.64506 5.68506 4.98882C5.68506 4.98882 6.69527 4.66947 8.97306 6.22604C9.94827 5.9622 10.954 5.82799 11.9642 5.82686C12.9744 5.82686 14.0042 5.96669 14.9552 6.22604C17.2332 4.66947 18.2434 4.98882 18.2434 4.98882C18.8973 6.64506 18.481 7.88253 18.3622 8.1818C19.1349 9.01984 19.5904 10.0974 19.5904 11.4144C19.5904 16.0442 16.7976 17.0618 14.1233 17.3613C14.5592 17.7404 14.9353 18.4587 14.9353 19.5962C14.9353 21.2126 14.9158 22.5098 14.9158 22.9087C14.9158 23.2281 15.1337 23.6074 15.7278 23.4877C20.4818 21.8909 23.9087 17.3811 23.9087 12.0531C23.9282 5.38776 18.5603 0 11.9642 0Z"
fill="currentColor"
/>
</svg>
);
const FacebookLogo: Logo = (props) => (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 25 24"
fill="none"
{...props}
>
<path
d="M10.02 23.88C4.32 22.86 0 17.94 0 12C0 5.4 5.4 0 12 0C18.6 0 24 5.4 24 12C24 17.94 19.68 22.86 13.98 23.88L13.32 23.34H10.68L10.02 23.88Z"
fill="url(#mas-facebook-logo-gradient)"
/>
<path
d="M16.68 15.36L17.22 12H14.04V9.66C14.04 8.7 14.4 7.98 15.84 7.98H17.4V4.92C16.56 4.8 15.6 4.68 14.76 4.68C12 4.68 10.08 6.36 10.08 9.36V12H7.08V15.36H10.08V23.82C10.74 23.94 11.4 24 12.06 24C12.72 24 13.38 23.94 14.04 23.82V15.36H16.68Z"
fill="white"
/>
<defs>
<linearGradient
id="mas-facebook-logo-gradient"
x1="12.0006"
y1="23.1654"
x2="12.0006"
y2="-0.00442066"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#0062E0" />
<stop offset="1" stopColor="#19AFFF" />
</linearGradient>
</defs>
</svg>
);
const AppleLogo: Logo = (props) => (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 28"
fill="none"
{...props}
>
<path
d="M20.9144 8.1816C20.7752 8.2896 18.3176 9.6744 18.3176 12.7536C18.3176 16.3152 21.4448 17.5752 21.5384 17.6064C21.524 17.6832 21.0416 19.332 19.8896 21.012C18.8624 22.4904 17.7896 23.9664 16.1576 23.9664C14.5256 23.9664 14.1056 23.0184 12.2216 23.0184C10.3856 23.0184 9.7328 23.9976 8.24 23.9976C6.7472 23.9976 5.7056 22.6296 4.508 20.9496C3.1208 18.9768 2 15.912 2 13.0032C2 8.3376 5.0336 5.8632 8.0192 5.8632C9.6056 5.8632 10.928 6.9048 11.924 6.9048C12.872 6.9048 14.3504 5.8008 16.1552 5.8008C16.8392 5.8008 19.2968 5.8632 20.9144 8.1816ZM15.2984 3.8256C16.0448 2.94 16.5728 1.7112 16.5728 0.4824C16.5728 0.312 16.5584 0.1392 16.5272 0C15.3128 0.0456 13.868 0.8088 12.9968 1.8192C12.3128 2.5968 11.6744 3.8256 11.6744 5.0712C11.6744 5.2584 11.7056 5.4456 11.72 5.5056C11.7968 5.52 11.9216 5.5368 12.0464 5.5368C13.136 5.5368 14.5064 4.8072 15.2984 3.8256Z"
fill="currentColor"
/>
</svg>
);
const DiscordLogo: Logo = (props) => (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
{...props}
>
<path
fill="currentColor"
d="M19.27 5.33C17.94 4.71 16.5 4.26 15 4a.1.1 0 0 0-.07.03c-.18.33-.39.76-.53 1.09a16.1 16.1 0 0 0-4.8 0c-.14-.34-.35-.76-.54-1.09c-.01-.02-.04-.03-.07-.03c-1.5.26-2.93.71-4.27 1.33c-.01 0-.02.01-.03.02c-2.72 4.07-3.47 8.03-3.1 11.95c0 .02.01.04.03.05c1.8 1.32 3.53 2.12 5.24 2.65c.03.01.06 0 .07-.02c.4-.55.76-1.13 1.07-1.74c.02-.04 0-.08-.04-.09c-.57-.22-1.11-.48-1.64-.78c-.04-.02-.04-.08-.01-.11c.11-.08.22-.17.33-.25c.02-.02.05-.02.07-.01c3.44 1.57 7.15 1.57 10.55 0c.02-.01.05-.01.07.01c.11.09.22.17.33.26c.04.03.04.09-.01.11c-.52.31-1.07.56-1.64.78c-.04.01-.05.06-.04.09c.32.61.68 1.19 1.07 1.74c.03.01.06.02.09.01c1.72-.53 3.45-1.33 5.25-2.65c.02-.01.03-.03.03-.05c.44-4.53-.73-8.46-3.1-11.95c-.01-.01-.02-.02-.04-.02M8.52 14.91c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.84 2.12-1.89 2.12m6.97 0c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.83 2.12-1.89 2.12"
/>
</svg>
);
/** 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;
}
};
@@ -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 (
<Form.Field name="username" serverInvalid={serverErrors.length > 0}>
<Form.Label>{t("common.username")}</Form.Label>
<Form.TextControl
required
autoComplete="username"
autoCorrect="off"
autoCapitalize="none"
value={username}
onChange={(e) => {
// Lowercase as the user types, so what is shown is what gets sent
setUsername(e.target.value.toLocaleLowerCase());
}}
onBlur={() => setUsername(normalizeUsername(username))}
/>
<Form.HelpMessage>{`@${normalized || "—"}:${serverName}`}</Form.HelpMessage>
<Form.ErrorMessage match="valueMissing">
{t("frontend.errors.field_required")}
</Form.ErrorMessage>
<Form.ErrorMessage
match={(value) => {
const n = normalizeUsername(value);
return n.length > 0 && !VALID_LOCALPART_RE.test(n);
}}
>
{t("frontend.errors.username_invalid")}
</Form.ErrorMessage>
{serverErrors.map((error, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static
<Form.ErrorMessage key={`${error.kind}-${index}`}>
{fieldErrorMessage(t, error)}
</Form.ErrorMessage>
))}
</Form.Field>
);
};
export const PasswordFields: React.FC<{
autoFocus?: boolean;
minimumPasswordComplexity: number;
serverErrors: ServerError[];
confirmServerErrors: ServerError[];
}> = ({
autoFocus,
minimumPasswordComplexity,
serverErrors,
confirmServerErrors,
}) => {
const { t } = useTranslation();
const confirmRef = useRef<HTMLInputElement>(null);
const [password, setPassword] = useState("");
return (
<>
<Form.Field name="password" serverInvalid={serverErrors.length > 0}>
<Form.Label>{t("frontend.register.password_label")}</Form.Label>
<Form.PasswordControl
required
autoComplete="new-password"
autoFocus={autoFocus}
// Re-check the confirmation once the first field settles, so a stale
// "no match" error doesn't stick around
onBlur={() =>
confirmRef.current?.value && confirmRef.current.reportValidity()
}
onChange={(e) => setPassword(e.target.value)}
/>
<PasswordComplexityFeedback
password={password}
minimumPasswordComplexity={minimumPasswordComplexity}
/>
<Form.ErrorMessage match="valueMissing">
{t("frontend.errors.field_required")}
</Form.ErrorMessage>
{serverErrors.map((error, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static
<Form.ErrorMessage key={`${error.kind}-${index}`}>
{fieldErrorMessage(t, error)}
</Form.ErrorMessage>
))}
</Form.Field>
<Form.Field
name="password_confirm"
serverInvalid={confirmServerErrors.length > 0}
>
<Form.Label>{t("frontend.register.password_confirm_label")}</Form.Label>
<Form.PasswordControl
required
ref={confirmRef}
autoComplete="new-password"
/>
<FieldMessageSlot>
<Form.ErrorMessage match="valueMissing">
{t("frontend.errors.field_required")}
</Form.ErrorMessage>
<Form.ErrorMessage
match={(value, form) => value !== form.get("password")}
>
{t("frontend.password_change.passwords_no_match")}
</Form.ErrorMessage>
<Form.SuccessMessage match="valid">
{t("frontend.password_change.passwords_match")}
</Form.SuccessMessage>
{confirmServerErrors.map((error, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static
<Form.ErrorMessage key={`${error.kind}-${index}`}>
{fieldErrorMessage(t, error)}
</Form.ErrorMessage>
))}
</FieldMessageSlot>
</Form.Field>
</>
);
};
@@ -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;
}
@@ -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) => (
<Button
key={provider.id}
type="submit"
name="provider"
value={provider.id}
// The username is advisory on this path: don't hold the user back
// over a field the upstream provider may well override
formNoValidate
kind="secondary"
size="lg"
Icon={providerLogo(provider.brand)}
>
{t("frontend.register.continue_with_provider", {
provider: provider.name,
})}
</Button>
))}
</>
);
};
const LoginLink: React.FC<{ href: string }> = ({ href }) => {
const { t } = useTranslation();
return (
<Button
as="a"
kind="tertiary"
size="lg"
href={href}
className={styles.loginLink}
>
{t("frontend.register.call_to_login")}
</Button>
);
};
/**
* 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<Record<string, FieldState>>;
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<CaptchaHandle>(null);
const [captchaError, setCaptchaError] = useState<string | null>(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<Step>(() =>
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 (
<Form.Root
method="POST"
className="min-w-0"
onChange={(e) => {
// 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);
}}
>
<input type="hidden" name="csrf" value={csrfToken} />
{formErrors.map((error, index) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static
key={`${error.kind}-${index}`}
role="alert"
className="text-critical font-medium"
>
{formErrorMessage(t, error)}
</div>
))}
<UsernameField
serverName={serverName}
defaultValue={fields.username?.value ?? ""}
serverErrors={serverErrors("username")}
/>
{step === "password" && (
<>
{emailRequired && (
<Form.Field
name="email"
serverInvalid={serverErrors("email").length > 0}
>
<Form.Label>{t("common.email_address")}</Form.Label>
<Form.TextControl
type="email"
required
autoComplete="email"
defaultValue={fields.email?.value ?? ""}
autoFocus={advanced}
/>
<Form.ErrorMessage match="typeMismatch">
{t("frontend.errors.invalid_email")}
</Form.ErrorMessage>
<Form.ErrorMessage match="valueMissing">
{t("frontend.errors.field_required")}
</Form.ErrorMessage>
{serverErrors("email").map((error, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static
<Form.ErrorMessage key={`${error.kind}-${index}`}>
{fieldErrorMessage(t, error)}
</Form.ErrorMessage>
))}
</Form.Field>
)}
<PasswordFields
autoFocus={advanced && !emailRequired}
minimumPasswordComplexity={minimumPasswordComplexity}
serverErrors={serverErrors("password")}
confirmServerErrors={serverErrors("password_confirm")}
/>
{tosUri && (
<Form.InlineField
name="accept_terms"
control={<Form.CheckboxControl required value="on" />}
serverInvalid={serverErrors("accept_terms").length > 0}
>
<Form.Label>
<Trans
i18nKey="frontend.register.terms_of_service"
components={{
a: <Link href={tosUri} target="_blank" />,
}}
/>
</Form.Label>
<Form.ErrorMessage match="valueMissing">
{t("frontend.errors.field_required")}
</Form.ErrorMessage>
{serverErrors("accept_terms").map((error, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static
<Form.ErrorMessage key={`${error.kind}-${index}`}>
{fieldErrorMessage(t, error)}
</Form.ErrorMessage>
))}
</Form.InlineField>
)}
<Suspense
fallback={
captchaConfig ? (
<CaptchaPlaceholder service={captchaConfig.service} />
) : null
}
>
<Captcha ref={captchaRef} config={captchaConfig} />
</Suspense>
{captchaError && (
<div role="alert" className="text-critical font-medium">
{captchaError}
</div>
)}
{/* The form's first submit button, so that Enter in any of the fields
registers rather than picking a provider */}
<Form.Submit>{t("action.continue")}</Form.Submit>
</>
)}
{step === "username" && (
<>
<Button type="button" onClick={() => setStep("password")}>
{emailRequired
? t("frontend.register.continue_with_email")
: t("frontend.register.continue_with_password")}
</Button>
<OrSeparator>{t("frontend.register.or_separator")}</OrSeparator>
<ProviderButtons providers={providers} />
</>
)}
<LoginLink href={loginLink} />
</Form.Root>
);
};
/**
* 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 }) => (
<Form.Root method="POST">
<input type="hidden" name="csrf" value={csrfToken} />
<ProviderButtons providers={providers} />
<LoginLink href={loginLink} />
</Form.Root>
);
@@ -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";
-48
View File
@@ -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:
//
// <div data-captcha-service="…" data-captcha-site-key="…"></div>
//
// 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<HTMLElement>(
"[data-captcha-service]",
)) {
const config = v.parse(schema, node.dataset);
createRoot(node).render(
<StrictMode>
<Suspense
fallback={<CaptchaPlaceholder service={config.captchaService} />}
>
<Captcha
config={{
service: config.captchaService,
site_key: config.captchaSiteKey,
}}
/>
</Suspense>
</StrictMode>,
);
}
+95
View File
@@ -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<typeof schema>;
const RegisterPage: React.FC<{ data: Data }> = ({ data }) =>
data.features.password_registration ? (
<PasswordRegisterForm
csrfToken={data.csrfToken}
serverName={data.branding.server_name}
tosUri={data.branding.tos_uri}
emailRequired={data.features.password_registration_email_required}
minimumPasswordComplexity={data.features.minimum_password_complexity}
captchaConfig={data.captchaConfig}
formErrors={data.form.errors}
fields={data.form.fields}
providers={data.providers}
loginLink={data.loginLink}
/>
) : (
<ProvidersOnlyForm
csrfToken={data.csrfToken}
providers={data.providers}
loginLink={data.loginLink}
/>
);
void mountIsland({
id: "register-form",
schema,
render: (data) => <RegisterPage data={data} />,
});
+58 -9
View File
@@ -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 {
+106
View File
@@ -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",
);
});
});
+115
View File
@@ -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<typeof serverErrorSchema>;
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<typeof fieldStateSchema>;
export type Provider = v.InferOutput<typeof providerSchema>;
/** 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");
}
};
+1 -1
View File
@@ -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 %}
<!DOCTYPE html>
<html lang="{{ lang }}">
@@ -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 %}
</head>
<body>
<div class="layout-container{% if consent_page is defined %} consent{% endif %}">
-21
View File
@@ -1,21 +0,0 @@
{#
Copyright 2025, 2026 Element Creations Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 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.
-#}
{% macro form(class="") -%}
{%- if captcha_config|default(False) -%}
<noscript>
<div class="captcha-noscript">
{{ _("mas.captcha.noscript") }}
</div>
</noscript>
{{ include_asset('src/entrypoints/captcha.tsx') }}
<div class="{{ class }}" data-captcha-service="{{ captcha_config.service }}" data-captcha-site-key="{{ captcha_config.site_key }}"></div>
{%- endif -%}
{% endmacro %}
+30 -74
View File
@@ -9,7 +9,11 @@ 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 %}
<header class="page-heading">
@@ -22,81 +26,33 @@ Please see LICENSE files in the repository root for full details.
</div>
</header>
<form method="POST" class="cpd-form-root">
{% for error in form.errors %}
{# Special case for the captcha error, as we want to put it at the bottom #}
{% if error.kind != "captcha" %}
<div class="text-critical font-medium">
{{ errors.form_error_message(error=error) }}
</div>
{% endif %}
{% endfor %}
<div id="register-form"
data-csrf-token="{{ csrf_token }}"
{%- if captcha_config %} data-captcha-config='{{ captcha_config | tojson }}'{% endif %}
data-branding='{{ branding | tojson }}'
data-features='{{ features | tojson }}'
data-form='{{ form | tojson }}'
data-providers='{{ providers | tojson }}'
data-login-link="{{ login_link }}">
<p class="sr-only">{{ _("mas.loading") }}</p>
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
{# Rough outline of the form, to keep the page from jumping once it mounts #}
<div class="register-skeleton" aria-hidden="true">
<div class="field"><span class="label"></span><span class="control"></span></div>
<div class="field"><span class="label"></span><span class="control"></span></div>
<div class="field"><span class="label"></span><span class="control"></span></div>
<span class="submit"></span>
</div>
</div>
{% if features.password_registration %}
{% call(f) field.field(label=_("common.username"), name="username", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="none" required data-choose-username />
{{ field.error(error={"kind": "policy", "code": "username-invalid-chars"}, hidden=true) }}
<div class="cpd-form-message cpd-form-help-message" id="{{ f.id }}-help">
@username:{{ branding.server_name }}
</div>
{% endcall %}
<div id="register-form-error" class="critical-notice" hidden>
{{ _("mas.loading_failed") }}
</div>
{% if features.password_registration_email_required %}
{% call(f) field.field(label=_("common.email_address"), name="email", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="email" autocomplete="email" required />
{% endcall %}
{% endif %}
<noscript>
{# The skeleton would otherwise sit above this message forever #}
<style>#register-form { display: none; }</style>
{% call(f) field.field(label=_("common.password"), name="password", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="password" autocomplete="new-password" required />
{% endcall %}
{% call(f) field.field(label=_("common.password_confirm"), name="password_confirm", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="password" autocomplete="new-password" required />
{% endcall %}
{% if branding.tos_uri is not none %}
{% call(f) field.field(label=_("mas.register.terms_of_service", tos_uri=branding.tos_uri), name="accept_terms", form_state=form, inline=true, class="my-4") %}
<div class="cpd-form-inline-field-control">
<div class="cpd-checkbox-container">
<input {{ field.attributes(f) }} class="cpd-checkbox-input" type="checkbox" required />
<div class="cpd-checkbox-ui">
{{ icon.check() }}
</div>
</div>
</div>
{% endcall %}
{% endif %}
{{ captcha.form(class="mb-4 self-center") }}
{% for error in form.errors %}
{% if error.kind == "captcha" %}
<div class="text-critical font-medium text-center -mt-4 mb-4">
{{ errors.form_error_message(error=error) }}
</div>
{% endif %}
{% endfor %}
{{ button.button(text=_("action.continue")) }}
{% if providers %}
{{ field.separator() }}
{% endif %}
{% endif %}
{% for provider in providers %}
{% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %}
{# The provider buttons submit the same form, so they must skip the validation of the password fields #}
<button type="submit" name="provider" value="{{ provider.id }}" formnovalidate class="cpd-button {%- if provider.brand_name %} has-icon {%- endif %}" data-kind="secondary" data-size="lg">
{{ logo(provider.brand_name) }}
{{ _("mas.login.continue_with_provider", provider=name) }}
</button>
{% endfor %}
{% set params = next["params"] | default({}) | to_params(prefix="?") %}
{{ button.link_tertiary(text=_("mas.register.call_to_login"), href="/login" ~ params) }}
</form>
<p class="critical-notice">{{ _("mas.register.javascript_required") }}</p>
</noscript>
{% endblock content %}
+17 -24
View File
@@ -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: <a class=\"cpd-link\" data-kind=\"primary\" href=\"%(discovery_url)s\">%(discovery_url)s</a>",
@@ -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 <a href=\"%s\" data-kind=\"primary\" class=\"cpd-link\">Terms and Conditions</a>",
"@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": {