mirror of
https://github.com/element-hq/matrix-authentication-service.git
synced 2026-10-23 15:13:37 +00:00
Interactive feedback for username availability in the registration form (#5977)
This commit is contained in:
21 files changed
+960
-14
No files matched your search
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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.
|
||||
violation_codes: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[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<UsernameAvailability, async_graphql::Error> {
|
||||
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<String> = 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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<Ulid>,
|
||||
email_authentication_attempt_per_session: KeyedRateLimiter<Ulid>,
|
||||
device_code_link_per_requester: KeyedRateLimiter<RequesterFingerprint>,
|
||||
username_availability_per_requester: KeyedRateLimiter<RequesterFingerprint>,
|
||||
}
|
||||
|
||||
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
|
||||
///
|
||||
|
||||
@@ -170,6 +170,17 @@ impl TestState {
|
||||
pub async fn from_pool_with_site_config(
|
||||
pool: PgPool,
|
||||
site_config: SiteConfig,
|
||||
) -> Result<Self, anyhow::Error> {
|
||||
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<Self, anyhow::Error> {
|
||||
// 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?;
|
||||
|
||||
|
||||
@@ -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<HashMap<String, MockUser>>,
|
||||
reserved_localparts: RwLock<HashSet<&'static str>>,
|
||||
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<bool, anyhow::Error> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -684,6 +684,7 @@ pub struct RegisterContext {
|
||||
providers: Vec<RegisterPageProvider>,
|
||||
login_link: String,
|
||||
form: FormState<RegisterFormField>,
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -255,7 +255,10 @@
|
||||
"or_separator": "Or",
|
||||
"password_confirm_label": "Confirm password",
|
||||
"password_label": "Password",
|
||||
"terms_of_service": "I agree to the <a>Terms and Conditions</a>"
|
||||
"terms_of_service": "I agree to the <a>Terms and Conditions</a>",
|
||||
"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": {
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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 (
|
||||
<Form.HelpMessage>
|
||||
{/* The spinner carries the accessible name, so the live region doesn't
|
||||
announce the same text twice */}
|
||||
<InlineSpinner role="img" aria-label={label} />
|
||||
<span aria-hidden="true">{label}</span>
|
||||
</Form.HelpMessage>
|
||||
);
|
||||
}
|
||||
|
||||
if (checkFailed) {
|
||||
return (
|
||||
<Form.HelpMessage>
|
||||
{t("frontend.register.username_check_failed")}
|
||||
</Form.HelpMessage>
|
||||
);
|
||||
}
|
||||
|
||||
if (!availability) return null;
|
||||
|
||||
if (availability.available) {
|
||||
return (
|
||||
<Form.SuccessMessage match="valid" forceMatch>
|
||||
{t("frontend.register.username_available")}
|
||||
</Form.SuccessMessage>
|
||||
);
|
||||
}
|
||||
|
||||
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) => (
|
||||
<Form.ErrorMessage key={message} match="badInput" forceMatch>
|
||||
{message}
|
||||
</Form.ErrorMessage>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.ErrorMessage match="badInput" forceMatch>
|
||||
{t("frontend.errors.username_taken")}
|
||||
</Form.ErrorMessage>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Form.Field name="username" serverInvalid={serverErrors.length > 0}>
|
||||
<Form.Field
|
||||
name="username"
|
||||
// Only actual POST-returned errors make the control invalid: flipping
|
||||
// this from the live check would steal the focus while typing
|
||||
serverInvalid={serverErrors.length > 0}
|
||||
>
|
||||
<Form.Label>{t("common.username")}</Form.Label>
|
||||
<Form.TextControl
|
||||
required
|
||||
@@ -37,12 +152,22 @@ export const UsernameField: React.FC<{
|
||||
onChange={(e) => {
|
||||
// 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 */}
|
||||
<Form.HelpMessage>{`@${normalized || "—"}:${serverName}`}</Form.HelpMessage>
|
||||
|
||||
<div aria-live="polite" aria-busy={checking}>
|
||||
<UsernameVerdict
|
||||
checking={checking}
|
||||
checkFailed={settled && isError}
|
||||
availability={availability}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Form.ErrorMessage match="valueMissing">
|
||||
{t("frontend.errors.field_required")}
|
||||
</Form.ErrorMessage>
|
||||
|
||||
@@ -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) => <RegisterPage data={data} />,
|
||||
render: (data) => {
|
||||
setGraphqlEndpoint(data.graphqlEndpoint);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RegisterPage data={data} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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<string> | 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<FooterQuery, FooterQueryVariables>;
|
||||
export const UsernameAvailableDocument = new TypedDocumentString(`
|
||||
query UsernameAvailable($username: String!) {
|
||||
usernameAvailable(username: $username) {
|
||||
available
|
||||
reason
|
||||
violationCodes
|
||||
}
|
||||
}
|
||||
`) as unknown as TypedDocumentString<UsernameAvailableQuery, UsernameAvailableQueryVariables>;
|
||||
export const EndBrowserSessionDocument = new TypedDocumentString(`
|
||||
mutation EndBrowserSession($id: ID!) {
|
||||
endBrowserSession(input: {browserSessionId: $id}) {
|
||||
@@ -1596,6 +1625,28 @@ export const mockFooterQuery = (resolver: GraphQLResponseResolver<FooterQuery, F
|
||||
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))
|
||||
* @see https://mswjs.io/docs/basics/response-resolver
|
||||
* @example
|
||||
* mockUsernameAvailableQuery(
|
||||
* ({ query, variables }) => {
|
||||
* const { username } = variables;
|
||||
* return HttpResponse.json({
|
||||
* data: { usernameAvailable }
|
||||
* })
|
||||
* },
|
||||
* requestOptions
|
||||
* )
|
||||
*/
|
||||
export const mockUsernameAvailableQuery = (resolver: GraphQLResponseResolver<UsernameAvailableQuery, UsernameAvailableQueryVariables>, options?: RequestHandlerOptions) =>
|
||||
graphql.query<UsernameAvailableQuery, UsernameAvailableQueryVariables>(
|
||||
'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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 = <T>(value: T, wait: number): T => {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setDebounced(value), wait);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [value, wait]);
|
||||
|
||||
return debounced;
|
||||
};
|
||||
@@ -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 }}">
|
||||
<p class="sr-only">{{ _("mas.loading") }}</p>
|
||||
|
||||
{# Rough outline of the form, to keep the page from jumping once it mounts #}
|
||||
|
||||
@@ -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 <a href=\"%s\" data-kind=\"primary\" class=\"cpd-link\">Terms and Conditions</a>",
|
||||
"@terms_of_service": {
|
||||
|
||||
Reference in new issue
Block a user