mirror of
https://github.com/element-hq/matrix-authentication-service.git
synced 2026-10-23 02:13:37 +00:00
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:
1 parent
dc69dd5391
commit
cdc8b51379
11 files changed
+290
-7
No files matched your search
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2026 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Returns `value` after it has stopped changing for `wait` milliseconds.
|
||||
*/
|
||||
export const useDebouncedValue = <T>(value: T, wait: number): T => {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setDebounced(value), wait);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [value, wait]);
|
||||
|
||||
return debounced;
|
||||
};
|
||||
@@ -33,7 +33,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
data-features='{{ features | tojson }}'
|
||||
data-form='{{ form | tojson }}'
|
||||
data-providers='{{ providers | tojson }}'
|
||||
data-login-link="{{ login_link }}">
|
||||
data-login-link="{{ login_link }}"
|
||||
data-graphql-endpoint="{{ graphql_endpoint }}">
|
||||
<p class="sr-only">{{ _("mas.loading") }}</p>
|
||||
|
||||
{# Rough outline of the form, to keep the page from jumping once it mounts #}
|
||||
|
||||
@@ -442,11 +442,11 @@
|
||||
},
|
||||
"loading": "Loading…",
|
||||
"@loading": {
|
||||
"context": "pages/register/index.html:37:26-42"
|
||||
"context": "pages/register/index.html:38:26-42"
|
||||
},
|
||||
"loading_failed": "Something went wrong while loading the page. Try reloading it.",
|
||||
"@loading_failed": {
|
||||
"context": "app.html:28:9-32, pages/register/index.html:49:7-30"
|
||||
"context": "app.html:28:9-32, pages/register/index.html:50:7-30"
|
||||
},
|
||||
"login": {
|
||||
"call_to_register": "Don't have an account yet?",
|
||||
@@ -688,7 +688,7 @@
|
||||
},
|
||||
"javascript_required": "JavaScript is required to create an account. Please enable JavaScript in your browser and reload this page.",
|
||||
"@javascript_required": {
|
||||
"context": "pages/register/index.html:56:34-71"
|
||||
"context": "pages/register/index.html:57:34-71"
|
||||
},
|
||||
"terms_of_service": "I agree to the <a href=\"%s\" data-kind=\"primary\" class=\"cpd-link\">Terms and Conditions</a>",
|
||||
"@terms_of_service": {
|
||||
|
||||
Reference in new issue
Block a user