diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs index 48b8d184c..c0516d25d 100644 --- a/crates/handlers/src/views/register/password.rs +++ b/crates/handlers/src/views/register/password.rs @@ -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(); diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 98121a9ad..6f7176876 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -689,10 +689,11 @@ impl RegisterContext { } /// Context used by the `password_register.html` template -#[derive(Serialize, Default)] +#[derive(Serialize)] pub struct PasswordRegisterContext { form: FormState, next: Option, + 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) -> Self { diff --git a/crates/templates/src/context/ext.rs b/crates/templates/src/context/ext.rs index 679ad91a7..620f0abe4 100644 --- a/crates/templates/src/context/ext.rs +++ b/crates/templates/src/context/ext.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // @@ -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, } } } diff --git a/crates/templates/src/context/features.rs b/crates/templates/src/context/features.rs index 9870b5169..0925e7355 100644 --- a/crates/templates/src/context/features.rs +++ b/crates/templates/src/context/features.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // @@ -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", ]) } } diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 18eaa74e4..8bd14b294 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -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"); diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 565464b7e..bdd267494 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -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 Terms and Conditions", + "username_available": "This username is available", + "username_check_failed": "We couldn't check if this username is available", + "username_checking": "Checking availability…" + }, "reset_cross_signing": { "cancelled": { "description_1": "You can close this window and go back to the app to continue.", diff --git a/frontend/src/components/PasswordComplexityFeedback.tsx b/frontend/src/components/PasswordComplexityFeedback.tsx new file mode 100644 index 000000000..e357ef73c --- /dev/null +++ b/frontend/src/components/PasswordComplexityFeedback.tsx @@ -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 ( + <> + complexity.scoreText} + tint={password === "" ? undefined : TINTS[complexity.score]} + max={4} + value={complexity.score} + /> + + {complexity.improvementsText.map((suggestion) => ( + {suggestion} + ))} + + {complexity.score < minimumPasswordComplexity && ( + true}> + {t("frontend.password_strength.too_weak")} + + )} + + ); +}; + +export default PasswordComplexityFeedback; diff --git a/frontend/src/components/PasswordCreationDoubleInput.tsx b/frontend/src/components/PasswordCreationDoubleInput.tsx index 6724463c5..ea77a078a 100644 --- a/frontend/src/components/PasswordCreationDoubleInput.tsx +++ b/frontend/src/components/PasswordCreationDoubleInput.tsx @@ -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({ - 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; - forceShowNewPasswordInvalid: boolean; + minimumPasswordComplexity: number; + forceShowNewPasswordInvalid?: boolean; }): React.ReactElement { const { t } = useTranslation(); - const { minimumPasswordComplexity } = useFragment( - CONFIG_FRAGMENT, - siteConfig, - ); - - const newPasswordRef = useRef(null); const newPasswordAgainRef = useRef(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 ( <> @@ -89,7 +32,6 @@ export default function PasswordCreationDoubleInput({ newPasswordAgainRef.current?.value && newPasswordAgainRef.current?.reportValidity() @@ -97,24 +39,11 @@ export default function PasswordCreationDoubleInput({ onChange={(e) => setNewPassword(e.target.value)} /> - passwordComplexity.scoreText} - tint={passwordStrengthTint} - max={4} - value={passwordComplexity.score} + - {passwordComplexity.improvementsText.map((suggestion) => ( - {suggestion} - ))} - - {passwordComplexity.score < minimumPasswordComplexity && ( - true}> - {t("frontend.password_strength.too_weak")} - - )} - {t("frontend.errors.field_required")} diff --git a/frontend/src/entrypoints/password-register.tsx b/frontend/src/entrypoints/password-register.tsx new file mode 100644 index 000000000..df6ccb91a --- /dev/null +++ b/frontend/src/entrypoints/password-register.tsx @@ -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; + +const USERNAME_AVAILABLE_QUERY = graphql(` + query UsernameAvailable($username: String!) { + usernameAvailable(username: $username) { + available + reason + violationCodes + } + } +`); + +/** + * The settled result of the live availability check. Rendered inside an + * `aria-live` region, so it must only ever hold states the user has stopped + * typing into. + */ +const UsernameVerdict: React.FC<{ + checking: boolean; + checkFailed: boolean; + availability?: { + available: boolean; + reason?: string | null; + violationCodes?: readonly string[] | null; + }; +}> = ({ checking, checkFailed, availability }) => { + const { t } = useTranslation(); + + if (checking) { + const label = t("frontend.register.username_checking"); + return ( + + {/* The spinner carries the accessible name, so the live region doesn't + announce the same text twice */} + + + + ); + } + + if (checkFailed) { + return ( + + {t("frontend.register.username_check_failed")} + + ); + } + + if (!availability) return null; + + if (availability.available) { + return ( + + {t("frontend.register.username_available")} + + ); + } + + if (availability.reason === "INVALID") { + const messages = (availability.violationCodes ?? []) + .map((code) => policyCodeMessage(t, code)) + .filter((message): message is string => message !== undefined); + + return ( + <> + {(messages.length > 0 + ? messages + : [t("frontend.errors.username_invalid")] + ).map((message) => ( + + {message} + + ))} + + ); + } + + return ( + + {t("frontend.errors.username_taken")} + + ); +}; + +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 ( + 0} + > + {t("common.username")} + { + // 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 */} + {`@${normalized || "—"}:${serverName}`} + +
+ +
+ + + {t("frontend.errors.field_required")} + + { + const n = normalizeUsername(value); + return n.length > 0 && !VALID_LOCALPART_RE.test(n); + }} + > + {t("frontend.errors.username_invalid")} + + + {!dirty && + serverErrors.map((error, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static + + {fieldErrorMessage(t, error)} + + ))} +
+ ); +}; + +const PasswordFields: React.FC<{ + minimumPasswordComplexity: number; + serverErrors: ServerError[]; + confirmServerErrors: ServerError[]; +}> = ({ minimumPasswordComplexity, serverErrors, confirmServerErrors }) => { + const { t } = useTranslation(); + const confirmRef = useRef(null); + const [password, setPassword] = useState(""); + + return ( + <> + 0}> + {t("frontend.register.password_label")} + + + confirmRef.current?.value && confirmRef.current.reportValidity() + } + onChange={(e) => setPassword(e.target.value)} + /> + + + + + {t("frontend.errors.field_required")} + + + {serverErrors.map((error, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static + + {fieldErrorMessage(t, error)} + + ))} + + + 0} + > + {t("frontend.register.password_confirm_label")} + + + + + {t("frontend.errors.field_required")} + + + value !== form.get("password")} + > + {t("frontend.password_change.passwords_no_match")} + + + + {t("frontend.password_change.passwords_match")} + + + {confirmServerErrors.map((error, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static + + {fieldErrorMessage(t, error)} + + ))} + + + ); +}; + +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( + data.captchaConfig ? null : true, + ); + const [captchaError, setCaptchaError] = useState(null); + + const onCaptchaValidChange = useCallback((valid: boolean) => { + setCaptchaValid(valid); + if (valid) setCaptchaError(null); + }, []); + + return ( + { + // 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); + }} + > + + + {formErrors.map((error, index) => ( +
+ {formErrorMessage(t, error)} +
+ ))} + + + + {data.features.password_registration_email_required && ( + + {t("common.email_address")} + + + {t("frontend.errors.invalid_email")} + + + {t("frontend.errors.field_required")} + + {fields.email?.errors.map((error, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static + + {fieldErrorMessage(t, error)} + + ))} + + )} + + + + {data.branding.tos_uri && ( + } + serverInvalid={!!fields.accept_terms?.errors.length} + > + + + ), + }} + /> + + + {t("frontend.errors.field_required")} + + {fields.accept_terms?.errors.map((error, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static + + {fieldErrorMessage(t, error)} + + ))} + + )} + + + + {captchaError && ( +
+ {captchaError} +
+ )} + + {t("action.continue")} + + +
+ ); +}; + +void mountIsland({ + id: "password-register-form", + schema, + queryClient, + children: (data) => , +}); diff --git a/frontend/src/entrypoints/templates.css b/frontend/src/entrypoints/templates.css index b72f5d470..0ca84ade2 100644 --- a/frontend/src/entrypoints/templates.css +++ b/frontend/src/entrypoints/templates.css @@ -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 { diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index 72047364f..e5ac9f243 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -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. */ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 3d6bdb953..5a8b1eb90 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -183,6 +183,19 @@ export type UserRecoveryTicketStatus = /** The ticket is valid */ | 'VALID'; +/** Why a username is not available for registration. */ +export type UsernameUnavailableReason = + /** + * The username does not pass the registration policy. See + * `violationCodes` for the specific reasons. + */ + | 'INVALID' + /** + * The username is already in use, either by an existing MAS user or + * reserved by the homeserver. + */ + | 'TAKEN'; + export type AccountDeleteButton_UserFragment = { username: string, hasPassword: boolean, matrix: { mxid: string, displayName: string | null } } & { ' $fragmentName'?: 'AccountDeleteButton_UserFragment' }; export type AccountDeleteButton_SiteConfigFragment = { passwordLoginEnabled: boolean } & { ' $fragmentName'?: 'AccountDeleteButton_SiteConfigFragment' }; @@ -224,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 | 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; -export const PasswordCreationDoubleInput_SiteConfigFragmentDoc = new TypedDocumentString(` - fragment PasswordCreationDoubleInput_siteConfig on SiteConfig { - id - minimumPasswordComplexity -} - `, {"fragmentName":"PasswordCreationDoubleInput_siteConfig"}) as unknown as TypedDocumentString; 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; +} + `, {"fragmentName":"RecoverPassword_siteConfig"}) as unknown as TypedDocumentString; 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; +export const UsernameAvailableDocument = new TypedDocumentString(` + query UsernameAvailable($username: String!) { + usernameAvailable(username: $username) { + available + reason + violationCodes + } +} + `) as unknown as TypedDocumentString; 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; + `) as unknown as TypedDocumentString; 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; export const AllowCrossSigningResetDocument = new TypedDocumentString(` mutation AllowCrossSigningReset($userId: ID!) { @@ -1812,6 +1823,28 @@ export const mockUserEmailListQuery = (resolver: GraphQLResponseResolver { + * const { username } = variables; + * return HttpResponse.json({ + * data: { usernameAvailable } + * }) + * }, + * requestOptions + * ) + */ +export const mockUsernameAvailableQuery = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => + graphql.query( + 'UsernameAvailable', + resolver, + options + ) + /** * @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions)) * @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options)) diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 843e89463..bbae11885 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -67,8 +67,8 @@ const Backend = { }, } satisfies BackendModule; -export const setupI18n = () => { - i18n +export const setupI18n = async () => { + await i18n .use(Backend) .use(LanguageDetector) .use(initReactI18next) diff --git a/frontend/src/routes/password.change.index.tsx b/frontend/src/routes/password.change.index.tsx index 39a6dce80..b3eb9d04d 100644 --- a/frontend/src/routes/password.change.index.tsx +++ b/frontend/src/routes/password.change.index.tsx @@ -52,7 +52,7 @@ const QUERY = graphql(/* GraphQL */ ` } siteConfig { - ...PasswordCreationDoubleInput_siteConfig + minimumPasswordComplexity } } `); @@ -183,7 +183,7 @@ function ChangePassword(): React.ReactNode { 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", + ); + }); +}); diff --git a/frontend/src/utils/registration.ts b/frontend/src/utils/registration.ts new file mode 100644 index 000000000..1c152dd4a --- /dev/null +++ b/frontend/src/utils/registration.ts @@ -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; + +/** 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"); + } +}; diff --git a/frontend/src/utils/useDebouncedValue.test.ts b/frontend/src/utils/useDebouncedValue.test.ts new file mode 100644 index 000000000..05760d8f9 --- /dev/null +++ b/frontend/src/utils/useDebouncedValue.test.ts @@ -0,0 +1,37 @@ +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +// @vitest-environment happy-dom + +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useDebouncedValue } from "./useDebouncedValue"; + +describe("useDebouncedValue()", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("returns the initial value straight away", () => { + const { result } = renderHook(() => useDebouncedValue("a", 500)); + expect(result.current).toBe("a"); + }); + + it("only settles once the value stops changing", () => { + const { result, rerender } = renderHook( + ({ value }) => useDebouncedValue(value, 500), + { initialProps: { value: "a" } }, + ); + + rerender({ value: "ab" }); + act(() => void vi.advanceTimersByTime(400)); + rerender({ value: "abc" }); + act(() => void vi.advanceTimersByTime(400)); + expect(result.current).toBe("a"); + + act(() => void vi.advanceTimersByTime(100)); + expect(result.current).toBe("abc"); + }); +}); diff --git a/frontend/src/utils/useDebouncedValue.ts b/frontend/src/utils/useDebouncedValue.ts new file mode 100644 index 000000000..90694948a --- /dev/null +++ b/frontend/src/utils/useDebouncedValue.ts @@ -0,0 +1,20 @@ +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +import { useEffect, useState } from "react"; + +/** + * Returns `value` after it has stopped changing for `wait` milliseconds. + */ +export const useDebouncedValue = (value: T, wait: number): T => { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timeout = setTimeout(() => setDebounced(value), wait); + return () => clearTimeout(timeout); + }, [value, wait]); + + return debounced; +}; diff --git a/frontend/src/utils/usePasswordComplexity.ts b/frontend/src/utils/usePasswordComplexity.ts new file mode 100644 index 000000000..8c06742b9 --- /dev/null +++ b/frontend/src/utils/usePasswordComplexity.ts @@ -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({ + 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; +}; diff --git a/templates/base.html b/templates/base.html index 8f4bddcea..b2bd476fc 100644 --- a/templates/base.html +++ b/templates/base.html @@ -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 %} @@ -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 %}