Rewrite the password registration form in React

The server-rendered registration form is replaced by a React-based
component which talks to the GraphQL API to validate the username as it
is typed, renders the CAPTCHA in-page, and gives live password
complexity feedback. Server-side validation errors are threaded into
the form through the mount node, so a rejected submission re-renders
with the same messages the old template showed.

Registering now requires JavaScript: the <noscript> block shows a
notice and a sign-in link instead of a duplicate form, which could not
work anyway when a CAPTCHA is configured. While the script loads, the
page shows a skeleton of the form to avoid layout shifts, and a hidden
error notice is revealed if it fails to boot.
This commit is contained in:
Quentin Gliech
2026-08-11 17:44:22 +02:00
parent 6094cc873d
commit c0518aed43
24 changed files with 1193 additions and 362 deletions
+68 -105
View File
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
@@ -96,7 +97,7 @@ pub(crate) async fn get(
.into_response());
}
let mut ctx = PasswordRegisterContext::default();
let mut ctx = PasswordRegisterContext::new(&url_builder);
// If we got a username from the query string, use it to prefill the form
if let Some(username) = query.username {
@@ -319,7 +320,7 @@ pub(crate) async fn post(
if !state.is_valid() {
let content = render(
locale,
PasswordRegisterContext::default().with_form_state(state),
PasswordRegisterContext::new(&url_builder).with_form_state(state),
query,
csrf_token,
&mut repo,
@@ -447,6 +448,16 @@ mod tests {
},
};
/// Extract the CSRF token the form island was booted with
fn csrf_token(body: &str) -> &str {
body.split("data-csrf-token=\"")
.nth(1)
.unwrap()
.split('"')
.next()
.unwrap()
}
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_password_disabled(pool: PgPool) {
setup();
@@ -494,15 +505,7 @@ mod tests {
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
// Extract the CSRF token from the response body
let csrf_token = response
.body()
.split("name=\"csrf\" value=\"")
.nth(1)
.unwrap()
.split('\"')
.next()
.unwrap();
let csrf_token = csrf_token(response.body());
// Submit the registration form
let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query())
@@ -560,15 +563,7 @@ mod tests {
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
// Extract the CSRF token from the response body
let csrf_token = response
.body()
.split("name=\"csrf\" value=\"")
.nth(1)
.unwrap()
.split('\"')
.next()
.unwrap();
let csrf_token = csrf_token(response.body());
// Submit the registration form
let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query())
@@ -584,7 +579,12 @@ mod tests {
let response = state.request(request).await;
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
assert!(response.body().contains("Password fields don't match"));
// The form state is handed to the client-side form as JSON
assert!(
response.body().contains("password_mismatch"),
"response body: {}",
response.body()
);
}
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
@@ -601,15 +601,7 @@ mod tests {
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
// Extract the CSRF token from the response body
let csrf_token = response
.body()
.split("name=\"csrf\" value=\"")
.nth(1)
.unwrap()
.split('\"')
.next()
.unwrap();
let csrf_token = csrf_token(response.body());
// Submit the registration form
let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query())
@@ -626,7 +618,7 @@ mod tests {
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
assert!(
response.body().contains("Username is too long"),
response.body().contains("\"code\":\"username-too-long\""),
"response body: {}",
response.body()
);
@@ -656,15 +648,7 @@ mod tests {
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
// Extract the CSRF token from the response body
let csrf_token = response
.body()
.split("name=\"csrf\" value=\"")
.nth(1)
.unwrap()
.split('\"')
.next()
.unwrap();
let csrf_token = csrf_token(response.body());
// Submit the registration form
let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query())
@@ -680,7 +664,13 @@ mod tests {
let response = state.request(request).await;
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
assert!(response.body().contains("This username is already taken"));
assert!(
response
.body()
.contains("\"username\":{\"errors\":[{\"kind\":\"exists\"}]"),
"response body: {}",
response.body()
);
}
/// When the username is already reserved on the homeserver, it should give
@@ -699,15 +689,7 @@ mod tests {
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
// Extract the CSRF token from the response body
let csrf_token = response
.body()
.split("name=\"csrf\" value=\"")
.nth(1)
.unwrap()
.split('\"')
.next()
.unwrap();
let csrf_token = csrf_token(response.body());
// Reserve "john" on the homeserver
state.homeserver_connection.reserve_localpart("john").await;
@@ -726,7 +708,13 @@ mod tests {
let response = state.request(request).await;
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
assert!(response.body().contains("This username is already taken"));
assert!(
response
.body()
.contains("\"username\":{\"errors\":[{\"kind\":\"exists\"}]"),
"response body: {}",
response.body()
);
}
/// Test registration without email when email is not required
@@ -752,15 +740,7 @@ mod tests {
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
// Extract the CSRF token from the response body
let csrf_token = response
.body()
.split("name=\"csrf\" value=\"")
.nth(1)
.unwrap()
.split('\"')
.next()
.unwrap();
let csrf_token = csrf_token(response.body());
// Submit the registration form without email
let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query())
@@ -821,15 +801,7 @@ mod tests {
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
// Extract the CSRF token from the response body
let csrf_token = response
.body()
.split("name=\"csrf\" value=\"")
.nth(1)
.unwrap()
.split('\"')
.next()
.unwrap();
let csrf_token = csrf_token(response.body());
// Submit the registration form with valid email
let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query())
@@ -891,15 +863,7 @@ mod tests {
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
// Extract the CSRF token from the response body
let csrf_token = response
.body()
.split("name=\"csrf\" value=\"")
.nth(1)
.unwrap()
.split('\"')
.next()
.unwrap();
let csrf_token = csrf_token(response.body());
// Submit the registration form without email
let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query())
@@ -916,9 +880,14 @@ mod tests {
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
// Check that the response contains an error about the email field
let body = response.body();
assert!(body.contains("email") || body.contains("Email"));
// Check that the response contains an error on the email field
assert!(
response
.body()
.contains("\"email\":{\"errors\":[{\"kind\":\"required\"}]"),
"response body: {}",
response.body()
);
// Ensure no registration was created
let mut repo = state.repository().await.unwrap();
@@ -949,15 +918,7 @@ mod tests {
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
// Extract the CSRF token from the response body
let csrf_token = response
.body()
.split("name=\"csrf\" value=\"")
.nth(1)
.unwrap()
.split('\"')
.next()
.unwrap();
let csrf_token = csrf_token(response.body());
// Submit the registration form with empty email
let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query())
@@ -975,9 +936,14 @@ mod tests {
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
// Check that the response contains an error about the email field
let body = response.body();
assert!(body.contains("email") || body.contains("Email"));
// Check that the response contains an error on the email field
assert!(
response
.body()
.contains("\"email\":{\"errors\":[{\"kind\":\"required\"}]"),
"response body: {}",
response.body()
);
// Ensure no registration was created
let mut repo = state.repository().await.unwrap();
@@ -1008,15 +974,7 @@ mod tests {
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
// Extract the CSRF token from the response body
let csrf_token = response
.body()
.split("name=\"csrf\" value=\"")
.nth(1)
.unwrap()
.split('\"')
.next()
.unwrap();
let csrf_token = csrf_token(response.body());
// Submit the registration form with invalid email
let request = Request::post(&*mas_router::PasswordRegister::default().path_and_query())
@@ -1034,9 +992,14 @@ mod tests {
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
// Check that the response contains an error about the email field
let body = response.body();
assert!(body.contains("email") || body.contains("Email"));
// Check that the response contains an error on the email field
assert!(
response
.body()
.contains("\"email\":{\"errors\":[{\"kind\":\"invalid\"}]"),
"response body: {}",
response.body()
);
// Ensure no registration was created
let mut repo = state.repository().await.unwrap();
+15 -5
View File
@@ -689,10 +689,11 @@ impl RegisterContext {
}
/// Context used by the `password_register.html` template
#[derive(Serialize, Default)]
#[derive(Serialize)]
pub struct PasswordRegisterContext {
form: FormState<RegisterFormField>,
next: Option<PostAuthContext>,
graphql_endpoint: String,
}
impl TemplateContext for PasswordRegisterContext {
@@ -704,15 +705,24 @@ impl TemplateContext for PasswordRegisterContext {
where
Self: Sized,
{
let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None);
// TODO: samples with errors
sample_list(vec![PasswordRegisterContext {
form: FormState::default(),
next: None,
}])
sample_list(vec![PasswordRegisterContext::new(&url_builder)])
}
}
impl PasswordRegisterContext {
/// Create a new context, resolving the GraphQL endpoint used by the
/// client-side form from the given [`UrlBuilder`]
#[must_use]
pub fn new(url_builder: &UrlBuilder) -> Self {
Self {
form: FormState::default(),
next: None,
graphql_endpoint: url_builder.relative_url_for(&GraphQL),
}
}
/// Add an error on the registration form
#[must_use]
pub fn with_form_state(self, form: FormState<RegisterFormField>) -> Self {
+2
View File
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
@@ -49,6 +50,7 @@ impl SiteConfigExt for SiteConfig {
password_login: self.password_login_enabled,
account_recovery: self.account_recovery_allowed,
login_with_email_allowed: self.login_with_email_allowed,
minimum_password_complexity: self.minimum_password_complexity,
}
}
}
+6
View File
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
@@ -29,6 +30,9 @@ pub struct SiteFeatures {
/// Whether users can log in with their email address.
pub login_with_email_allowed: bool,
/// The minimum password complexity score required for new passwords.
pub minimum_password_complexity: u8,
}
impl Object for SiteFeatures {
@@ -41,6 +45,7 @@ impl Object for SiteFeatures {
"password_login" => Some(Value::from(self.password_login)),
"account_recovery" => Some(Value::from(self.account_recovery)),
"login_with_email_allowed" => Some(Value::from(self.login_with_email_allowed)),
"minimum_password_complexity" => Some(Value::from(self.minimum_password_complexity)),
_ => None,
}
}
@@ -52,6 +57,7 @@ impl Object for SiteFeatures {
"password_login",
"account_recovery",
"login_with_email_allowed",
"minimum_password_complexity",
])
}
}
+1
View File
@@ -517,6 +517,7 @@ mod tests {
password_registration_email_required: true,
account_recovery: true,
login_with_email_allowed: true,
minimum_password_complexity: 3,
};
let vite_manifest_path =
Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend/dist/manifest.json");
+32 -2
View File
@@ -27,12 +27,14 @@
},
"common": {
"e2ee": "End-to-end encryption",
"email_address": "Email address",
"loading": "Loading…",
"next": "Next",
"password": "Password",
"previous": "Previous",
"saved": "Saved",
"saving": "Saving…"
"saving": "Saving…",
"username": "Username"
},
"frontend": {
"account": {
@@ -117,8 +119,25 @@
"title": "Something went wrong"
},
"errors": {
"captcha": "CAPTCHA verification failed, please try again",
"email_banned": "Email is banned by the server policy",
"email_domain_banned": "Email domain is banned by the server policy",
"email_domain_not_allowed": "Email domain is not allowed by the server policy",
"email_not_allowed": "Email is not allowed by the server policy",
"field_invalid": "This field is invalid",
"field_required": "This field is required",
"rate_limit_exceeded": "You've made too many requests in a short period. Please wait a few minutes and try again."
"invalid_email": "Please enter a valid email address",
"password_mismatch": "Password fields don't match",
"rate_limit_exceeded": "You've made too many requests in a short period. Please wait a few minutes and try again.",
"unspecified": "Something went wrong. Please try again.",
"username_all_numeric": "Username cannot consist solely of numbers",
"username_banned": "Username is banned by the server policy",
"username_invalid": "Username can only contain lowercase letters, numbers, and the characters . _ - + / =",
"username_invalid_chars": "Username contains invalid characters. Use lowercase letters, numbers, dashes and underscores only.",
"username_not_allowed": "Username is not allowed by the server policy",
"username_taken": "This username is already taken",
"username_too_long": "Username is too long",
"username_too_short": "Username is too short"
},
"last_active": {
"active_date": "Active {{relativeDate}}",
@@ -230,6 +249,17 @@
"word_by_itself": "Single words are easy to guess."
}
},
"register": {
"call_to_login": "Already have an account? Sign in",
"captcha_incomplete": "Please complete the CAPTCHA challenge before continuing",
"captcha_loading": "The CAPTCHA is still loading, please wait a moment",
"password_confirm_label": "Confirm password",
"password_label": "Password",
"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": {
"description_1": "You can close this window and go back to the app to continue.",
@@ -0,0 +1,49 @@
// Copyright 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
import { Form, Progress } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import { usePasswordComplexity } from "../utils/usePasswordComplexity";
const TINTS = ["red", "red", "orange", "lime", "green"] as const;
/**
* Strength meter, improvement hints and too-weak error for a new password.
* Renders as a set of `Form.Field` children, so it must be used inside one.
*/
const PasswordComplexityFeedback: React.FC<{
password: string;
minimumPasswordComplexity: number;
}> = ({ password, minimumPasswordComplexity }) => {
const { t } = useTranslation();
const complexity = usePasswordComplexity(password);
return (
<>
<Progress
size="sm"
getValueLabel={() => complexity.scoreText}
tint={password === "" ? undefined : TINTS[complexity.score]}
max={4}
value={complexity.score}
/>
{complexity.improvementsText.map((suggestion) => (
<Form.HelpMessage key={suggestion}>{suggestion}</Form.HelpMessage>
))}
{complexity.score < minimumPasswordComplexity && (
<Form.ErrorMessage match={() => true}>
{t("frontend.password_strength.too_weak")}
</Form.ErrorMessage>
)}
</>
);
};
export default PasswordComplexityFeedback;
@@ -1,84 +1,27 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
import { Form, Progress } from "@vector-im/compound-web";
import { useDeferredValue, useEffect, useRef, useState } from "react";
import { Form } from "@vector-im/compound-web";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { type FragmentType, graphql, useFragment } from "../gql";
import type { PasswordComplexity } from "../utils/password_complexity";
const CONFIG_FRAGMENT = graphql(/* GraphQL */ `
fragment PasswordCreationDoubleInput_siteConfig on SiteConfig {
id
minimumPasswordComplexity
}
`);
// This will load the password complexity module lazily,
// so that it doesn't block the initial render and can be code-split
const loadPromise = import("../utils/password_complexity").then(
({ estimatePasswordComplexity }) => estimatePasswordComplexity,
);
const usePasswordComplexity = (password: string): PasswordComplexity => {
const { t } = useTranslation();
const [result, setResult] = useState<PasswordComplexity>({
score: 0,
scoreText: t("frontend.password_strength.placeholder"),
improvementsText: [],
});
const deferredPassword = useDeferredValue(password);
useEffect(() => {
if (deferredPassword === "") {
setResult({
score: 0,
scoreText: t("frontend.password_strength.placeholder"),
improvementsText: [],
});
} else {
loadPromise
.then((estimatePasswordComplexity) =>
estimatePasswordComplexity(deferredPassword, t),
)
.then((response) => setResult(response));
}
}, [deferredPassword, t]);
return result;
};
import PasswordComplexityFeedback from "./PasswordComplexityFeedback";
export default function PasswordCreationDoubleInput({
siteConfig,
forceShowNewPasswordInvalid,
minimumPasswordComplexity,
forceShowNewPasswordInvalid = false,
}: {
siteConfig: FragmentType<typeof CONFIG_FRAGMENT>;
forceShowNewPasswordInvalid: boolean;
minimumPasswordComplexity: number;
forceShowNewPasswordInvalid?: boolean;
}): React.ReactElement {
const { t } = useTranslation();
const { minimumPasswordComplexity } = useFragment(
CONFIG_FRAGMENT,
siteConfig,
);
const newPasswordRef = useRef<HTMLInputElement>(null);
const newPasswordAgainRef = useRef<HTMLInputElement>(null);
const [newPassword, setNewPassword] = useState("");
const passwordComplexity = usePasswordComplexity(newPassword);
let passwordStrengthTint: "red" | "orange" | "lime" | "green" | undefined;
if (newPassword === "") {
passwordStrengthTint = undefined;
} else {
passwordStrengthTint = (["red", "red", "orange", "lime", "green"] as const)[
passwordComplexity.score
];
}
return (
<>
<Form.Field name="new_password">
@@ -89,7 +32,6 @@ export default function PasswordCreationDoubleInput({
<Form.PasswordControl
required
autoComplete="new-password"
ref={newPasswordRef}
onBlur={() =>
newPasswordAgainRef.current?.value &&
newPasswordAgainRef.current?.reportValidity()
@@ -97,24 +39,11 @@ export default function PasswordCreationDoubleInput({
onChange={(e) => setNewPassword(e.target.value)}
/>
<Progress
size="sm"
getValueLabel={() => passwordComplexity.scoreText}
tint={passwordStrengthTint}
max={4}
value={passwordComplexity.score}
<PasswordComplexityFeedback
password={newPassword}
minimumPasswordComplexity={minimumPasswordComplexity}
/>
{passwordComplexity.improvementsText.map((suggestion) => (
<Form.HelpMessage key={suggestion}>{suggestion}</Form.HelpMessage>
))}
{passwordComplexity.score < minimumPasswordComplexity && (
<Form.ErrorMessage match={() => true}>
{t("frontend.password_strength.too_weak")}
</Form.ErrorMessage>
)}
<Form.ErrorMessage match="valueMissing">
{t("frontend.errors.field_required")}
</Form.ErrorMessage>
@@ -0,0 +1,492 @@
// 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 { QueryClient, useQuery } from "@tanstack/react-query";
import { Button, Form, InlineSpinner } from "@vector-im/compound-web";
import { useCallback, useRef, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import * as v from "valibot";
import { CaptchaSection } from "../components/Captcha";
import PasswordComplexityFeedback from "../components/PasswordComplexityFeedback";
import { graphql } from "../gql";
import { graphqlRequest } from "../graphql";
import { mountIsland } from "../utils/mountIsland";
import {
fieldErrorMessage,
formErrorMessage,
isUsernameCheckable,
normalizeUsername,
policyCodeMessage,
type ServerError,
serverErrorSchema,
VALID_LOCALPART_RE,
} from "../utils/registration";
import { useDebouncedValue } from "../utils/useDebouncedValue";
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,
},
},
});
const fieldStateSchema = v.object({
value: v.optional(v.nullable(v.string())),
errors: v.array(serverErrorSchema),
});
// 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(),
loginLink: v.string(),
captchaConfig: v.optional(
v.pipe(
v.string(),
v.parseJson(),
v.object({
service: v.picklist([
"recaptcha_v2",
"cloudflare_turnstile",
"hcaptcha",
]),
site_key: v.string(),
}),
),
),
branding: v.pipe(
v.string(),
v.parseJson(),
v.object({
server_name: v.string(),
tos_uri: v.optional(v.nullable(v.string())),
}),
),
features: v.pipe(
v.string(),
v.parseJson(),
v.object({
password_registration_email_required: v.boolean(),
minimum_password_complexity: v.number(),
}),
),
form: v.pipe(
v.string(),
v.parseJson(),
v.object({
errors: v.array(serverErrorSchema),
fields: v.record(v.string(), fieldStateSchema),
}),
),
});
type Data = v.InferOutput<typeof schema>;
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>
);
};
const UsernameField: React.FC<{
serverName: string;
defaultValue: string;
serverErrors: ServerError[];
}> = ({ serverName, defaultValue, serverErrors }) => {
const { t } = useTranslation();
const [username, setUsername] = useState(defaultValue);
// Until the user edits the field, what the POST came back with is the truth
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);
return (
<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={!dirty && serverErrors.length > 0}
>
<Form.Label>{t("common.username")}</Form.Label>
<Form.TextControl
required
autoComplete="username"
autoCorrect="off"
autoCapitalize="none"
value={username}
onChange={(e) => {
// Lowercase as the user types, so what is shown is what gets sent
setUsername(e.target.value.toLocaleLowerCase());
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={settled ? data?.usernameAvailable : undefined}
/>
</div>
<Form.ErrorMessage match="valueMissing">
{t("frontend.errors.field_required")}
</Form.ErrorMessage>
<Form.ErrorMessage
match={(value) => {
const n = normalizeUsername(value);
return n.length > 0 && !VALID_LOCALPART_RE.test(n);
}}
>
{t("frontend.errors.username_invalid")}
</Form.ErrorMessage>
{!dirty &&
serverErrors.map((error, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static
<Form.ErrorMessage key={`${error.kind}-${index}`}>
{fieldErrorMessage(t, error)}
</Form.ErrorMessage>
))}
</Form.Field>
);
};
const PasswordFields: React.FC<{
minimumPasswordComplexity: number;
serverErrors: ServerError[];
confirmServerErrors: ServerError[];
}> = ({ minimumPasswordComplexity, serverErrors, confirmServerErrors }) => {
const { t } = useTranslation();
const confirmRef = useRef<HTMLInputElement>(null);
const [password, setPassword] = useState("");
return (
<>
<Form.Field name="password" serverInvalid={serverErrors.length > 0}>
<Form.Label>{t("frontend.register.password_label")}</Form.Label>
<Form.PasswordControl
required
autoComplete="new-password"
// Re-check the confirmation once the first field settles, so a stale
// "no match" error doesn't stick around
onBlur={() =>
confirmRef.current?.value && confirmRef.current.reportValidity()
}
onChange={(e) => setPassword(e.target.value)}
/>
<PasswordComplexityFeedback
password={password}
minimumPasswordComplexity={minimumPasswordComplexity}
/>
<Form.ErrorMessage match="valueMissing">
{t("frontend.errors.field_required")}
</Form.ErrorMessage>
{serverErrors.map((error, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static
<Form.ErrorMessage key={`${error.kind}-${index}`}>
{fieldErrorMessage(t, error)}
</Form.ErrorMessage>
))}
</Form.Field>
<Form.Field
name="password_confirm"
serverInvalid={confirmServerErrors.length > 0}
>
<Form.Label>{t("frontend.register.password_confirm_label")}</Form.Label>
<Form.PasswordControl
required
ref={confirmRef}
autoComplete="new-password"
/>
<Form.ErrorMessage match="valueMissing">
{t("frontend.errors.field_required")}
</Form.ErrorMessage>
<Form.ErrorMessage
match={(value, form) => value !== form.get("password")}
>
{t("frontend.password_change.passwords_no_match")}
</Form.ErrorMessage>
<Form.SuccessMessage match="valid">
{t("frontend.password_change.passwords_match")}
</Form.SuccessMessage>
{confirmServerErrors.map((error, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static
<Form.ErrorMessage key={`${error.kind}-${index}`}>
{fieldErrorMessage(t, error)}
</Form.ErrorMessage>
))}
</Form.Field>
</>
);
};
const PasswordRegisterForm: React.FC<{ data: Data }> = ({ data }) => {
const { t } = useTranslation();
const { fields, errors: formErrors } = data.form;
// `null` until the widget has mounted and told us it is ready; `true` right
// away when there is no captcha to solve.
const [captchaValid, setCaptchaValid] = useState<boolean | null>(
data.captchaConfig ? null : true,
);
const [captchaError, setCaptchaError] = useState<string | null>(null);
const onCaptchaValidChange = useCallback((valid: boolean) => {
setCaptchaValid(valid);
if (valid) setCaptchaError(null);
}, []);
return (
<Form.Root
method="POST"
onSubmit={(e) => {
// Enter-to-submit bypasses the field's onBlur, so normalize here too.
// Writing to the DOM is safe: the page navigates away right after.
const username = e.currentTarget.elements.namedItem("username");
if (username instanceof HTMLInputElement) {
username.value = normalizeUsername(username.value);
}
if (captchaValid === null) {
e.preventDefault();
setCaptchaError(t("frontend.register.captcha_loading"));
return;
}
if (!captchaValid) {
e.preventDefault();
setCaptchaError(t("frontend.register.captcha_incomplete"));
return;
}
setCaptchaError(null);
}}
>
<input type="hidden" name="csrf" value={data.csrfToken} />
{formErrors.map((error, index) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static
key={`${error.kind}-${index}`}
role="alert"
className="text-critical font-medium"
>
{formErrorMessage(t, error)}
</div>
))}
<UsernameField
serverName={data.branding.server_name}
defaultValue={fields.username?.value ?? ""}
serverErrors={fields.username?.errors ?? []}
/>
{data.features.password_registration_email_required && (
<Form.Field name="email" serverInvalid={!!fields.email?.errors.length}>
<Form.Label>{t("common.email_address")}</Form.Label>
<Form.TextControl
type="email"
required
autoComplete="email"
defaultValue={fields.email?.value ?? ""}
/>
<Form.ErrorMessage match="typeMismatch">
{t("frontend.errors.invalid_email")}
</Form.ErrorMessage>
<Form.ErrorMessage match="valueMissing">
{t("frontend.errors.field_required")}
</Form.ErrorMessage>
{fields.email?.errors.map((error, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static
<Form.ErrorMessage key={`${error.kind}-${index}`}>
{fieldErrorMessage(t, error)}
</Form.ErrorMessage>
))}
</Form.Field>
)}
<PasswordFields
minimumPasswordComplexity={data.features.minimum_password_complexity}
serverErrors={fields.password?.errors ?? []}
confirmServerErrors={fields.password_confirm?.errors ?? []}
/>
{data.branding.tos_uri && (
<Form.InlineField
name="accept_terms"
control={<Form.CheckboxControl required value="on" />}
serverInvalid={!!fields.accept_terms?.errors.length}
>
<Form.Label>
<Trans
i18nKey="frontend.register.terms_of_service"
components={{
a: (
// biome-ignore lint/a11y/useAnchorContent: content filled by Trans
<a
href={data.branding.tos_uri}
target="_blank"
rel="noreferrer"
className="cpd-link"
data-kind="primary"
/>
),
}}
/>
</Form.Label>
<Form.ErrorMessage match="valueMissing">
{t("frontend.errors.field_required")}
</Form.ErrorMessage>
{fields.accept_terms?.errors.map((error, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static
<Form.ErrorMessage key={`${error.kind}-${index}`}>
{fieldErrorMessage(t, error)}
</Form.ErrorMessage>
))}
</Form.InlineField>
)}
<CaptchaSection
config={data.captchaConfig}
onValidChange={onCaptchaValidChange}
/>
{captchaError && (
<div role="alert" className="text-critical font-medium">
{captchaError}
</div>
)}
<Form.Submit>{t("action.continue")}</Form.Submit>
<Button as="a" kind="tertiary" size="lg" href={data.loginLink}>
{t("frontend.register.call_to_login")}
</Button>
</Form.Root>
);
};
void mountIsland({
id: "password-register-form",
schema,
queryClient,
children: (data) => <PasswordRegisterForm data={data} />,
});
+60 -10
View File
@@ -1,4 +1,5 @@
/* Copyright 2024, 2025 New Vector Ltd.
/* Copyright 2025, 2026 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
* Copyright 2022-2024 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
@@ -72,15 +73,64 @@
color: var(--cpd-color-text-secondary);
}
.captcha-noscript {
color: var(--cpd-color-text-critical-primary);
font: var(--cpd-font-body-md-semibold);
letter-spacing: var(--cpd-font-letter-spacing-body-md);
background-color: var(--cpd-color-bg-critical-subtle);
border: 1px solid var(--cpd-color-border-critical-subtle);
border-radius: var(--cpd-space-2x);
text-align: justify;
padding: var(--cpd-space-4x);
@keyframes skeleton-pulse {
50% {
opacity: 0.45;
}
}
/* Rough stand-in for the React registration form: a couple of field rows and a
* submit button, so the page has something of about the right height to show
* while the island boots. */
.register-skeleton {
display: flex;
flex-direction: column;
gap: var(--cpd-space-5x);
& .field {
display: flex;
flex-direction: column;
gap: var(--cpd-space-1x);
}
& .label,
& .control,
& .submit {
display: block;
background-color: var(--cpd-color-bg-subtle-secondary);
border-radius: var(--cpd-space-2x);
animation: skeleton-pulse 1.6s ease-in-out infinite;
}
& .label {
inline-size: 30%;
block-size: calc(
var(--cpd-font-size-body-md) *
var(--cpd-font-line-height-regular)
);
}
& .control {
/* Matches .cpd-text-control: line height, plus its padding and border */
block-size: calc(
var(--cpd-font-size-body-md) *
var(--cpd-font-line-height-regular) +
2 *
var(--cpd-space-3x) +
2px
);
}
& .submit {
block-size: var(--cpd-space-12x);
border-radius: var(--cpd-radius-pill-effect);
}
}
@media (prefers-reduced-motion: reduce) {
.register-skeleton :is(.label, .control, .submit) {
animation: none;
}
}
.consent-client-icon {
+12 -12
View File
@@ -25,7 +25,6 @@ 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 fragment PasswordCreationDoubleInput_siteConfig on SiteConfig {\n id\n minimumPasswordComplexity\n }\n": typeof types.PasswordCreationDoubleInput_SiteConfigFragmentDoc,
"\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,
@@ -49,6 +48,7 @@ type Documents = {
"\n fragment UserEmailList_user on User {\n hasPassword\n }\n": typeof types.UserEmailList_UserFragmentDoc,
"\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n }\n": typeof types.UserEmailList_SiteConfigFragmentDoc,
"\n fragment BrowserSessionsOverview_user on User {\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n": typeof types.BrowserSessionsOverview_UserFragmentDoc,
"\n query UsernameAvailable($username: String!) {\n usernameAvailable(username: $username) {\n available\n reason\n violationCodes\n }\n }\n": typeof types.UsernameAvailableDocument,
"\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n": typeof types.UserProfileDocument,
"\n query PlanManagementTab {\n siteConfig {\n planManagementIframeUri\n }\n }\n": typeof types.PlanManagementTabDocument,
"\n query BrowserSessionList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n $lastActive: DateFilter\n ) {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n\n user {\n id\n\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n lastActive: $lastActive\n state: ACTIVE\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n }\n": typeof types.BrowserSessionListDocument,
@@ -62,11 +62,11 @@ type Documents = {
"\n mutation DoVerifyEmail($id: ID!, $code: String!) {\n completeEmailAuthentication(input: { id: $id, code: $code }) {\n status\n }\n }\n": typeof types.DoVerifyEmailDocument,
"\n mutation ResendEmailAuthenticationCode($id: ID!, $language: String!) {\n resendEmailAuthenticationCode(input: { id: $id, language: $language }) {\n status\n }\n }\n": typeof types.ResendEmailAuthenticationCodeDocument,
"\n mutation ChangePassword(\n $userId: ID!\n $oldPassword: String!\n $newPassword: String!\n ) {\n setPassword(\n input: {\n userId: $userId\n currentPassword: $oldPassword\n newPassword: $newPassword\n }\n ) {\n status\n }\n }\n": typeof types.ChangePasswordDocument,
"\n query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n }\n": typeof types.PasswordChangeDocument,
"\n query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n minimumPasswordComplexity\n }\n }\n": typeof types.PasswordChangeDocument,
"\n mutation RecoverPassword($ticket: String!, $newPassword: String!) {\n setPasswordByRecovery(\n input: { ticket: $ticket, newPassword: $newPassword }\n ) {\n status\n }\n }\n": typeof types.RecoverPasswordDocument,
"\n mutation ResendRecoveryEmail($ticket: String!) {\n resendRecoveryEmail(input: { ticket: $ticket }) {\n status\n progressUrl\n }\n }\n": typeof types.ResendRecoveryEmailDocument,
"\n fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket {\n username\n email\n }\n": typeof types.RecoverPassword_UserRecoveryTicketFragmentDoc,
"\n fragment RecoverPassword_siteConfig on SiteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n": typeof types.RecoverPassword_SiteConfigFragmentDoc,
"\n fragment RecoverPassword_siteConfig on SiteConfig {\n minimumPasswordComplexity\n }\n": typeof types.RecoverPassword_SiteConfigFragmentDoc,
"\n query PasswordRecovery($ticket: String!) {\n siteConfig {\n ...RecoverPassword_siteConfig\n }\n\n userRecoveryTicket(ticket: $ticket) {\n status\n ...RecoverPassword_userRecoveryTicket\n }\n }\n": typeof types.PasswordRecoveryDocument,
"\n mutation AllowCrossSigningReset($userId: ID!) {\n allowUserCrossSigningReset(input: { userId: $userId }) {\n user {\n id\n }\n }\n }\n": typeof types.AllowCrossSigningResetDocument,
"\n query SessionDetail($id: ID!) {\n viewerSession {\n ... on Node {\n id\n }\n }\n\n node(id: $id) {\n __typename\n id\n ...CompatSession_detail\n ...OAuth2Session_detail\n ...BrowserSession_detail\n }\n }\n": typeof types.SessionDetailDocument,
@@ -82,7 +82,6 @@ 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 fragment PasswordCreationDoubleInput_siteConfig on SiteConfig {\n id\n minimumPasswordComplexity\n }\n": types.PasswordCreationDoubleInput_SiteConfigFragmentDoc,
"\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,
@@ -106,6 +105,7 @@ const documents: Documents = {
"\n fragment UserEmailList_user on User {\n hasPassword\n }\n": types.UserEmailList_UserFragmentDoc,
"\n fragment UserEmailList_siteConfig on SiteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n }\n": types.UserEmailList_SiteConfigFragmentDoc,
"\n fragment BrowserSessionsOverview_user on User {\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n": types.BrowserSessionsOverview_UserFragmentDoc,
"\n query UsernameAvailable($username: String!) {\n usernameAvailable(username: $username) {\n available\n reason\n violationCodes\n }\n }\n": types.UsernameAvailableDocument,
"\n query UserProfile {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n user {\n ...AddEmailForm_user\n ...UserEmailList_user\n ...AccountDeleteButton_user\n hasPassword\n emails(first: 0) {\n totalCount\n }\n }\n }\n }\n\n siteConfig {\n emailChangeAllowed\n passwordLoginEnabled\n accountDeactivationAllowed\n ...AddEmailForm_siteConfig\n ...UserEmailList_siteConfig\n ...PasswordChange_siteConfig\n ...AccountDeleteButton_siteConfig\n }\n }\n": types.UserProfileDocument,
"\n query PlanManagementTab {\n siteConfig {\n planManagementIframeUri\n }\n }\n": types.PlanManagementTabDocument,
"\n query BrowserSessionList(\n $first: Int\n $after: String\n $last: Int\n $before: String\n $lastActive: DateFilter\n ) {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n\n user {\n id\n\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n lastActive: $lastActive\n state: ACTIVE\n ) {\n totalCount\n\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n }\n }\n": types.BrowserSessionListDocument,
@@ -119,11 +119,11 @@ const documents: Documents = {
"\n mutation DoVerifyEmail($id: ID!, $code: String!) {\n completeEmailAuthentication(input: { id: $id, code: $code }) {\n status\n }\n }\n": types.DoVerifyEmailDocument,
"\n mutation ResendEmailAuthenticationCode($id: ID!, $language: String!) {\n resendEmailAuthenticationCode(input: { id: $id, language: $language }) {\n status\n }\n }\n": types.ResendEmailAuthenticationCodeDocument,
"\n mutation ChangePassword(\n $userId: ID!\n $oldPassword: String!\n $newPassword: String!\n ) {\n setPassword(\n input: {\n userId: $userId\n currentPassword: $oldPassword\n newPassword: $newPassword\n }\n ) {\n status\n }\n }\n": types.ChangePasswordDocument,
"\n query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n }\n": types.PasswordChangeDocument,
"\n query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n minimumPasswordComplexity\n }\n }\n": types.PasswordChangeDocument,
"\n mutation RecoverPassword($ticket: String!, $newPassword: String!) {\n setPasswordByRecovery(\n input: { ticket: $ticket, newPassword: $newPassword }\n ) {\n status\n }\n }\n": types.RecoverPasswordDocument,
"\n mutation ResendRecoveryEmail($ticket: String!) {\n resendRecoveryEmail(input: { ticket: $ticket }) {\n status\n progressUrl\n }\n }\n": types.ResendRecoveryEmailDocument,
"\n fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket {\n username\n email\n }\n": types.RecoverPassword_UserRecoveryTicketFragmentDoc,
"\n fragment RecoverPassword_siteConfig on SiteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n": types.RecoverPassword_SiteConfigFragmentDoc,
"\n fragment RecoverPassword_siteConfig on SiteConfig {\n minimumPasswordComplexity\n }\n": types.RecoverPassword_SiteConfigFragmentDoc,
"\n query PasswordRecovery($ticket: String!) {\n siteConfig {\n ...RecoverPassword_siteConfig\n }\n\n userRecoveryTicket(ticket: $ticket) {\n status\n ...RecoverPassword_userRecoveryTicket\n }\n }\n": types.PasswordRecoveryDocument,
"\n mutation AllowCrossSigningReset($userId: ID!) {\n allowUserCrossSigningReset(input: { userId: $userId }) {\n user {\n id\n }\n }\n }\n": types.AllowCrossSigningResetDocument,
"\n query SessionDetail($id: ID!) {\n viewerSession {\n ... on Node {\n id\n }\n }\n\n node(id: $id) {\n __typename\n id\n ...CompatSession_detail\n ...OAuth2Session_detail\n ...BrowserSession_detail\n }\n }\n": types.SessionDetailDocument,
@@ -169,10 +169,6 @@ 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 fragment PasswordCreationDoubleInput_siteConfig on SiteConfig {\n id\n minimumPasswordComplexity\n }\n"): typeof import('./graphql').PasswordCreationDoubleInput_SiteConfigFragmentDoc;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -265,6 +261,10 @@ export function graphql(source: "\n fragment UserEmailList_siteConfig on SiteCo
* 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 BrowserSessionsOverview_user on User {\n browserSessions(first: 0, state: ACTIVE) {\n totalCount\n }\n }\n"): typeof import('./graphql').BrowserSessionsOverview_UserFragmentDoc;
/**
* 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.
*/
@@ -320,7 +320,7 @@ export function graphql(source: "\n mutation ChangePassword(\n $userId: 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 query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n }\n"): typeof import('./graphql').PasswordChangeDocument;
export function graphql(source: "\n query PasswordChange {\n viewer {\n __typename\n ... on Node {\n id\n }\n }\n\n siteConfig {\n minimumPasswordComplexity\n }\n }\n"): typeof import('./graphql').PasswordChangeDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -336,7 +336,7 @@ export function graphql(source: "\n fragment RecoverPassword_userRecoveryTicket
/**
* 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 RecoverPassword_siteConfig on SiteConfig {\n ...PasswordCreationDoubleInput_siteConfig\n }\n"): typeof import('./graphql').RecoverPassword_SiteConfigFragmentDoc;
export function graphql(source: "\n fragment RecoverPassword_siteConfig on SiteConfig {\n minimumPasswordComplexity\n }\n"): typeof import('./graphql').RecoverPassword_SiteConfigFragmentDoc;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
+59 -26
View File
@@ -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,8 +237,6 @@ export type OAuth2Session_SessionFragment = (
& { ' $fragmentRefs'?: { 'EndOAuth2SessionButton_SessionFragment': EndOAuth2SessionButton_SessionFragment } }
) & { ' $fragmentName'?: 'OAuth2Session_SessionFragment' };
export type PasswordCreationDoubleInput_SiteConfigFragment = { id: string, minimumPasswordComplexity: number } & { ' $fragmentName'?: 'PasswordCreationDoubleInput_SiteConfigFragment' };
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<{
@@ -338,6 +349,13 @@ export type UserEmailList_SiteConfigFragment = { emailChangeAllowed: boolean, pa
export type BrowserSessionsOverview_UserFragment = { browserSessions: { totalCount: number } } & { ' $fragmentName'?: 'BrowserSessionsOverview_UserFragment' };
export type UsernameAvailableQueryVariables = Exact<{
username: string;
}>;
export type UsernameAvailableQuery = { usernameAvailable: { available: boolean, reason: UsernameUnavailableReason | null, violationCodes: Array<string> | null } };
export type UserProfileQueryVariables = Exact<{ [key: string]: never; }>;
@@ -488,7 +506,7 @@ export type PasswordChangeQueryVariables = Exact<{ [key: string]: never; }>;
export type PasswordChangeQuery = { viewer:
| { __typename: 'Anonymous', id: string }
| { __typename: 'User', id: string }
, siteConfig: { ' $fragmentRefs'?: { 'PasswordCreationDoubleInput_SiteConfigFragment': PasswordCreationDoubleInput_SiteConfigFragment } } };
, siteConfig: { minimumPasswordComplexity: number } };
export type RecoverPasswordMutationVariables = Exact<{
ticket: string;
@@ -507,7 +525,7 @@ export type ResendRecoveryEmailMutation = { resendRecoveryEmail: { status: Resen
export type RecoverPassword_UserRecoveryTicketFragment = { username: string, email: string } & { ' $fragmentName'?: 'RecoverPassword_UserRecoveryTicketFragment' };
export type RecoverPassword_SiteConfigFragment = { ' $fragmentRefs'?: { 'PasswordCreationDoubleInput_SiteConfigFragment': PasswordCreationDoubleInput_SiteConfigFragment } } & { ' $fragmentName'?: 'RecoverPassword_SiteConfigFragment' };
export type RecoverPassword_SiteConfigFragment = { minimumPasswordComplexity: number } & { ' $fragmentName'?: 'RecoverPassword_SiteConfigFragment' };
export type PasswordRecoveryQueryVariables = Exact<{
ticket: string;
@@ -914,20 +932,11 @@ export const RecoverPassword_UserRecoveryTicketFragmentDoc = new TypedDocumentSt
email
}
`, {"fragmentName":"RecoverPassword_userRecoveryTicket"}) as unknown as TypedDocumentString<RecoverPassword_UserRecoveryTicketFragment, unknown>;
export const PasswordCreationDoubleInput_SiteConfigFragmentDoc = new TypedDocumentString(`
fragment PasswordCreationDoubleInput_siteConfig on SiteConfig {
id
minimumPasswordComplexity
}
`, {"fragmentName":"PasswordCreationDoubleInput_siteConfig"}) as unknown as TypedDocumentString<PasswordCreationDoubleInput_SiteConfigFragment, unknown>;
export const RecoverPassword_SiteConfigFragmentDoc = new TypedDocumentString(`
fragment RecoverPassword_siteConfig on SiteConfig {
...PasswordCreationDoubleInput_siteConfig
}
fragment PasswordCreationDoubleInput_siteConfig on SiteConfig {
id
minimumPasswordComplexity
}`, {"fragmentName":"RecoverPassword_siteConfig"}) as unknown as TypedDocumentString<RecoverPassword_SiteConfigFragment, unknown>;
}
`, {"fragmentName":"RecoverPassword_siteConfig"}) as unknown as TypedDocumentString<RecoverPassword_SiteConfigFragment, unknown>;
export const DeactivateUserDocument = new TypedDocumentString(`
mutation DeactivateUser($hsErase: Boolean!, $password: String) {
deactivateUser(input: {hsErase: $hsErase, password: $password}) {
@@ -1053,6 +1062,15 @@ export const UserEmailListDocument = new TypedDocumentString(`
id
email
}`) as unknown as TypedDocumentString<UserEmailListQuery, UserEmailListQueryVariables>;
export const UsernameAvailableDocument = new TypedDocumentString(`
query UsernameAvailable($username: String!) {
usernameAvailable(username: $username) {
available
reason
violationCodes
}
}
`) as unknown as TypedDocumentString<UsernameAvailableQuery, UsernameAvailableQueryVariables>;
export const UserProfileDocument = new TypedDocumentString(`
query UserProfile {
viewerSession {
@@ -1404,13 +1422,10 @@ export const PasswordChangeDocument = new TypedDocumentString(`
}
}
siteConfig {
...PasswordCreationDoubleInput_siteConfig
minimumPasswordComplexity
}
}
fragment PasswordCreationDoubleInput_siteConfig on SiteConfig {
id
minimumPasswordComplexity
}`) as unknown as TypedDocumentString<PasswordChangeQuery, PasswordChangeQueryVariables>;
`) as unknown as TypedDocumentString<PasswordChangeQuery, PasswordChangeQueryVariables>;
export const RecoverPasswordDocument = new TypedDocumentString(`
mutation RecoverPassword($ticket: String!, $newPassword: String!) {
setPasswordByRecovery(input: {ticket: $ticket, newPassword: $newPassword}) {
@@ -1436,16 +1451,12 @@ export const PasswordRecoveryDocument = new TypedDocumentString(`
...RecoverPassword_userRecoveryTicket
}
}
fragment PasswordCreationDoubleInput_siteConfig on SiteConfig {
id
minimumPasswordComplexity
}
fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket {
fragment RecoverPassword_userRecoveryTicket on UserRecoveryTicket {
username
email
}
fragment RecoverPassword_siteConfig on SiteConfig {
...PasswordCreationDoubleInput_siteConfig
minimumPasswordComplexity
}`) as unknown as TypedDocumentString<PasswordRecoveryQuery, PasswordRecoveryQueryVariables>;
export const AllowCrossSigningResetDocument = new TypedDocumentString(`
mutation AllowCrossSigningReset($userId: ID!) {
@@ -1812,6 +1823,28 @@ export const mockUserEmailListQuery = (resolver: GraphQLResponseResolver<UserEma
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))
+2 -2
View File
@@ -67,8 +67,8 @@ const Backend = {
},
} satisfies BackendModule;
export const setupI18n = () => {
i18n
export const setupI18n = async () => {
await i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
@@ -52,7 +52,7 @@ const QUERY = graphql(/* GraphQL */ `
}
siteConfig {
...PasswordCreationDoubleInput_siteConfig
minimumPasswordComplexity
}
}
`);
@@ -183,7 +183,7 @@ function ChangePassword(): React.ReactNode {
<Separator />
<PasswordCreationDoubleInput
siteConfig={siteConfig}
minimumPasswordComplexity={siteConfig.minimumPasswordComplexity}
forceShowNewPasswordInvalid={
(mutation.data &&
mutation.data.status === "INVALID_NEW_PASSWORD") ||
@@ -58,7 +58,7 @@ const FRAGMENT = graphql(/* GraphQL */ `
const SITE_CONFIG_FRAGMENT = graphql(/* GraphQL */ `
fragment RecoverPassword_siteConfig on SiteConfig {
...PasswordCreationDoubleInput_siteConfig
minimumPasswordComplexity
}
`);
@@ -292,7 +292,7 @@ const EmailRecovery: React.FC<{
/>
<PasswordCreationDoubleInput
siteConfig={siteConfig}
minimumPasswordComplexity={siteConfig.minimumPasswordComplexity}
forceShowNewPasswordInvalid={
mutation.data?.status === "INVALID_NEW_PASSWORD" || false
}
+121
View File
@@ -0,0 +1,121 @@
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
import type { TFunction } from "i18next";
import { describe, expect, it } from "vitest";
import {
fieldErrorMessage,
formErrorMessage,
isUsernameCheckable,
normalizeUsername,
policyCodeMessage,
} from "./registration";
// Echoes back the key, so the tests assert on which key was picked
const t = ((key: string) => key) as unknown as TFunction;
describe("normalizeUsername()", () => {
it("trims and lowercases", () => {
expect(normalizeUsername(" Alice ")).toBe("alice");
expect(normalizeUsername("ÉLODIE")).toBe("élodie");
expect(normalizeUsername("")).toBe("");
});
});
describe("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(
"frontend.errors.username_too_short",
);
expect(policyCodeMessage(t, "password-too-weak")).toBe(
"frontend.password_strength.too_weak",
);
});
it("returns undefined for unknown codes", () => {
expect(policyCodeMessage(t, "something-new")).toBeUndefined();
});
});
describe("fieldErrorMessage()", () => {
it("maps well-known kinds", () => {
expect(fieldErrorMessage(t, { kind: "required" })).toBe(
"frontend.errors.field_required",
);
expect(fieldErrorMessage(t, { kind: "exists" })).toBe(
"frontend.errors.username_taken",
);
expect(fieldErrorMessage(t, { kind: "invalid" })).toBe(
"frontend.errors.field_invalid",
);
});
it("prefers the policy code over the server message", () => {
expect(
fieldErrorMessage(t, {
kind: "policy",
code: "username-banned",
message: "nope",
}),
).toBe("frontend.errors.username_banned");
});
it("falls back to the server message for unknown policy codes", () => {
expect(
fieldErrorMessage(t, {
kind: "policy",
code: "something-new",
message: "nope",
}),
).toBe("nope");
expect(
fieldErrorMessage(t, { kind: "policy", code: "something-new" }),
).toBe("frontend.errors.field_invalid");
});
it("falls back to the server message for unknown kinds", () => {
expect(fieldErrorMessage(t, { kind: "brand_new", message: "nope" })).toBe(
"nope",
);
expect(fieldErrorMessage(t, { kind: "brand_new" })).toBe(
"frontend.errors.field_invalid",
);
});
});
describe("formErrorMessage()", () => {
it("maps well-known kinds", () => {
expect(formErrorMessage(t, { kind: "captcha" })).toBe(
"frontend.errors.captcha",
);
expect(formErrorMessage(t, { kind: "rate_limit_exceeded" })).toBe(
"frontend.errors.rate_limit_exceeded",
);
});
it("falls back to the server message, then to a generic one", () => {
expect(formErrorMessage(t, { kind: "brand_new", message: "nope" })).toBe(
"nope",
);
expect(formErrorMessage(t, { kind: "brand_new" })).toBe(
"frontend.errors.unspecified",
);
});
});
+102
View File
@@ -0,0 +1,102 @@
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
import type { TFunction } from "i18next";
import * as v from "valibot";
/** Shape of both the form-level and the field-level errors the server sends. */
export const serverErrorSchema = v.object({
kind: v.string(),
code: v.optional(v.nullable(v.string())),
message: v.optional(v.string()),
});
export type ServerError = v.InferOutput<typeof serverErrorSchema>;
/** Valid Matrix localpart: lowercase ascii, digits, and a few special chars */
export const VALID_LOCALPART_RE = /^[a-z0-9._=/+-]+$/;
/** Normalize a username: trim and lowercase. */
export const normalizeUsername = (value: string): string =>
value.trim().toLocaleLowerCase();
/** 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.
*/
const POLICY_CODE_MESSAGES = {
"username-too-short": "frontend.errors.username_too_short",
"username-too-long": "frontend.errors.username_too_long",
"username-invalid-chars": "frontend.errors.username_invalid_chars",
"username-all-numeric": "frontend.errors.username_all_numeric",
"username-banned": "frontend.errors.username_banned",
"username-not-allowed": "frontend.errors.username_not_allowed",
"email-domain-not-allowed": "frontend.errors.email_domain_not_allowed",
"email-domain-banned": "frontend.errors.email_domain_banned",
"email-not-allowed": "frontend.errors.email_not_allowed",
"email-banned": "frontend.errors.email_banned",
"password-too-weak": "frontend.password_strength.too_weak",
} as const;
/**
* Translate a policy violation code, or return `undefined` if the code is
* unknown to this version of the frontend.
*/
export const policyCodeMessage = (
t: TFunction,
code: string,
): string | undefined => {
const key = POLICY_CODE_MESSAGES[code as keyof typeof POLICY_CODE_MESSAGES];
return key ? t(key) : undefined;
};
/** Translate a server-side field error. */
export const fieldErrorMessage = (t: TFunction, error: ServerError): string => {
switch (error.kind) {
case "required":
return t("frontend.errors.field_required");
case "exists":
return t("frontend.errors.username_taken");
case "password_mismatch":
return t("frontend.errors.password_mismatch");
case "policy":
return (
(error.code ? policyCodeMessage(t, error.code) : undefined) ??
error.message ??
t("frontend.errors.field_invalid")
);
// The server marks a malformed email as 'invalid', and uses
// 'unspecified' for errors it doesn't want to detail
case "invalid":
case "unspecified":
return t("frontend.errors.field_invalid");
default:
return error.message ?? t("frontend.errors.field_invalid");
}
};
/** Translate a server-side form error. */
export const formErrorMessage = (t: TFunction, error: ServerError): string => {
switch (error.kind) {
case "captcha":
return t("frontend.errors.captcha");
case "rate_limit_exceeded":
return t("frontend.errors.rate_limit_exceeded");
case "password_mismatch":
return t("frontend.errors.password_mismatch");
case "policy":
return (
(error.code ? policyCodeMessage(t, error.code) : undefined) ??
error.message ??
t("frontend.errors.unspecified")
);
default:
return error.message ?? t("frontend.errors.unspecified");
}
};
@@ -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");
});
});
+20
View File
@@ -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;
};
@@ -0,0 +1,46 @@
// Copyright 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
import { useDeferredValue, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { PasswordComplexity } from "./password_complexity";
// This will load the password complexity module lazily,
// so that it doesn't block the initial render and can be code-split
const loadPromise = import("./password_complexity").then(
({ estimatePasswordComplexity }) => estimatePasswordComplexity,
);
/** Score the given password, off the critical path and off the main thread. */
export const usePasswordComplexity = (password: string): PasswordComplexity => {
const { t } = useTranslation();
const [result, setResult] = useState<PasswordComplexity>({
score: 0,
scoreText: t("frontend.password_strength.placeholder"),
improvementsText: [],
});
const deferredPassword = useDeferredValue(password);
useEffect(() => {
if (deferredPassword === "") {
setResult({
score: 0,
scoreText: t("frontend.password_strength.placeholder"),
improvementsText: [],
});
} else {
loadPromise
.then((estimatePasswordComplexity) =>
estimatePasswordComplexity(deferredPassword, t),
)
.then((response) => setResult(response));
}
}, [deferredPassword, t]);
return result;
};
+2 -2
View File
@@ -1,4 +1,5 @@
{#
Copyright 2025, 2026 Element Creations Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2021-2024 The Matrix.org Foundation C.I.C.
@@ -15,7 +16,6 @@ Please see LICENSE files in the repository root for full details.
{% import "components/errors.html" as errors %}
{% import "components/icon.html" as icon %}
{% import "components/scope.html" as scope %}
{% import "components/captcha.html" as captcha %}
<!DOCTYPE html>
<html lang="{{ lang }}">
@@ -27,7 +27,7 @@ Please see LICENSE files in the repository root for full details.
{{ include_asset('src/entrypoints/shared.css') | indent(4) | safe }}
{{ include_asset('src/entrypoints/templates.css') | indent(4) | safe }}
{{ include_asset('src/entrypoints/templates.ts') | indent(4) | safe }}
{{ captcha.head() }}
{% block head %}{% endblock head %}
</head>
<body>
<div class="layout-container{% if consent_page is defined %} consent{% endif %}">
-41
View File
@@ -1,41 +0,0 @@
{#
Copyright 2024, 2025 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
-#}
{% macro form(class="") -%}
{%- if captcha_config|default(False) -%}
<noscript>
<div class="captcha-noscript">
{{ _("mas.captcha.noscript") }}
</div>
</noscript>
{%- if captcha_config.service == "recaptcha_v2" -%}
<div class="g-recaptcha {{ class }}" data-sitekey="{{ captcha_config.site_key }}"></div>
{%- elif captcha_config.service == "cloudflare_turnstile" -%}
<div class="cf-turnstile {{ class }}" data-sitekey="{{ captcha_config.site_key }}"></div>
{%- elif captcha_config.service == "hcaptcha" -%}
<div class="h-captcha {{ class }}" data-sitekey="{{ captcha_config.site_key }}"></div>
{%- else -%}
{{ throw(message="Invalid captcha service setup") }}
{%- endif %}
{%- endif -%}
{% endmacro %}
{% macro head() -%}
{%- if captcha_config|default(False) -%}
{%- if captcha_config.service == "recaptcha_v2" -%}
<script src="https://www.recaptcha.net/recaptcha/api.js" async defer></script>
{%- elif captcha_config.service == "cloudflare_turnstile" -%}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{%- elif captcha_config.service == "hcaptcha" -%}
<script src="https://js.hcaptcha.com/1/api.js?recaptchacompat=off" async defer></script>
{%- else -%}
{{ throw(message="Invalid captcha service setup") }}
{%- endif %}
{%- endif -%}
{%- endmacro %}
+35 -53
View File
@@ -1,4 +1,5 @@
{#
Copyright 2025, 2026 Element Creations Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2021-2024 The Matrix.org Foundation C.I.C.
@@ -8,6 +9,12 @@ Please see LICENSE files in the repository root for full details.
{% extends "base.html" %}
{% block head %}
{{ include_asset('src/entrypoints/password-register.tsx') | indent(4) | safe }}
{# Pre-load the locale data for the current language #}
{{ include_asset('locales/' ~ lang ~ '.json') | indent(4) | safe }}
{% endblock head %}
{% block content %}
<header class="page-heading">
<div class="icon">
@@ -19,64 +26,39 @@ Please see LICENSE files in the repository root for full details.
</div>
</header>
<form method="POST" class="cpd-form-root">
{% for error in form.errors %}
{# Special case for the captcha error, as we want to put it at the bottom #}
{% if error.kind != "captcha" %}
<div class="text-critical font-medium">
{{ errors.form_error_message(error=error) }}
</div>
{% endif %}
{% endfor %}
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
{% set params = next["params"] | default({}) | to_params(prefix="?") %}
{% call(f) field.field(label=_("common.username"), name="username", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="none" required data-choose-username />
{{ field.error(error={"kind": "policy", "code": "username-invalid-chars"}, hidden=true) }}
{% endcall %}
<div id="password-register-form"
data-csrf-token="{{ csrf_token }}"
{%- if captcha_config %} data-captcha-config='{{ captcha_config | tojson }}'{% endif %}
data-branding='{{ branding | tojson }}'
data-features='{{ features | tojson }}'
data-form='{{ form | tojson }}'
data-graphql-endpoint="{{ graphql_endpoint }}"
data-login-link="{{ ("/login" ~ params) | prefix_url }}">
<p class="sr-only">{{ _("mas.loading") }}</p>
{% if features.password_registration_email_required %}
{% call(f) field.field(label=_("common.email_address"), name="email", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="email" autocomplete="email" required />
{% endcall %}
{% endif %}
{# Rough outline of the form, to keep the page from jumping once it mounts #}
<div class="register-skeleton" aria-hidden="true">
<div class="field"><span class="label"></span><span class="control"></span></div>
<div class="field"><span class="label"></span><span class="control"></span></div>
<span class="submit"></span>
</div>
</div>
{% call(f) field.field(label=_("common.password"), name="password", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="password" autocomplete="new-password" required />
{% endcall %}
<div id="password-register-form-error" class="critical-notice" hidden>
{{ _("mas.loading_failed") }}
</div>
{% call(f) field.field(label=_("common.password_confirm"), name="password_confirm", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="password" autocomplete="new-password" required />
{% endcall %}
<noscript>
{# The skeleton would otherwise sit above this message forever #}
<style>#password-register-form { display: none; }</style>
{% if branding.tos_uri is not none %}
{% call(f) field.field(label=_("mas.register.terms_of_service", tos_uri=branding.tos_uri), name="accept_terms", form_state=form, inline=true, class="my-4") %}
<div class="cpd-form-inline-field-control">
<div class="cpd-checkbox-container">
<input {{ field.attributes(f) }} class="cpd-checkbox-input" type="checkbox" required />
<div class="cpd-checkbox-ui">
{{ icon.check() }}
</div>
</div>
</div>
{% endcall %}
{% endif %}
<div class="flex flex-col gap-5">
<p class="critical-notice">{{ _("mas.register.javascript_required") }}</p>
{{ captcha.form(class="mb-4 self-center") }}
{% for error in form.errors %}
{# Special case for the captcha error #}
{% if error.kind == "captcha" %}
<div class="text-critical font-medium text-center -mt-4 mb-4">
{{ errors.form_error_message(error=error) }}
</div>
{% endif %}
{% endfor %}
{{ button.button(text=_("action.continue")) }}
{% set params = next["params"] | default({}) | to_params(prefix="?") %}
{{ button.link_tertiary(text=_("mas.register.call_to_login"), href="/login" ~ params) }}
</form>
{{ button.link_tertiary(text=_("mas.register.call_to_login"), href="/login" ~ params) }}
</div>
</noscript>
{% endblock content %}
+17 -18
View File
@@ -10,7 +10,7 @@
},
"continue": "Continue",
"@continue": {
"context": "form_post.html:25:28-48, pages/consent.html:71:28-48, pages/device_link.html:42:28-48, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:77:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:52:28-48"
"context": "form_post.html:25:28-48, pages/consent.html:71:28-48, pages/device_link.html:42:28-48, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:52:28-48"
},
"create_account": "Create Account",
"@create_account": {
@@ -79,7 +79,7 @@
},
"email_address": "Email address",
"@email_address": {
"context": "pages/recovery/start.html:34:33-58, pages/register/password.html:40:35-60, pages/upstream_oauth2/do_register.html:115:37-62"
"context": "pages/recovery/start.html:34:33-58, pages/upstream_oauth2/do_register.html:115:37-62"
},
"loading": "Loading…",
"@loading": {
@@ -91,15 +91,11 @@
},
"password": "Password",
"@password": {
"context": "pages/login.html:56:37-57, pages/reauth.html:28:35-55, pages/register/password.html:45:33-53"
},
"password_confirm": "Confirm password",
"@password_confirm": {
"context": "pages/register/password.html:49:33-61"
"context": "pages/login.html:56:37-57, pages/reauth.html:28:35-55"
},
"username": "Username",
"@username": {
"context": "pages/login.html:51:39-59, pages/register/index.html:30:35-55, pages/register/password.html:34:33-53, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:107:39-59"
"context": "pages/login.html:51:39-59, pages/register/index.html:30:35-55, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:107:39-59"
}
},
"error": {
@@ -146,12 +142,6 @@
"@back_to_homepage": {
"context": "pages/404.html:16:29-54"
},
"captcha": {
"noscript": "This form is protected by a CAPTCHA and requires JavaScript to be enabled to submit it. Please enable JavaScript in your browser and reload this page.",
"@noscript": {
"context": "components/captcha.html:13:11-36"
}
},
"change_password": {
"change": "Change password",
"@change": {
@@ -450,9 +440,14 @@
"context": "pages/sso.html:28:11-109"
}
},
"loading": "Loading…",
"@loading": {
"context": "pages/register/password.html:40:26-42",
"description": "Announced to screen readers while a page loads"
},
"loading_failed": "Something went wrong while loading the page. Try reloading it.",
"@loading_failed": {
"context": "app.html:28:9-32"
"context": "app.html:28:9-32, pages/register/password.html:51:7-30"
},
"login": {
"call_to_register": "Don't have an account yet?",
@@ -664,7 +659,7 @@
"register": {
"call_to_login": "Already have an account?",
"@call_to_login": {
"context": "pages/register/index.html:63:35-66, pages/register/password.html:80:33-64",
"context": "pages/register/index.html:63:35-66, pages/register/password.html:61:35-66",
"description": "Displayed on the registration page to suggest to log in instead"
},
"continue_with_email": "Continue with email address",
@@ -682,12 +677,16 @@
},
"heading": "Create an account",
"@heading": {
"context": "pages/register/index.html:21:29-69, pages/register/password.html:18:27-67"
"context": "pages/register/index.html:21:29-69, pages/register/password.html:25:27-67"
}
},
"javascript_required": "JavaScript is required to create an account. Please enable JavaScript in your browser and reload this page.",
"@javascript_required": {
"context": "pages/register/password.html:59:36-73"
},
"terms_of_service": "I agree to the <a href=\"%s\" data-kind=\"primary\" class=\"cpd-link\">Terms and Conditions</a>",
"@terms_of_service": {
"context": "pages/register/password.html:54:35-95, pages/upstream_oauth2/do_register.html:180:35-95"
"context": "pages/upstream_oauth2/do_register.html:180:35-95"
}
},
"registration_token": {