Check the username availability as it is typed in the registration form

Once the username input settles, the field queries `usernameAvailable`
and reports next to it whether the name is available, taken or rejected
by the registration policy. In the two-step layout, continuing is held
back while a settled verdict says the name can't be used. The island now
declares the GraphQL endpoint on its mount node and brings its own query
client.
This commit is contained in:
Quentin Gliech authored and Devon Hudson committed 2026-10-09 18:22:16 +00:00
1 parent dc69dd5391
commit cdc8b51379
11 files changed
+290 -7

No files matched your search

+2
View File
@@ -684,6 +684,7 @@ pub struct RegisterContext {
providers: Vec<RegisterPageProvider>,
login_link: String,
form: FormState<RegisterFormField>,
graphql_endpoint: String,
}
impl TemplateContext for RegisterContext {
@@ -717,6 +718,7 @@ impl RegisterContext {
.collect(),
login_link: url_builder.relative_url_for(&Login::from(post_auth_action.cloned())),
form: FormState::default(),
graphql_endpoint: url_builder.relative_url_for(&GraphQL),
}
}
+4 -1
View File
@@ -253,7 +253,10 @@
"or_separator": "Or",
"password_confirm_label": "Confirm password",
"password_label": "Password",
"terms_of_service": "I agree to the <a>Terms and Conditions</a>"
"terms_of_service": "I agree to the <a>Terms and Conditions</a>",
"username_available": "This username is available",
"username_check_failed": "We couldn't check if this username is available",
"username_checking": "Checking availability…"
},
"reset_cross_signing": {
"cancelled": {
+146 -2
View File
@@ -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<typeof providerSchema>;
// 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<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;
@@ -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 (
<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>
@@ -123,8 +250,17 @@ const UsernameField: React.FC<{
onBlur={() => setUsername(normalizeUsername(username))}
/>
{/* Outside the live region: it changes on every keystroke */}
<Form.HelpMessage>{`@${normalized || "—"}:${serverName}`}</Form.HelpMessage>
<div aria-live="polite" aria-busy={checking}>
<UsernameVerdict
checking={checking}
checkFailed={settled && isError}
availability={availability}
/>
</div>
<Form.ErrorMessage match="valueMissing">
{t("frontend.errors.field_required")}
</Form.ErrorMessage>
@@ -469,5 +605,13 @@ const RegisterPage: React.FC<{ data: Data }> = ({ data }) => {
void mountIsland({
id: "register-form",
schema,
render: (data) => <RegisterPage data={data} />,
render: (data) => {
setGraphqlEndpoint(data.graphqlEndpoint);
return (
<QueryClientProvider client={queryClient}>
<RegisterPage data={data} />
</QueryClientProvider>
);
},
});
+6
View File
@@ -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.
*/
+51
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' };
@@ -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<string> | null } };
export type UserProfileQueryVariables = Exact<{ [key: string]: never; }>;
@@ -1042,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 {
@@ -1794,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))
+15
View File
@@ -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(
+4
View File
@@ -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.
@@ -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;
};
+2 -1
View File
@@ -33,7 +33,8 @@ Please see LICENSE files in the repository root for full details.
data-features='{{ features | tojson }}'
data-form='{{ form | tojson }}'
data-providers='{{ providers | tojson }}'
data-login-link="{{ login_link }}">
data-login-link="{{ login_link }}"
data-graphql-endpoint="{{ graphql_endpoint }}">
<p class="sr-only">{{ _("mas.loading") }}</p>
{# Rough outline of the form, to keep the page from jumping once it mounts #}
+3 -3
View File
@@ -442,11 +442,11 @@
},
"loading": "Loading…",
"@loading": {
"context": "pages/register/index.html:37:26-42"
"context": "pages/register/index.html:38:26-42"
},
"loading_failed": "Something went wrong while loading the page. Try reloading it.",
"@loading_failed": {
"context": "app.html:28:9-32, pages/register/index.html:49:7-30"
"context": "app.html:28:9-32, pages/register/index.html:50:7-30"
},
"login": {
"call_to_register": "Don't have an account yet?",
@@ -688,7 +688,7 @@
},
"javascript_required": "JavaScript is required to create an account. Please enable JavaScript in your browser and reload this page.",
"@javascript_required": {
"context": "pages/register/index.html:56:34-71"
"context": "pages/register/index.html:57:34-71"
},
"terms_of_service": "I agree to the <a href=\"%s\" data-kind=\"primary\" class=\"cpd-link\">Terms and Conditions</a>",
"@terms_of_service": {