From 335be429bac705c07dfa08c0eaccc18da51d14b9 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Mon, 10 Aug 2026 20:12:05 +0200 Subject: [PATCH] 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. --- crates/handlers/src/lib.rs | 1 + crates/handlers/src/views/invite.rs | 88 +++++++++++ crates/handlers/src/views/mod.rs | 2 + crates/handlers/src/views/register/mod.rs | 26 ++++ crates/templates/src/context.rs | 12 ++ crates/templates/src/context/ext.rs | 1 + crates/templates/src/context/features.rs | 5 + crates/templates/src/lib.rs | 1 + frontend/locales/en.json | 5 + frontend/src/entrypoints/register.tsx | 178 +++++++++++++++++++++- frontend/src/gql/gql.ts | 6 + frontend/src/gql/graphql.ts | 38 +++++ templates/pages/register/index.html | 1 + translations/en.json | 6 +- 14 files changed, 359 insertions(+), 11 deletions(-) create mode 100644 crates/handlers/src/views/invite.rs diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index f83093375..92545c9f7 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -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), diff --git a/crates/handlers/src/views/invite.rs b/crates/handlers/src/views/invite.rs new file mode 100644 index 000000000..1e3d1e0b5 --- /dev/null +++ b/crates/handlers/src/views/invite.rs @@ -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, + mut repo: BoxRepository, + Path(token): Path, +) -> Result { + // 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); + } +} diff --git a/crates/handlers/src/views/mod.rs b/crates/handlers/src/views/mod.rs index bc070cda7..c3aea3bcd 100644 --- a/crates/handlers/src/views/mod.rs +++ b/crates/handlers/src/views/mod.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. // @@ -6,6 +7,7 @@ pub mod app; pub mod index; +pub mod invite; pub mod login; pub mod logout; pub mod recovery; diff --git a/crates/handlers/src/views/register/mod.rs b/crates/handlers/src/views/register/mod.rs index 90755a347..25f0118bc 100644 --- a/crates/handlers/src/views/register/mod.rs +++ b/crates/handlers/src/views/register/mod.rs @@ -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, + token: Option, #[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, ®istration_token) + .await? + } else { + registration + } + }; + let registration = if let Some(email) = email { // Create a new user email authentication session let user_email_authentication = repo diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index afa44e7ab..58af9f1ab 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -686,6 +686,8 @@ pub struct RegisterContext { form: FormState, next: Option, graphql_endpoint: String, + /// Registration token pre-filled from the query parameter + token: Option, } 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 { diff --git a/crates/templates/src/context/ext.rs b/crates/templates/src/context/ext.rs index 620f0abe4..6ca936fe6 100644 --- a/crates/templates/src/context/ext.rs +++ b/crates/templates/src/context/ext.rs @@ -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, } } } diff --git a/crates/templates/src/context/features.rs b/crates/templates/src/context/features.rs index 0925e7355..8bfc4948e 100644 --- a/crates/templates/src/context/features.rs +++ b/crates/templates/src/context/features.rs @@ -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", ]) } } diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index aa872a4a9..8355a50da 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -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"); diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 007f27cd3..e76e14efd 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -256,6 +256,11 @@ "password_confirm_label": "Confirm password", "password_label": "Password", "terms_of_service": "I agree to the Terms and Conditions", + "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…" diff --git a/frontend/src/entrypoints/register.tsx b/frontend/src/entrypoints/register.tsx index d3f04bf02..883545fa6 100644 --- a/frontend/src/entrypoints/register.tsx +++ b/frontend/src/entrypoints/register.tsx @@ -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 ( + + {/* The spinner carries the accessible name, so the live region doesn't + announce the same text twice */} + + + + ); + } + + if (notFound) { + return ( + + {t("frontend.register.token_not_found")} + + ); + } + + if (!info) return null; + + if (!info.valid) { + return ( + + {t("frontend.register.token_invalid")} + + ); + } + + return ( + + {t("frontend.register.token_valid")} + + ); +}; + +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 ( + + {t("frontend.register.token_label")} + setToken(e.target.value)} + /> + +
+ +
+ + + {t("frontend.errors.field_required")} + +
+ ); +}; + /** * 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<{ {t("common.username")} { // 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(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 && ( + + )} + + {showEmail && ( {t("common.email_address")} {t("frontend.errors.invalid_email")} diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index e5ac9f243..ef9fc46cf 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -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. */ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index 5a8b1eb90..e8915eec6 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -356,6 +356,13 @@ export type UsernameAvailableQueryVariables = Exact<{ export type UsernameAvailableQuery = { usernameAvailable: { available: boolean, reason: UsernameUnavailableReason | null, violationCodes: Array | 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; +export const RegistrationTokenDocument = new TypedDocumentString(` + query RegistrationToken($token: String!) { + registrationToken(token: $token) { + valid + username + email + } +} + `) as unknown as TypedDocumentString; export const UserProfileDocument = new TypedDocumentString(` query UserProfile { viewerSession { @@ -1845,6 +1861,28 @@ export const mockUsernameAvailableQuery = (resolver: GraphQLResponseResolver { + * const { token } = variables; + * return HttpResponse.json({ + * data: { registrationToken } + * }) + * }, + * requestOptions + * ) + */ +export const mockRegistrationTokenQuery = (resolver: GraphQLResponseResolver, options?: RequestHandlerOptions) => + graphql.query( + '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)) diff --git a/templates/pages/register/index.html b/templates/pages/register/index.html index 62b623419..be35ed031 100644 --- a/templates/pages/register/index.html +++ b/templates/pages/register/index.html @@ -29,6 +29,7 @@ Please see LICENSE files in the repository root for full details.
Terms and Conditions", "@terms_of_service": {