diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 763c840f9..496ba6e78 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -684,6 +684,7 @@ pub struct RegisterContext { providers: Vec, login_link: String, form: FormState, + graphql_endpoint: String, } impl TemplateContext for RegisterContext { @@ -717,6 +718,7 @@ impl RegisterContext { .collect(), login_link: url_builder.relative_url_for(&Login::from(post_auth_action.cloned())), form: FormState::default(), + graphql_endpoint: url_builder.relative_url_for(&GraphQL), } } diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 0e72697b3..56570b1cc 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -253,7 +253,10 @@ "or_separator": "Or", "password_confirm_label": "Confirm password", "password_label": "Password", - "terms_of_service": "I agree to the Terms and Conditions" + "terms_of_service": "I agree to the Terms and Conditions", + "username_available": "This username is available", + "username_check_failed": "We couldn't check if this username is available", + "username_checking": "Checking availability…" }, "reset_cross_signing": { "cancelled": { diff --git a/frontend/src/entrypoints/register.tsx b/frontend/src/entrypoints/register.tsx index cc2a43d34..786ee94f2 100644 --- a/frontend/src/entrypoints/register.tsx +++ b/frontend/src/entrypoints/register.tsx @@ -3,7 +3,12 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. -import { Form } from "@vector-im/compound-web"; +import { + QueryClient, + QueryClientProvider, + useQuery, +} from "@tanstack/react-query"; +import { Form, InlineSpinner } from "@vector-im/compound-web"; import { Suspense, useRef, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import * as v from "valibot"; @@ -14,17 +19,34 @@ import { } from "../components/Captcha"; import PasswordComplexityFeedback from "../components/PasswordComplexityFeedback"; import ProviderLogo, { hasProviderLogo } from "../components/ProviderLogo"; +import { graphql } from "../gql"; +import { graphqlRequest, setGraphqlEndpoint } 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), @@ -45,6 +67,7 @@ type Provider = v.InferOutput; // JSON-encoded by the template. const schema = v.object({ csrfToken: v.string(), + graphqlEndpoint: v.string(), captchaConfig: v.optional( v.pipe( v.string(), @@ -91,6 +114,88 @@ const schema = v.object({ 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; @@ -102,10 +207,32 @@ const UsernameField: React.FC<{ const [dirty, setDirty] = useState(false); const normalized = normalizeUsername(username); + const debounced = useDebouncedValue(normalized, 500); + const isDebouncePending = normalized !== debounced; + + const { data, isFetching, isError } = useQuery({ + queryKey: ["usernameAvailable", debounced], + queryFn: ({ signal }) => + graphqlRequest({ + query: USERNAME_AVAILABLE_QUERY, + variables: { username: debounced }, + signal, + }), + enabled: dirty && isUsernameCheckable(debounced), + }); + + const settled = dirty && !isDebouncePending; + const checking = + dirty && + isUsernameCheckable(normalized) && + (isFetching || isDebouncePending); + const availability = settled ? data?.usernameAvailable : undefined; return ( 0} > {t("common.username")} @@ -123,8 +250,17 @@ const UsernameField: React.FC<{ onBlur={() => setUsername(normalizeUsername(username))} /> + {/* Outside the live region: it changes on every keystroke */} {`@${normalized || "—"}:${serverName}`} +
+ +
+ {t("frontend.errors.field_required")} @@ -469,5 +605,13 @@ const RegisterPage: React.FC<{ data: Data }> = ({ data }) => { void mountIsland({ id: "register-form", schema, - render: (data) => , + render: (data) => { + setGraphqlEndpoint(data.graphqlEndpoint); + + return ( + + + + ); + }, }); diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index eca477e5d..e5ac9f243 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -48,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, @@ -104,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, @@ -259,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. */ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 63278096d..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' }; @@ -336,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; }>; @@ -1042,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 { @@ -1794,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/utils/registration.test.ts b/frontend/src/utils/registration.test.ts index 0bf0fcb48..f804a62e5 100644 --- a/frontend/src/utils/registration.test.ts +++ b/frontend/src/utils/registration.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from "vitest"; import { fieldErrorMessage, formErrorMessage, + isUsernameCheckable, normalizeUsername, policyCodeMessage, } from "./registration"; @@ -24,6 +25,20 @@ describe("normalizeUsername()", () => { }); }); +describe("isUsernameCheckable()", () => { + it("accepts valid localparts", () => { + expect(isUsernameCheckable("alice")).toBe(true); + expect(isUsernameCheckable("a.b_c-d=e/f+g0")).toBe(true); + }); + + it("rejects empty and invalid localparts", () => { + expect(isUsernameCheckable("")).toBe(false); + expect(isUsernameCheckable("Alice")).toBe(false); + expect(isUsernameCheckable("alice bob")).toBe(false); + expect(isUsernameCheckable("élodie")).toBe(false); + }); +}); + describe("policyCodeMessage()", () => { it("translates known codes", () => { expect(policyCodeMessage(t, "username-too-short")).toBe( diff --git a/frontend/src/utils/registration.ts b/frontend/src/utils/registration.ts index 77733e652..1c152dd4a 100644 --- a/frontend/src/utils/registration.ts +++ b/frontend/src/utils/registration.ts @@ -22,6 +22,10 @@ export const VALID_LOCALPART_RE = /^[a-z0-9._=/+-]+$/; 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. diff --git a/frontend/src/utils/useDebouncedValue.test.ts b/frontend/src/utils/useDebouncedValue.test.ts new file mode 100644 index 000000000..05760d8f9 --- /dev/null +++ b/frontend/src/utils/useDebouncedValue.test.ts @@ -0,0 +1,37 @@ +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +// @vitest-environment happy-dom + +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useDebouncedValue } from "./useDebouncedValue"; + +describe("useDebouncedValue()", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("returns the initial value straight away", () => { + const { result } = renderHook(() => useDebouncedValue("a", 500)); + expect(result.current).toBe("a"); + }); + + it("only settles once the value stops changing", () => { + const { result, rerender } = renderHook( + ({ value }) => useDebouncedValue(value, 500), + { initialProps: { value: "a" } }, + ); + + rerender({ value: "ab" }); + act(() => void vi.advanceTimersByTime(400)); + rerender({ value: "abc" }); + act(() => void vi.advanceTimersByTime(400)); + expect(result.current).toBe("a"); + + act(() => void vi.advanceTimersByTime(100)); + expect(result.current).toBe("abc"); + }); +}); diff --git a/frontend/src/utils/useDebouncedValue.ts b/frontend/src/utils/useDebouncedValue.ts new file mode 100644 index 000000000..90694948a --- /dev/null +++ b/frontend/src/utils/useDebouncedValue.ts @@ -0,0 +1,20 @@ +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +import { useEffect, useState } from "react"; + +/** + * Returns `value` after it has stopped changing for `wait` milliseconds. + */ +export const useDebouncedValue = (value: T, wait: number): T => { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timeout = setTimeout(() => setDebounced(value), wait); + return () => clearTimeout(timeout); + }, [value, wait]); + + return debounced; +}; diff --git a/templates/pages/register/index.html b/templates/pages/register/index.html index c74e356fb..e7a2db99f 100644 --- a/templates/pages/register/index.html +++ b/templates/pages/register/index.html @@ -33,7 +33,8 @@ Please see LICENSE files in the repository root for full details. data-features='{{ features | tojson }}' data-form='{{ form | tojson }}' data-providers='{{ providers | tojson }}' - data-login-link="{{ login_link }}"> + data-login-link="{{ login_link }}" + data-graphql-endpoint="{{ graphql_endpoint }}">

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

{# Rough outline of the form, to keep the page from jumping once it mounts #} diff --git a/translations/en.json b/translations/en.json index f8b7c3001..068510ac5 100644 --- a/translations/en.json +++ b/translations/en.json @@ -442,11 +442,11 @@ }, "loading": "Loading…", "@loading": { - "context": "pages/register/index.html:37:26-42" + "context": "pages/register/index.html:38:26-42" }, "loading_failed": "Something went wrong while loading the page. Try reloading it.", "@loading_failed": { - "context": "app.html:28:9-32, pages/register/index.html:49:7-30" + "context": "app.html:28:9-32, pages/register/index.html:50:7-30" }, "login": { "call_to_register": "Don't have an account yet?", @@ -688,7 +688,7 @@ }, "javascript_required": "JavaScript is required to create an account. Please enable JavaScript in your browser and reload this page.", "@javascript_required": { - "context": "pages/register/index.html:56:34-71" + "context": "pages/register/index.html:57:34-71" }, "terms_of_service": "I agree to the Terms and Conditions", "@terms_of_service": {