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": {