Add an /invite/{token} route which pre-fills the registration form

`/invite/{token}` checks that the token exists and redirects to `/register`
with it in the query string, which hands it to the form island as a read-only
field. Once the token resolves over GraphQL, the username and email address it
was issued for are filled in and locked, and the email field shows up even when
the server would otherwise not ask for one.

The token is passed back in the POST body and attached to the registration.
Nothing validates it server-side yet.
This commit is contained in:
Quentin Gliech committed 2026-09-10 18:53:59 +02:00
1 parent f02c3fe293
commit 335be429ba
14 files changed
+359 -11

No files matched your search

+1
View File
@@ -391,6 +391,7 @@ where
get(self::views::login::get).post(self::views::login::post),
)
.route(mas_router::Logout::route(), post(self::views::logout::post))
.route(mas_router::Invite::route(), get(self::views::invite::get))
.route(
mas_router::Register::route(),
get(self::views::register::get).post(self::views::register::post),
+88
View File
@@ -0,0 +1,88 @@
// 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.
use axum::{
extract::{Path, State},
response::{IntoResponse, Response},
};
use mas_axum_utils::InternalError;
use mas_router::UrlBuilder;
use mas_storage::BoxRepository;
/// `GET /invite/{token}`
///
/// Deep-link to register with a specific registration token.
/// Redirects to the registration page with the token pre-filled.
#[tracing::instrument(name = "handlers.views.invite.get", skip_all, fields(token))]
pub async fn get(
State(url_builder): State<UrlBuilder>,
mut repo: BoxRepository,
Path(token): Path<String>,
) -> Result<Response, InternalError> {
// Look up the token to make sure it exists
let registration_token = repo.user_registration_token().find_by_token(&token).await?;
repo.cancel().await?;
if registration_token.is_none() {
// TODO: show a proper error page
return Ok(axum::http::StatusCode::NOT_FOUND.into_response());
}
// Redirect to the registration page with the token as a query param
let url = url_builder.absolute_url_for(&mas_router::Register::default());
let url = format!("{url}?token={token}");
Ok(axum::response::Redirect::to(&url).into_response())
}
#[cfg(test)]
mod tests {
use hyper::{Request, StatusCode, header::LOCATION};
use mas_router::Route;
use sqlx::PgPool;
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
/// An invite link hands the token over to the registration page
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_invite(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let mut rng = state.rng();
let mut repo = state.repository().await.unwrap();
repo.user_registration_token()
.add(
&mut rng,
&state.clock,
"invite_code".to_owned(),
None,
None,
None,
None,
)
.await
.unwrap();
repo.save().await.unwrap();
let request =
Request::get(&*mas_router::Invite::new("invite_code".to_owned()).path()).empty();
let response = state.request(request).await;
response.assert_status(StatusCode::SEE_OTHER);
assert!(
response
.headers()
.get(LOCATION)
.unwrap()
.to_str()
.unwrap()
.ends_with("/register?token=invite_code")
);
// An unknown token doesn't lead anywhere
let request = Request::get(&*mas_router::Invite::new("nope".to_owned()).path()).empty();
let response = state.request(request).await;
response.assert_status(StatusCode::NOT_FOUND);
}
}
+2
View File
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
@@ -6,6 +7,7 @@
pub mod app;
pub mod index;
pub mod invite;
pub mod login;
pub mod logout;
pub mod recovery;
+26
View File
@@ -71,6 +71,8 @@ pub(crate) struct RegisterForm {
password_confirm: String,
#[serde(default)]
accept_terms: String,
#[serde(default)]
token: String,
/// Which upstream provider the user chose, if any: each provider has its
/// own submit button
@@ -88,6 +90,7 @@ impl ToFormState for RegisterForm {
#[derive(Deserialize)]
pub(crate) struct QueryParams {
username: Option<String>,
token: Option<String>,
#[serde(flatten)]
action: OptionalPostAuthAction,
}
@@ -152,6 +155,11 @@ pub(crate) async fn get(
query.action.post_auth_action.as_ref(),
);
// If we got a token from the query string, pass it on to the form
if let Some(token) = query.token {
ctx = ctx.with_token(token);
}
// If we got a username from the query string, use it to prefill the form
if let Some(username) = query.username {
let mut form_state = FormState::default();
@@ -456,6 +464,24 @@ pub(crate) async fn post(
registration
};
// Attach the registration token if provided
let registration = if form.token.is_empty() {
registration
} else {
let registration_token = repo
.user_registration_token()
.find_by_token(&form.token)
.await?;
if let Some(registration_token) = registration_token {
repo.user_registration()
.set_registration_token(registration, &registration_token)
.await?
} else {
registration
}
};
let registration = if let Some(email) = email {
// Create a new user email authentication session
let user_email_authentication = repo
+12
View File
@@ -686,6 +686,8 @@ pub struct RegisterContext {
form: FormState<RegisterFormField>,
next: Option<PostAuthContext>,
graphql_endpoint: String,
/// Registration token pre-filled from the query parameter
token: Option<String>,
}
impl TemplateContext for RegisterContext {
@@ -721,6 +723,7 @@ impl RegisterContext {
form: FormState::default(),
next: None,
graphql_endpoint: url_builder.relative_url_for(&GraphQL),
token: None,
}
}
@@ -730,6 +733,15 @@ impl RegisterContext {
Self { form, ..self }
}
/// Pre-fill the registration token field
#[must_use]
pub fn with_token(self, token: String) -> Self {
Self {
token: Some(token),
..self
}
}
/// Add a post authentication action to the context
#[must_use]
pub fn with_post_action(self, next: PostAuthContext) -> Self {
+1
View File
@@ -51,6 +51,7 @@ impl SiteConfigExt for SiteConfig {
account_recovery: self.account_recovery_allowed,
login_with_email_allowed: self.login_with_email_allowed,
minimum_password_complexity: self.minimum_password_complexity,
registration_token_required: self.registration_token_required,
}
}
}
+5
View File
@@ -33,6 +33,9 @@ pub struct SiteFeatures {
/// The minimum password complexity score required for new passwords.
pub minimum_password_complexity: u8,
/// Whether a registration token is required to register.
pub registration_token_required: bool,
}
impl Object for SiteFeatures {
@@ -46,6 +49,7 @@ impl Object for SiteFeatures {
"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)),
"registration_token_required" => Some(Value::from(self.registration_token_required)),
_ => None,
}
}
@@ -58,6 +62,7 @@ impl Object for SiteFeatures {
"account_recovery",
"login_with_email_allowed",
"minimum_password_complexity",
"registration_token_required",
])
}
}
+1
View File
@@ -514,6 +514,7 @@ mod tests {
account_recovery: true,
login_with_email_allowed: true,
minimum_password_complexity: 3,
registration_token_required: false,
};
let vite_manifest_path =
Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend/dist/manifest.json");
+5
View File
@@ -256,6 +256,11 @@
"password_confirm_label": "Confirm password",
"password_label": "Password",
"terms_of_service": "I agree to the <a>Terms and Conditions</a>",
"token_checking": "Checking token…",
"token_invalid": "This token is invalid or has expired",
"token_label": "Registration token",
"token_not_found": "This token was not recognised",
"token_valid": "Token accepted",
"username_available": "This username is available",
"username_check_failed": "We couldn't check if this username is available",
"username_checking": "Checking availability…"
+170 -8
View File
@@ -93,8 +93,11 @@ const schema = v.object({
password_registration: v.boolean(),
password_registration_email_required: v.boolean(),
minimum_password_complexity: v.number(),
registration_token_required: v.boolean(),
}),
),
// Registration token carried by an invite link, if any
token: v.optional(v.string()),
form: v.pipe(
v.string(),
v.parseJson(),
@@ -120,6 +123,126 @@ const USERNAME_AVAILABLE_QUERY = graphql(`
}
`);
const REGISTRATION_TOKEN_QUERY = graphql(`
query RegistrationToken($token: String!) {
registrationToken(token: $token) {
valid
username
email
}
}
`);
/** What a looked-up registration token imposes on the rest of the form. */
type TokenInfo = {
valid: boolean;
username?: string | null;
email?: string | null;
};
/** The settled result of the token lookup, rendered in a live region. */
const TokenVerdict: React.FC<{
checking: boolean;
notFound: boolean;
info?: TokenInfo;
}> = ({ checking, notFound, info }) => {
const { t } = useTranslation();
if (checking) {
const label = t("frontend.register.token_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 (notFound) {
return (
<Form.ErrorMessage match="badInput" forceMatch>
{t("frontend.register.token_not_found")}
</Form.ErrorMessage>
);
}
if (!info) return null;
if (!info.valid) {
return (
<Form.ErrorMessage match="badInput" forceMatch>
{t("frontend.register.token_invalid")}
</Form.ErrorMessage>
);
}
return (
<Form.SuccessMessage match="valid" forceMatch>
{t("frontend.register.token_valid")}
</Form.SuccessMessage>
);
};
const TokenField: React.FC<{
defaultValue: string;
/** The token came from the invite link: show it, but don't let it be edited */
locked: boolean;
onTokenInfo: (info: TokenInfo | undefined) => void;
}> = ({ defaultValue, locked, onTokenInfo }) => {
const { t } = useTranslation();
const [token, setToken] = useState(defaultValue);
const debounced = useDebouncedValue(token, 500);
const isDebouncePending = token !== debounced;
const { data, isFetching } = useQuery({
queryKey: ["registrationToken", debounced],
queryFn: ({ signal }) =>
graphqlRequest({
query: REGISTRATION_TOKEN_QUERY,
variables: { token: debounced },
signal,
}),
enabled: debounced.length > 0,
});
const settled = !isDebouncePending && data !== undefined;
// A `null` token means the server doesn't know this one at all
const info = settled ? (data.registrationToken ?? undefined) : undefined;
const notFound = settled && data.registrationToken === null;
const checking = token.length > 0 && (isFetching || isDebouncePending);
// The username and email fields lock onto whatever the token was issued for
useEffect(() => {
onTokenInfo(info);
}, [info, onTokenInfo]);
return (
<Form.Field name="token">
<Form.Label>{t("frontend.register.token_label")}</Form.Label>
<Form.TextControl
required
readOnly={locked}
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
value={token}
onChange={(e) => setToken(e.target.value)}
/>
<div aria-live="polite" aria-busy={checking}>
<TokenVerdict checking={checking} notFound={notFound} info={info} />
</div>
<Form.ErrorMessage match="valueMissing">
{t("frontend.errors.field_required")}
</Form.ErrorMessage>
</Form.Field>
);
};
/**
* 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
@@ -198,16 +321,28 @@ const UsernameField: React.FC<{
serverErrors: ServerError[];
/** Reports the settled verdict, which is what holds the chooser step back */
onAvailabilityChange: (available: boolean | undefined) => void;
}> = ({ serverName, defaultValue, serverErrors, onAvailabilityChange }) => {
/** Username the registration token was issued for, which can't be changed */
forcedValue?: string | null;
}> = ({
serverName,
defaultValue,
serverErrors,
onAvailabilityChange,
forcedValue,
}) => {
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 locked = !!forcedValue;
const normalized = normalizeUsername(locked ? forcedValue : username);
const debounced = useDebouncedValue(normalized, 500);
const isDebouncePending = normalized !== debounced;
// A forced username was never typed in, but still deserves a verdict
const live = dirty || locked;
const { data, isFetching, isError } = useQuery({
queryKey: ["usernameAvailable", debounced],
queryFn: ({ signal }) =>
@@ -216,12 +351,12 @@ const UsernameField: React.FC<{
variables: { username: debounced },
signal,
}),
enabled: dirty && isUsernameCheckable(debounced),
enabled: live && isUsernameCheckable(debounced),
});
const settled = dirty && !isDebouncePending;
const settled = live && !isDebouncePending;
const checking =
dirty &&
live &&
isUsernameCheckable(normalized) &&
(isFetching || isDebouncePending);
const availability = settled ? data?.usernameAvailable : undefined;
@@ -240,10 +375,11 @@ const UsernameField: React.FC<{
<Form.Label>{t("common.username")}</Form.Label>
<Form.TextControl
required
readOnly={locked}
autoComplete="username"
autoCorrect="off"
autoCapitalize="none"
value={username}
value={locked ? forcedValue : username}
onChange={(e) => {
// Lowercase as the user types, so what is shown is what gets sent
setUsername(e.target.value.toLocaleLowerCase());
@@ -445,6 +581,19 @@ const PasswordRegisterForm: React.FC<{ data: Data }> = ({ data }) => {
const [usernameAvailable, setUsernameAvailable] = useState<
boolean | undefined
>(undefined);
const [tokenInfo, setTokenInfo] = useState<TokenInfo | undefined>(undefined);
// The field shows up when the server requires a token, or when an invite link
// handed us one
const showToken =
data.features.registration_token_required || data.token !== undefined;
// A valid token may pin the identity the account has to be registered with
const forcedUsername = tokenInfo?.valid ? tokenInfo.username : undefined;
const forcedEmail = tokenInfo?.valid ? tokenInfo.email : undefined;
const showEmail =
data.features.password_registration_email_required || !!forcedEmail;
// With no provider to pick from there is nothing to choose, so the whole form
// is shown at once
@@ -563,6 +712,7 @@ const PasswordRegisterForm: React.FC<{ data: Data }> = ({ data }) => {
defaultValue={fields.username?.value ?? ""}
serverErrors={fields.username?.errors ?? []}
onAvailabilityChange={setUsernameAvailable}
forcedValue={forcedUsername}
/>
{/* Ahead of the details, so that hitting Enter in the username field
@@ -590,17 +740,29 @@ const PasswordRegisterForm: React.FC<{ data: Data }> = ({ data }) => {
hidden={step === 1}
disabled={step === 1}
>
{data.features.password_registration_email_required && (
{showToken && (
<TokenField
defaultValue={data.token ?? ""}
locked={data.token !== undefined}
onTokenInfo={setTokenInfo}
/>
)}
{showEmail && (
<Form.Field
name="email"
serverInvalid={!!fields.email?.errors.length}
>
<Form.Label>{t("common.email_address")}</Form.Label>
<Form.TextControl
// A forced address only arrives once the token resolves, so the
// control has to be remounted to pick it up
key={forcedEmail ?? "editable"}
type="email"
required
readOnly={!!forcedEmail}
autoComplete="email"
defaultValue={fields.email?.value ?? ""}
defaultValue={forcedEmail ?? fields.email?.value ?? ""}
/>
<Form.ErrorMessage match="typeMismatch">
{t("frontend.errors.invalid_email")}
+6
View File
@@ -49,6 +49,7 @@ type Documents = {
"\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 RegistrationToken($token: String!) {\n registrationToken(token: $token) {\n valid\n username\n email\n }\n }\n": typeof types.RegistrationTokenDocument,
"\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,
@@ -106,6 +107,7 @@ const documents: Documents = {
"\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 RegistrationToken($token: String!) {\n registrationToken(token: $token) {\n valid\n username\n email\n }\n }\n": types.RegistrationTokenDocument,
"\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,
@@ -265,6 +267,10 @@ export function graphql(source: "\n fragment BrowserSessionsOverview_user on Us
* 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.
*/
export function graphql(source: "\n query RegistrationToken($token: String!) {\n registrationToken(token: $token) {\n valid\n username\n email\n }\n }\n"): typeof import('./graphql').RegistrationTokenDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
+38
View File
@@ -356,6 +356,13 @@ export type UsernameAvailableQueryVariables = Exact<{
export type UsernameAvailableQuery = { usernameAvailable: { available: boolean, reason: UsernameUnavailableReason | null, violationCodes: Array<string> | null } };
export type RegistrationTokenQueryVariables = Exact<{
token: string;
}>;
export type RegistrationTokenQuery = { registrationToken: { valid: boolean, username: string | null, email: string | null } | null };
export type UserProfileQueryVariables = Exact<{ [key: string]: never; }>;
@@ -1071,6 +1078,15 @@ export const UsernameAvailableDocument = new TypedDocumentString(`
}
}
`) as unknown as TypedDocumentString<UsernameAvailableQuery, UsernameAvailableQueryVariables>;
export const RegistrationTokenDocument = new TypedDocumentString(`
query RegistrationToken($token: String!) {
registrationToken(token: $token) {
valid
username
email
}
}
`) as unknown as TypedDocumentString<RegistrationTokenQuery, RegistrationTokenQueryVariables>;
export const UserProfileDocument = new TypedDocumentString(`
query UserProfile {
viewerSession {
@@ -1845,6 +1861,28 @@ export const mockUsernameAvailableQuery = (resolver: GraphQLResponseResolver<Use
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
* mockRegistrationTokenQuery(
* ({ query, variables }) => {
* const { token } = variables;
* return HttpResponse.json({
* data: { registrationToken }
* })
* },
* requestOptions
* )
*/
export const mockRegistrationTokenQuery = (resolver: GraphQLResponseResolver<RegistrationTokenQuery, RegistrationTokenQueryVariables>, options?: RequestHandlerOptions) =>
graphql.query<RegistrationTokenQuery, RegistrationTokenQueryVariables>(
'RegistrationToken',
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))
+1
View File
@@ -29,6 +29,7 @@ Please see LICENSE files in the repository root for full details.
<div id="register-form"
data-csrf-token="{{ csrf_token }}"
{%- if captcha_config %} data-captcha-config='{{ captcha_config | tojson }}'{% endif %}
{%- if token %} data-token="{{ token }}"{% endif %}
data-branding='{{ branding | tojson }}'
data-features='{{ features | tojson }}'
data-form='{{ form | tojson }}'
+3 -3
View File
@@ -442,12 +442,12 @@
},
"loading": "Loading…",
"@loading": {
"context": "pages/register/index.html:38:26-42",
"context": "pages/register/index.html:39:26-42",
"description": "Announced to screen readers while a page loads"
},
"loading_failed": "Something went wrong while loading the page. Try reloading it.",
"@loading_failed": {
"context": "app.html:28:9-32, 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?",
@@ -669,7 +669,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": {