From 24683fb938506bb9b7c4e457378d7e6597fd4f64 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 15 Sep 2026 13:25:00 +0200 Subject: [PATCH 1/2] Add a `usernameAvailable` GraphQL query for anonymous lookups Exposes a new query telling whether a given username is available for registration, and if not, why: already taken, or rejected by the registration policy. A username reserved on the homeserver side is deliberately reported as taken, mirroring the ambiguity of the registration form, which doesn't tell the two apart either. Because the query reveals whether a username exists, it is rate-limited per requester, configurable through the new `rate_limiting.username_availability` option. --- crates/config/src/sections/rate_limiting.rs | 21 ++ crates/handlers/src/graphql/query/user.rs | 124 ++++++- crates/handlers/src/graphql/tests.rs | 391 +++++++++++++++++++- crates/handlers/src/rate_limit.rs | 30 ++ crates/handlers/src/test_utils.rs | 13 +- crates/matrix/src/mock.rs | 24 +- docs/config.schema.json | 12 + docs/reference/configuration.md | 8 + frontend/schema.graphql | 48 +++ 9 files changed, 665 insertions(+), 6 deletions(-) diff --git a/crates/config/src/sections/rate_limiting.rs b/crates/config/src/sections/rate_limiting.rs index bcefa4093..70454ac9a 100644 --- a/crates/config/src/sections/rate_limiting.rs +++ b/crates/config/src/sections/rate_limiting.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // @@ -38,6 +39,14 @@ pub struct RateLimitingConfig { /// This can protect against brute-forcing the user code. #[serde(default = "default_device_code_link")] pub device_code_link: RateLimiterConfiguration, + + /// Controls how often the registration form can check whether a username + /// is available, based on source address. + /// + /// The form checks the username as it is typed. This limit makes it + /// harder to use those checks to find out which usernames exist. + #[serde(default = "default_username_availability")] + pub username_availability: RateLimiterConfiguration, } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] @@ -174,6 +183,10 @@ impl ConfigurationSection for RateLimitingConfig { return Err(error_on_field(error, "registration").into()); } + if let Some(error) = error_on_limiter(&self.username_availability) { + return Err(error_on_field(error, "username_availability").into()); + } + if let Some(error) = error_on_limiter(&self.login.per_ip) { return Err(error_on_nested_field(error, "login", "per_ip").into()); } @@ -275,6 +288,13 @@ fn default_device_code_link() -> RateLimiterConfiguration { } } +fn default_username_availability() -> RateLimiterConfiguration { + RateLimiterConfiguration { + burst: NonZeroU32::new(5).unwrap(), + per_second: 1.0, + } +} + impl Default for RateLimitingConfig { fn default() -> Self { RateLimitingConfig { @@ -283,6 +303,7 @@ impl Default for RateLimitingConfig { account_recovery: AccountRecoveryRateLimitingConfig::default(), email_authentication: EmailauthenticationRateLimitingConfig::default(), device_code_link: default_device_code_link(), + username_availability: default_username_availability(), } } } diff --git a/crates/handlers/src/graphql/query/user.rs b/crates/handlers/src/graphql/query/user.rs index bb55ef67b..7673c35eb 100644 --- a/crates/handlers/src/graphql/query/user.rs +++ b/crates/handlers/src/graphql/query/user.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // @@ -5,7 +6,7 @@ // Please see LICENSE files in the repository root for full details. use async_graphql::{ - Context, Enum, ID, Object, + Context, Enum, ID, Object, SimpleObject, connection::{Connection, Edge, OpaqueCursor, query}, }; use mas_storage::{Pagination, user::UserFilter}; @@ -16,6 +17,34 @@ use crate::graphql::{ state::ContextExt as _, }; +/// Why a username is not available for registration. +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +enum UsernameUnavailableReason { + /// The username is already in use, either by an existing MAS user or + /// reserved by the homeserver. + Taken, + /// The username does not pass the registration policy. See + /// `violationCodes` for the specific reasons. + Invalid, +} + +/// The result of a username availability check. +#[derive(SimpleObject)] +struct UsernameAvailability { + /// Whether the username is available for registration. + available: bool, + /// If the username is not available, the reason why. + reason: Option, + /// If the username is invalid, the list of policy violation codes (e.g. + /// `username-too-long`, `username-all-numeric`, `username-banned`, + /// `username-not-allowed`, `username-invalid-chars`), so that clients can + /// localize the error message. + /// + /// This may be an empty list, when the registration policy rejected the + /// username for a reason that has no code attached to it. + violation_codes: Option>, +} + #[derive(Default)] pub struct UserQuery; @@ -45,6 +74,99 @@ impl UserQuery { Ok(user.map(User)) } + /// Check whether a username is available for registration. + /// + /// This query is accessible to anonymous users, as it is used during the + /// registration flow. Unlike `userByUsername`, it reveals whether a + /// username exists, so it is rate-limited per requester. + async fn username_available( + &self, + ctx: &Context<'_>, + username: String, + ) -> Result { + let state = ctx.state(); + let requester = ctx.requester(); + + if !state.site_config().password_registration_enabled { + return Err(async_graphql::Error::new( + "Password registration is disabled on this server", + )); + } + + if let Err(e) = state + .limiter() + .check_username_availability(requester.fingerprint()) + { + tracing::warn!(error = &e as &dyn std::error::Error); + return Err(async_graphql::Error::new("Too many requests")); + } + + // Check if the username exists in the MAS database + let mut repo = state.repository().await?; + let exists = repo.user().exists(&username).await?; + repo.cancel().await?; + + if exists { + return Ok(UsernameAvailability { + available: false, + reason: Some(UsernameUnavailableReason::Taken), + violation_codes: None, + }); + } + + // Run the same registration policy the register views use, so that this + // query doesn't report a username as available when registration would + // actually reject it + let mut policy = state.policy().await?; + let res = policy + .evaluate_register(mas_policy::RegisterInput { + registration_method: mas_policy::RegistrationMethod::Password, + username: &username, + email: None, + requester: requester.for_policy(), + }) + .await?; + + // Any violation means registration would be rejected, even if it isn't + // about the username itself (e.g. a banned requester) or has no code we + // know about. The codes we do recognise are only there to let clients + // localize the error, so they may well be empty. + if !res.violations.is_empty() { + let violation_codes: Vec = res + .violations + .into_iter() + .filter(|violation| violation.field.as_deref() == Some("username")) + .filter_map(|violation| { + violation.variant.map(|variant| variant.as_str().to_owned()) + }) + .collect(); + + return Ok(UsernameAvailability { + available: false, + reason: Some(UsernameUnavailableReason::Invalid), + violation_codes: Some(violation_codes), + }); + } + + // Check if the username is available on the homeserver + let homeserver = state.homeserver_connection(); + let homeserver_available = homeserver.is_localpart_available(&username).await?; + + if !homeserver_available { + return Ok(UsernameAvailability { + available: false, + reason: Some(UsernameUnavailableReason::Taken), + violation_codes: None, + }); + } + + Ok(UsernameAvailability { + available: true, + reason: None, + violation_codes: None, + }) + } + /// Fetch a user by its username. async fn user_by_username( &self, diff --git a/crates/handlers/src/graphql/tests.rs b/crates/handlers/src/graphql/tests.rs index fde54bd9b..112dcf02d 100644 --- a/crates/handlers/src/graphql/tests.rs +++ b/crates/handlers/src/graphql/tests.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // @@ -22,7 +23,12 @@ use oauth2_types::{ use sqlx::PgPool; use zeroize::Zeroizing; -use crate::test_utils::{self, CookieHelper, RequestBuilderExt, ResponseExt, TestState, setup}; +use crate::{ + SiteConfig, + test_utils::{ + self, CookieHelper, RequestBuilderExt, ResponseExt, TestState, setup, test_site_config, + }, +}; async fn create_test_client(state: &TestState) -> Client { let mut repo = state.repository().await.unwrap(); @@ -1084,3 +1090,386 @@ async fn test_deactivate_user_rejected_wrong_password(pool: PgPool) { response.data ); } + +const USERNAME_AVAILABLE_QUERY: &str = r" + query($username: String!) { + usernameAvailable(username: $username) { + available + reason + violationCodes + } + } +"; + +/// Test that the `usernameAvailable` query reports an available username. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_username_available(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let request = Request::post("/graphql").json(serde_json::json!({ + "query": USERNAME_AVAILABLE_QUERY, + "variables": { "username": "carol" }, + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data["usernameAvailable"], + serde_json::json!({ + "available": true, + "reason": null, + "violationCodes": null, + }) + ); +} + +/// Test that the `usernameAvailable` query reports a username already taken +/// by an existing MAS user. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_username_available_taken(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + create_test_user(&state, "alice").await; + + let request = Request::post("/graphql").json(serde_json::json!({ + "query": USERNAME_AVAILABLE_QUERY, + "variables": { "username": "alice" }, + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data["usernameAvailable"], + serde_json::json!({ + "available": false, + "reason": "TAKEN", + "violationCodes": null, + }) + ); +} + +/// Test that the `usernameAvailable` query matches existing MAS users +/// case-insensitively. +/// +/// The MAS-side existence check is a `LOWER(username) = LOWER($1)` match and +/// runs before the registration policy, so a differently-cased spelling of an +/// existing username reports `TAKEN` rather than the `username-invalid-chars` +/// violation the policy would raise for the uppercase letter. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_username_available_different_case(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + create_test_user(&state, "alice").await; + + let request = Request::post("/graphql").json(serde_json::json!({ + "query": USERNAME_AVAILABLE_QUERY, + "variables": { "username": "Alice" }, + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data["usernameAvailable"], + serde_json::json!({ + "available": false, + "reason": "TAKEN", + "violationCodes": null, + }) + ); +} + +/// Test that the `usernameAvailable` query rejects an empty username. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_username_available_empty(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let request = Request::post("/graphql").json(serde_json::json!({ + "query": USERNAME_AVAILABLE_QUERY, + "variables": { "username": "" }, + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data["usernameAvailable"]["available"], + serde_json::json!(false) + ); + assert_eq!( + response.data["usernameAvailable"]["reason"].as_str(), + Some("INVALID") + ); +} + +/// Test that the `usernameAvailable` query reports a username reserved on +/// the homeserver as `TAKEN`, like an existing MAS user. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_username_available_reserved(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + state.homeserver_connection.reserve_localpart("bob").await; + + let request = Request::post("/graphql").json(serde_json::json!({ + "query": USERNAME_AVAILABLE_QUERY, + "variables": { "username": "bob" }, + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data["usernameAvailable"], + serde_json::json!({ + "available": false, + "reason": "TAKEN", + "violationCodes": null, + }) + ); +} + +/// Test that the `usernameAvailable` query runs the registration policy and +/// reports the violation codes for an all-numeric username. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_username_available_invalid_all_numeric(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let request = Request::post("/graphql").json(serde_json::json!({ + "query": USERNAME_AVAILABLE_QUERY, + "variables": { "username": "1234567" }, + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data["usernameAvailable"]["reason"].as_str(), + Some("INVALID") + ); + let codes = response.data["usernameAvailable"]["violationCodes"] + .as_array() + .expect("violationCodes to be an array"); + assert!( + codes + .iter() + .any(|c| c.as_str() == Some("username-all-numeric")), + "{:?}", + codes + ); +} + +/// Test that the `usernameAvailable` query reports a username as unavailable +/// when the registration policy rejects the registration for a reason that +/// isn't about the username, and therefore has no violation code. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_username_available_banned_requester(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config_and_policy_data( + pool, + test_site_config(), + serde_json::json!({ + "requester": { + "banned_ips": ["1.2.3.4"], + }, + }), + ) + .await + .unwrap(); + + // The policy crashes on a null user agent when formatting the requester, + // so make sure we send one + let request = Request::post("/graphql") + .client_ip([1, 2, 3, 4].into()) + .header("user-agent", "Test/1.0") + .json(serde_json::json!({ + "query": USERNAME_AVAILABLE_QUERY, + "variables": { "username": "frank" }, + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data["usernameAvailable"], + serde_json::json!({ + "available": false, + "reason": "INVALID", + "violationCodes": [], + }) + ); +} + +/// Test that the `usernameAvailable` query reports an overly long username as +/// invalid, with the violation code the registration policy raises. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_username_available_too_long(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let username = "a".repeat(300); + let request = Request::post("/graphql").json(serde_json::json!({ + "query": USERNAME_AVAILABLE_QUERY, + "variables": { "username": username }, + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data["usernameAvailable"]["reason"].as_str(), + Some("INVALID") + ); + assert_eq!( + response.data["usernameAvailable"]["violationCodes"], + serde_json::json!(["username-too-long"]) + ); +} + +/// Test that the `usernameAvailable` query errors out when password +/// registration is disabled on the server. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_username_available_registration_disabled(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_registration_enabled: false, + ..test_site_config() + }, + ) + .await + .unwrap(); + + let request = Request::post("/graphql").json(serde_json::json!({ + "query": USERNAME_AVAILABLE_QUERY, + "variables": { "username": "dave" }, + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert_eq!( + response.errors[0]["message"].as_str(), + Some("Password registration is disabled on this server"), + "{:?}", + response.errors + ); + assert!(response.data["usernameAvailable"].is_null()); +} + +/// Test that the `usernameAvailable` query errors out when the homeserver call +/// fails, rather than reporting the username as available. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_username_available_homeserver_failure(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + state.homeserver_connection.fail_localpart_availability(); + + let request = Request::post("/graphql").json(serde_json::json!({ + "query": USERNAME_AVAILABLE_QUERY, + "variables": { "username": "grace" }, + })); + + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert!(!response.errors.is_empty()); + assert!(response.data["usernameAvailable"].is_null()); +} + +/// Test that the `usernameAvailable` query is rate-limited per requester. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_username_available_rate_limited(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + // The default configuration allows a burst of 5 requests. + for _ in 0..5 { + let request = Request::post("/graphql").json(serde_json::json!({ + "query": USERNAME_AVAILABLE_QUERY, + "variables": { "username": "eve" }, + })); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert!(response.errors.is_empty(), "{:?}", response.errors); + } + + // The next one should be rate limited. + let request = Request::post("/graphql").json(serde_json::json!({ + "query": USERNAME_AVAILABLE_QUERY, + "variables": { "username": "eve" }, + })); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert_eq!( + response.errors[0]["message"].as_str(), + Some("Too many requests"), + "{:?}", + response.errors + ); +} + +/// Test that the `usernameAvailable` rate limit is keyed on the client IP, so +/// that one client can't exhaust another client's budget. +#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] +async fn test_username_available_rate_limited_per_ip(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + // Exhaust the burst of 5 requests for the first client + for _ in 0..5 { + let request = Request::post("/graphql") + .client_ip([1, 2, 3, 4].into()) + .json(serde_json::json!({ + "query": USERNAME_AVAILABLE_QUERY, + "variables": { "username": "eve" }, + })); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert!(response.errors.is_empty(), "{:?}", response.errors); + } + + let request = Request::post("/graphql") + .client_ip([1, 2, 3, 4].into()) + .json(serde_json::json!({ + "query": USERNAME_AVAILABLE_QUERY, + "variables": { "username": "eve" }, + })); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert_eq!( + response.errors[0]["message"].as_str(), + Some("Too many requests"), + "{:?}", + response.errors + ); + + // The second client still has its own budget + let request = Request::post("/graphql") + .client_ip([5, 6, 7, 8].into()) + .json(serde_json::json!({ + "query": USERNAME_AVAILABLE_QUERY, + "variables": { "username": "eve" }, + })); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let response: GraphQLResponse = response.json(); + assert!(response.errors.is_empty(), "{:?}", response.errors); +} diff --git a/crates/handlers/src/rate_limit.rs b/crates/handlers/src/rate_limit.rs index 246e2d5d4..6b9394223 100644 --- a/crates/handlers/src/rate_limit.rs +++ b/crates/handlers/src/rate_limit.rs @@ -39,6 +39,12 @@ pub enum RegistrationLimitedError { Requester(RequesterFingerprint), } +#[derive(Debug, Clone, thiserror::Error)] +pub enum UsernameAvailabilityLimitedError { + #[error("Too many username availability checks for requester {0}")] + Requester(RequesterFingerprint), +} + #[derive(Debug, Clone, thiserror::Error)] pub enum EmailAuthenticationLimitedError { #[error("Too many email authentication requests for requester {0}")] @@ -129,6 +135,7 @@ struct LimiterInner { email_authentication_emails_per_session: KeyedRateLimiter, email_authentication_attempt_per_session: KeyedRateLimiter, device_code_link_per_requester: KeyedRateLimiter, + username_availability_per_requester: KeyedRateLimiter, } impl LimiterInner { @@ -156,6 +163,9 @@ impl LimiterInner { config.email_authentication.attempt_per_session.to_quota()?, ), device_code_link_per_requester: RateLimiter::keyed(config.device_code_link.to_quota()?), + username_availability_per_requester: RateLimiter::keyed( + config.username_availability.to_quota()?, + ), }) } } @@ -202,6 +212,9 @@ impl Limiter { .email_authentication_attempt_per_session .retain_recent(); this.inner.device_code_link_per_requester.retain_recent(); + this.inner + .username_availability_per_requester + .retain_recent(); interval.tick().await; } @@ -275,6 +288,23 @@ impl Limiter { Ok(()) } + /// Check if a username availability check can be performed + /// + /// # Errors + /// + /// Returns an error if the operation is rate limited. + pub fn check_username_availability( + &self, + requester: RequesterFingerprint, + ) -> Result<(), UsernameAvailabilityLimitedError> { + self.inner + .username_availability_per_requester + .check_key(&requester) + .map_err(|_| UsernameAvailabilityLimitedError::Requester(requester))?; + + Ok(()) + } + /// Check if an email can be sent to the address for an email /// authentication session /// diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index f3e125b50..48d463f09 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -170,6 +170,17 @@ impl TestState { pub async fn from_pool_with_site_config( pool: PgPool, site_config: SiteConfig, + ) -> Result { + Self::from_pool_with_site_config_and_policy_data(pool, site_config, serde_json::json!({})) + .await + } + + /// Create a new test state from the given database pool, site config and + /// policy data + pub async fn from_pool_with_site_config_and_policy_data( + pool: PgPool, + site_config: SiteConfig, + policy_data: serde_json::Value, ) -> Result { // Make sure the rustls crypto provider and tracing are set up, as // building the HTTP client below requires a process-level provider to @@ -224,7 +235,7 @@ impl TestState { server_name: site_config.server_name.clone(), session_limit: site_config.session_limit.clone(), }, - serde_json::json!({}), + policy_data, ) .await?; diff --git a/crates/matrix/src/mock.rs b/crates/matrix/src/mock.rs index 2dbbf4b9a..837b3c046 100644 --- a/crates/matrix/src/mock.rs +++ b/crates/matrix/src/mock.rs @@ -1,10 +1,14 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. -use std::collections::{HashMap, HashSet}; +use std::{ + collections::{HashMap, HashSet}, + sync::atomic::{AtomicBool, Ordering}, +}; use anyhow::Context; use async_trait::async_trait; @@ -24,12 +28,13 @@ pub struct MockUser { pub locked: bool, } -/// A mock implementation of a [`HomeserverConnection`], which never fails and -/// doesn't do anything. +/// A mock implementation of a [`HomeserverConnection`], which doesn't do +/// anything and only fails when explicitly told to. pub struct HomeserverConnection { homeserver: String, users: RwLock>, reserved_localparts: RwLock>, + fail_localpart_availability: AtomicBool, } impl HomeserverConnection { @@ -46,6 +51,7 @@ impl HomeserverConnection { homeserver: homeserver.into(), users: RwLock::new(HashMap::new()), reserved_localparts: RwLock::new(HashSet::new()), + fail_localpart_availability: AtomicBool::new(false), } } @@ -53,6 +59,13 @@ impl HomeserverConnection { self.reserved_localparts.write().await.insert(localpart); } + /// Make [`crate::HomeserverConnection::is_localpart_available`] fail, to + /// exercise homeserver failures. + pub fn fail_localpart_availability(&self) { + self.fail_localpart_availability + .store(true, Ordering::Relaxed); + } + /// Like `query_user` but get the raw test state of the user. /// /// # Errors @@ -125,6 +138,11 @@ impl crate::HomeserverConnection for HomeserverConnection { } async fn is_localpart_available(&self, localpart: &str) -> Result { + anyhow::ensure!( + !self.fail_localpart_availability.load(Ordering::Relaxed), + "Mock homeserver failure while checking localpart availability" + ); + if self.reserved_localparts.read().await.contains(localpart) { return Ok(false); } diff --git a/docs/config.schema.json b/docs/config.schema.json index cbe8f67c8..b600968fc 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2033,6 +2033,18 @@ "$ref": "#/definitions/RateLimiterConfiguration" } ] + }, + "username_availability": { + "description": "Controls how often the registration form can check whether a username\nis available, based on source address.\n\nThe form checks the username as it is typed. This limit makes it\nharder to use those checks to find out which usernames exist.", + "default": { + "burst": 5, + "per_second": 1.0 + }, + "allOf": [ + { + "$ref": "#/definitions/RateLimiterConfiguration" + } + ] } } }, diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index e9f07ae34..8ad090d3c 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -648,6 +648,14 @@ rate_limiting: device_code_link: burst: 10 per_second: 0.016666 + + # Limits how often the registration form can check whether a username is + # available, based on source address. + # The form checks the username as it is typed. This limit makes it harder to + # use those checks to find out which usernames exist. + username_availability: + burst: 5 + per_second: 1.0 ``` ## `telemetry` diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 76242ea6d..4e81ff015 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -1185,6 +1185,14 @@ type Query { """ user(id: ID!): User """ + Check whether a username is available for registration. + + This query is accessible to anonymous users, as it is used during the + registration flow. Unlike `userByUsername`, it reveals whether a + username exists, so it is rate-limited per requester. + """ + usernameAvailable(username: String!): UsernameAvailability! + """ Fetch a user by its username. """ userByUsername(username: String!): User @@ -2470,6 +2478,46 @@ enum UserState { LOCKED } +""" +The result of a username availability check. +""" +type UsernameAvailability { + """ + Whether the username is available for registration. + """ + available: Boolean! + """ + If the username is not available, the reason why. + """ + reason: UsernameUnavailableReason + """ + If the username is invalid, the list of policy violation codes (e.g. + `username-too-long`, `username-all-numeric`, `username-banned`, + `username-not-allowed`, `username-invalid-chars`), so that clients can + localize the error message. + + This may be an empty list, when the registration policy rejected the + username for a reason that has no code attached to it. + """ + violationCodes: [String!] +} + +""" +Why a username is not available for registration. +""" +enum UsernameUnavailableReason { + """ + The username is already in use, either by an existing MAS user or + reserved by the homeserver. + """ + TAKEN + """ + The username does not pass the registration policy. See + `violationCodes` for the specific reasons. + """ + INVALID +} + """ Represents the current viewer """ From 3a1e62c72b2486f69e73b6ae7d0463a622fb4503 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 15 Sep 2026 13:26:36 +0200 Subject: [PATCH 2/2] Check the username availability as it is typed in the registration form Once the username input settles, the field queries `usernameAvailable` and reports next to it whether the name is available, taken or rejected by the registration policy. In the two-step layout, continuing is held back while a settled verdict says the name can't be used. The island now declares the GraphQL endpoint on its mount node and brings its own query client. --- crates/templates/src/context.rs | 2 + frontend/locales/en.json | 5 +- .../RegisterForm/RegisterFields.tsx | 129 +++++++++++++++++- frontend/src/entrypoints/register.tsx | 25 +++- frontend/src/gql/gql.ts | 6 + frontend/src/gql/graphql.ts | 51 +++++++ frontend/src/utils/registration.test.ts | 15 ++ frontend/src/utils/registration.ts | 4 + frontend/src/utils/useDebouncedValue.test.ts | 37 +++++ frontend/src/utils/useDebouncedValue.ts | 20 +++ templates/pages/register/index.html | 3 +- translations/en.json | 6 +- 12 files changed, 295 insertions(+), 8 deletions(-) create mode 100644 frontend/src/utils/useDebouncedValue.test.ts create mode 100644 frontend/src/utils/useDebouncedValue.ts diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 6a4699076..7b3418ef8 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -684,6 +684,7 @@ pub struct RegisterContext { providers: Vec, login_link: String, form: FormState, + graphql_endpoint: String, } impl TemplateContext for RegisterContext { @@ -717,6 +718,7 @@ impl RegisterContext { .collect(), login_link: url_builder.relative_url_for(&Login::from(post_auth_action.cloned())), form: FormState::default(), + graphql_endpoint: url_builder.relative_url_for(&GraphQL), } } diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 1269981ae..ad1df6c9e 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -255,7 +255,10 @@ "or_separator": "Or", "password_confirm_label": "Confirm password", "password_label": "Password", - "terms_of_service": "I agree to the Terms and Conditions" + "terms_of_service": "I agree to the Terms and Conditions", + "username_available": "This username is available", + "username_check_failed": "We couldn't check if this username is available", + "username_checking": "Checking availability…" }, "reset_cross_signing": { "cancelled": { diff --git a/frontend/src/components/RegisterForm/RegisterFields.tsx b/frontend/src/components/RegisterForm/RegisterFields.tsx index 15abd8390..a861c7e44 100644 --- a/frontend/src/components/RegisterForm/RegisterFields.tsx +++ b/frontend/src/components/RegisterForm/RegisterFields.tsx @@ -3,18 +3,106 @@ // 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 { useQuery } from "@tanstack/react-query"; +import { Form, InlineSpinner } from "@vector-im/compound-web"; import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { graphql } from "../../gql"; +import { graphqlRequest } from "../../graphql"; import { fieldErrorMessage, + isUsernameCheckable, normalizeUsername, + policyCodeMessage, type ServerError, VALID_LOCALPART_RE, } from "../../utils/registration"; +import { useDebouncedValue } from "../../utils/useDebouncedValue"; import FieldMessageSlot from "../FieldMessageSlot"; import PasswordComplexityFeedback from "../PasswordComplexityFeedback"; +const USERNAME_AVAILABLE_QUERY = graphql(` + query UsernameAvailable($username: String!) { + usernameAvailable(username: $username) { + available + reason + violationCodes + } + } +`); + +/** + * The settled result of the live availability check. Rendered inside an + * `aria-live` region, so it must only ever hold states the user has stopped + * typing into. + */ +const UsernameVerdict: React.FC<{ + checking: boolean; + checkFailed: boolean; + availability?: { + available: boolean; + reason?: string | null; + violationCodes?: readonly string[] | null; + }; +}> = ({ checking, checkFailed, availability }) => { + const { t } = useTranslation(); + + if (checking) { + const label = t("frontend.register.username_checking"); + return ( + + {/* The spinner carries the accessible name, so the live region doesn't + announce the same text twice */} + + + + ); + } + + if (checkFailed) { + return ( + + {t("frontend.register.username_check_failed")} + + ); + } + + if (!availability) return null; + + if (availability.available) { + return ( + + {t("frontend.register.username_available")} + + ); + } + + if (availability.reason === "INVALID") { + const messages = (availability.violationCodes ?? []) + .map((code) => policyCodeMessage(t, code)) + .filter((message): message is string => message !== undefined); + + return ( + <> + {(messages.length > 0 + ? messages + : [t("frontend.errors.username_invalid")] + ).map((message) => ( + + {message} + + ))} + + ); + } + + return ( + + {t("frontend.errors.username_taken")} + + ); +}; + export const UsernameField: React.FC<{ serverName: string; defaultValue: string; @@ -22,11 +110,38 @@ export const UsernameField: React.FC<{ }> = ({ serverName, defaultValue, serverErrors }) => { const { t } = useTranslation(); const [username, setUsername] = useState(defaultValue); + // The value the page was rendered with has been checked by the POST already + const [dirty, setDirty] = useState(false); const normalized = normalizeUsername(username); + const debounced = useDebouncedValue(normalized, 500); + const isDebouncePending = normalized !== debounced; + + const { data, isFetching, isError } = useQuery({ + queryKey: ["usernameAvailable", debounced], + queryFn: ({ signal }) => + graphqlRequest({ + query: USERNAME_AVAILABLE_QUERY, + variables: { username: debounced }, + signal, + }), + enabled: dirty && isUsernameCheckable(debounced), + }); + + const settled = dirty && !isDebouncePending; + const checking = + dirty && + isUsernameCheckable(normalized) && + (isFetching || isDebouncePending); + const availability = settled ? data?.usernameAvailable : undefined; return ( - 0}> + 0} + > {t("common.username")} { // Lowercase as the user types, so what is shown is what gets sent setUsername(e.target.value.toLowerCase()); + setDirty(true); }} onBlur={() => setUsername(normalizeUsername(username))} /> + {/* Outside the live region: it changes on every keystroke */} {`@${normalized || "—"}:${serverName}`} +
+ +
+ {t("frontend.errors.field_required")} diff --git a/frontend/src/entrypoints/register.tsx b/frontend/src/entrypoints/register.tsx index 86cbbe4ac..ed7328c18 100644 --- a/frontend/src/entrypoints/register.tsx +++ b/frontend/src/entrypoints/register.tsx @@ -3,11 +3,13 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import * as v from "valibot"; import { PasswordRegisterForm, ProvidersOnlyForm, } from "../components/RegisterForm"; +import { setGraphqlEndpoint } from "../graphql"; import { mountIsland } from "../utils/mountIsland"; import { fieldStateSchema, @@ -16,10 +18,23 @@ import { } from "../utils/registration"; import "./shared.css"; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // A username check is cheap to redo and pointless to retry: a failure + // just falls back to the neutral "couldn't check" message. + retry: false, + refetchOnWindowFocus: false, + staleTime: 60_000, + }, + }, +}); + // Parsed from the mount node's `data-*` attributes; structured values are // JSON-encoded by the template. const schema = v.object({ csrfToken: v.string(), + graphqlEndpoint: v.string(), captchaConfig: v.optional( v.pipe( v.string(), @@ -91,5 +106,13 @@ const RegisterPage: React.FC<{ data: Data }> = ({ data }) => void mountIsland({ id: "register-form", schema, - render: (data) => , + render: (data) => { + setGraphqlEndpoint(data.graphqlEndpoint); + + return ( + + + + ); + }, }); diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index eca477e5d..ff35b2e62 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -25,6 +25,7 @@ type Documents = { "\n fragment Footer_siteConfig on SiteConfig {\n id\n imprint\n tosUri\n policyUri\n }\n": typeof types.Footer_SiteConfigFragmentDoc, "\n query Footer {\n siteConfig {\n id\n ...Footer_siteConfig\n }\n }\n": typeof types.FooterDocument, "\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n": typeof types.OAuth2Session_SessionFragmentDoc, + "\n query UsernameAvailable($username: String!) {\n usernameAvailable(username: $username) {\n available\n reason\n violationCodes\n }\n }\n": typeof types.UsernameAvailableDocument, "\n fragment EndBrowserSessionButton_session on BrowserSession {\n id\n userAgent {\n name\n os\n model\n deviceType\n }\n }\n": typeof types.EndBrowserSessionButton_SessionFragmentDoc, "\n mutation EndBrowserSession($id: ID!) {\n endBrowserSession(input: { browserSessionId: $id }) {\n status\n browserSession {\n id\n }\n }\n }\n": typeof types.EndBrowserSessionDocument, "\n fragment EndCompatSessionButton_session on CompatSession {\n id\n userAgent {\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n": typeof types.EndCompatSessionButton_SessionFragmentDoc, @@ -81,6 +82,7 @@ const documents: Documents = { "\n fragment Footer_siteConfig on SiteConfig {\n id\n imprint\n tosUri\n policyUri\n }\n": types.Footer_SiteConfigFragmentDoc, "\n query Footer {\n siteConfig {\n id\n ...Footer_siteConfig\n }\n }\n": types.FooterDocument, "\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n": types.OAuth2Session_SessionFragmentDoc, + "\n query UsernameAvailable($username: String!) {\n usernameAvailable(username: $username) {\n available\n reason\n violationCodes\n }\n }\n": types.UsernameAvailableDocument, "\n fragment EndBrowserSessionButton_session on BrowserSession {\n id\n userAgent {\n name\n os\n model\n deviceType\n }\n }\n": types.EndBrowserSessionButton_SessionFragmentDoc, "\n mutation EndBrowserSession($id: ID!) {\n endBrowserSession(input: { browserSessionId: $id }) {\n status\n browserSession {\n id\n }\n }\n }\n": types.EndBrowserSessionDocument, "\n fragment EndCompatSessionButton_session on CompatSession {\n id\n userAgent {\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n": types.EndCompatSessionButton_SessionFragmentDoc, @@ -167,6 +169,10 @@ export function graphql(source: "\n query Footer {\n siteConfig {\n id\ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n"): typeof import('./graphql').OAuth2Session_SessionFragmentDoc; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query UsernameAvailable($username: String!) {\n usernameAvailable(username: $username) {\n available\n reason\n violationCodes\n }\n }\n"): typeof import('./graphql').UsernameAvailableDocument; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 63278096d..67c099065 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -183,6 +183,19 @@ export type UserRecoveryTicketStatus = /** The ticket is valid */ | 'VALID'; +/** Why a username is not available for registration. */ +export type UsernameUnavailableReason = + /** + * The username does not pass the registration policy. See + * `violationCodes` for the specific reasons. + */ + | 'INVALID' + /** + * The username is already in use, either by an existing MAS user or + * reserved by the homeserver. + */ + | 'TAKEN'; + export type AccountDeleteButton_UserFragment = { username: string, hasPassword: boolean, matrix: { mxid: string, displayName: string | null } } & { ' $fragmentName'?: 'AccountDeleteButton_UserFragment' }; export type AccountDeleteButton_SiteConfigFragment = { passwordLoginEnabled: boolean } & { ' $fragmentName'?: 'AccountDeleteButton_SiteConfigFragment' }; @@ -224,6 +237,13 @@ export type OAuth2Session_SessionFragment = ( & { ' $fragmentRefs'?: { 'EndOAuth2SessionButton_SessionFragment': EndOAuth2SessionButton_SessionFragment } } ) & { ' $fragmentName'?: 'OAuth2Session_SessionFragment' }; +export type UsernameAvailableQueryVariables = Exact<{ + username: string; +}>; + + +export type UsernameAvailableQuery = { usernameAvailable: { available: boolean, reason: UsernameUnavailableReason | null, violationCodes: Array | null } }; + export type EndBrowserSessionButton_SessionFragment = { id: string, userAgent: { name: string | null, os: string | null, model: string | null, deviceType: DeviceType } | null } & { ' $fragmentName'?: 'EndBrowserSessionButton_SessionFragment' }; export type EndBrowserSessionMutationVariables = Exact<{ @@ -937,6 +957,15 @@ export const FooterDocument = new TypedDocumentString(` tosUri policyUri }`) as unknown as TypedDocumentString; +export const UsernameAvailableDocument = new TypedDocumentString(` + query UsernameAvailable($username: String!) { + usernameAvailable(username: $username) { + available + reason + violationCodes + } +} + `) as unknown as TypedDocumentString; export const EndBrowserSessionDocument = new TypedDocumentString(` mutation EndBrowserSession($id: ID!) { endBrowserSession(input: {browserSessionId: $id}) { @@ -1596,6 +1625,28 @@ export const mockFooterQuery = (resolver: GraphQLResponseResolver { + * const { username } = variables; + * return HttpResponse.json({ + * data: { usernameAvailable } + * }) + * }, + * requestOptions + * ) + */ +export const mockUsernameAvailableQuery = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => + graphql.query( + 'UsernameAvailable', + resolver, + options + ) + /** * @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions)) * @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options)) diff --git a/frontend/src/utils/registration.test.ts b/frontend/src/utils/registration.test.ts index 0bf0fcb48..f804a62e5 100644 --- a/frontend/src/utils/registration.test.ts +++ b/frontend/src/utils/registration.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from "vitest"; import { fieldErrorMessage, formErrorMessage, + isUsernameCheckable, normalizeUsername, policyCodeMessage, } from "./registration"; @@ -24,6 +25,20 @@ describe("normalizeUsername()", () => { }); }); +describe("isUsernameCheckable()", () => { + it("accepts valid localparts", () => { + expect(isUsernameCheckable("alice")).toBe(true); + expect(isUsernameCheckable("a.b_c-d=e/f+g0")).toBe(true); + }); + + it("rejects empty and invalid localparts", () => { + expect(isUsernameCheckable("")).toBe(false); + expect(isUsernameCheckable("Alice")).toBe(false); + expect(isUsernameCheckable("alice bob")).toBe(false); + expect(isUsernameCheckable("élodie")).toBe(false); + }); +}); + describe("policyCodeMessage()", () => { it("translates known codes", () => { expect(policyCodeMessage(t, "username-too-short")).toBe( diff --git a/frontend/src/utils/registration.ts b/frontend/src/utils/registration.ts index bf1710b0d..95e93e932 100644 --- a/frontend/src/utils/registration.ts +++ b/frontend/src/utils/registration.ts @@ -39,6 +39,10 @@ export const VALID_LOCALPART_RE = /^[a-z0-9._=/+-]+$/; export const normalizeUsername = (value: string): string => value.trim().toLowerCase(); +/** Whether a normalized username is worth sending to the availability check. */ +export const isUsernameCheckable = (normalized: string): boolean => + normalized.length > 0 && VALID_LOCALPART_RE.test(normalized); + /** * 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. diff --git a/frontend/src/utils/useDebouncedValue.test.ts b/frontend/src/utils/useDebouncedValue.test.ts new file mode 100644 index 000000000..05760d8f9 --- /dev/null +++ b/frontend/src/utils/useDebouncedValue.test.ts @@ -0,0 +1,37 @@ +// 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. + +// @vitest-environment happy-dom + +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useDebouncedValue } from "./useDebouncedValue"; + +describe("useDebouncedValue()", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("returns the initial value straight away", () => { + const { result } = renderHook(() => useDebouncedValue("a", 500)); + expect(result.current).toBe("a"); + }); + + it("only settles once the value stops changing", () => { + const { result, rerender } = renderHook( + ({ value }) => useDebouncedValue(value, 500), + { initialProps: { value: "a" } }, + ); + + rerender({ value: "ab" }); + act(() => void vi.advanceTimersByTime(400)); + rerender({ value: "abc" }); + act(() => void vi.advanceTimersByTime(400)); + expect(result.current).toBe("a"); + + act(() => void vi.advanceTimersByTime(100)); + expect(result.current).toBe("abc"); + }); +}); diff --git a/frontend/src/utils/useDebouncedValue.ts b/frontend/src/utils/useDebouncedValue.ts new file mode 100644 index 000000000..90694948a --- /dev/null +++ b/frontend/src/utils/useDebouncedValue.ts @@ -0,0 +1,20 @@ +// 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 { useEffect, useState } from "react"; + +/** + * Returns `value` after it has stopped changing for `wait` milliseconds. + */ +export const useDebouncedValue = (value: T, wait: number): T => { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timeout = setTimeout(() => setDebounced(value), wait); + return () => clearTimeout(timeout); + }, [value, wait]); + + return debounced; +}; diff --git a/templates/pages/register/index.html b/templates/pages/register/index.html index c74e356fb..e7a2db99f 100644 --- a/templates/pages/register/index.html +++ b/templates/pages/register/index.html @@ -33,7 +33,8 @@ Please see LICENSE files in the repository root for full details. data-features='{{ features | tojson }}' data-form='{{ form | tojson }}' data-providers='{{ providers | tojson }}' - data-login-link="{{ login_link }}"> + data-login-link="{{ login_link }}" + data-graphql-endpoint="{{ graphql_endpoint }}">

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

{# Rough outline of the form, to keep the page from jumping once it mounts #} diff --git a/translations/en.json b/translations/en.json index e0bb7d9ca..7d307b9f1 100644 --- a/translations/en.json +++ b/translations/en.json @@ -442,11 +442,11 @@ }, "loading": "Loading…", "@loading": { - "context": "pages/register/index.html:37:26-42" + "context": "pages/register/index.html:38:26-42" }, "loading_failed": "Something went wrong while loading the page. Try reloading it.", "@loading_failed": { - "context": "app.html:28:9-32, pages/register/index.html:49:7-30" + "context": "app.html:28:9-32, pages/register/index.html:50:7-30" }, "login": { "call_to_register": "Don't have an account yet?", @@ -688,7 +688,7 @@ }, "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" + "context": "pages/register/index.html:57:34-71" }, "terms_of_service": "I agree to the Terms and Conditions", "@terms_of_service": {