From 4ac82a97f53d80b42f33c85638ee275d01cc2e89 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 17 Sep 2026 21:22:35 +0200 Subject: [PATCH] Pre-fill the registration form from an invite link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/register?token=…` now resolves the invite code server-side and hands what it was issued for to the form island through a `data-invite` attribute: a pinned email address is shown as the line announcing where the verification code goes rather than as an input, a pinned username isn't shown at all, and a `passwordless` code replaces the password fields with a notice and relabels the submit button. A code the server refused only reports that, as an error above the form. Because a passwordless code is a way to register on its own, the shortcuts which send the page to the upstream provider or to `/login` when password registration is disabled are skipped when the link carries one. The code itself travels back on submission through a hidden field, so a form which comes back with errors keeps its shape, and so does the redirect the page sends the user to when the CSRF token has expired. --- crates/handlers/src/views/register/mod.rs | 167 ++++++++++++++++-- .../handlers/src/views/register/password.rs | 21 ++- crates/templates/src/context.rs | 46 ++++- crates/templates/src/lib.rs | 11 +- frontend/locales/en.json | 4 + .../components/RegisterForm/RegisterForm.tsx | 75 ++++++-- frontend/src/entrypoints/register.tsx | 8 +- frontend/src/utils/registration.ts | 14 ++ templates/components/field.html | 2 + templates/pages/register/index.html | 1 + translations/en.json | 12 +- 11 files changed, 319 insertions(+), 42 deletions(-) diff --git a/crates/handlers/src/views/register/mod.rs b/crates/handlers/src/views/register/mod.rs index 206f63d7f..2081f2965 100644 --- a/crates/handlers/src/views/register/mod.rs +++ b/crates/handlers/src/views/register/mod.rs @@ -25,7 +25,10 @@ use mas_storage::{ BoxRepository, upstream_oauth2::UpstreamOAuthProviderRepository, user::UserRegistrationTokenRepository, }; -use mas_templates::{RegisterContext, RegisterFormField, TemplateContext, Templates, ToFormState}; +use mas_templates::{ + FormState, InviteContext, RegisterContext, RegisterFormField, TemplateContext, Templates, + ToFormState, +}; use serde::{Deserialize, Serialize}; use thiserror::Error; use ulid::Ulid; @@ -48,6 +51,16 @@ pub use self::cookie::UserRegistrationSessions as UserRegistrationSessionsCookie #[error("Upstream OAuth 2.0 provider not found")] struct ProviderNotFound; +/// The query parameters of the registration page: an invite link deep-links +/// with a registration token, on top of the usual post-auth action +#[derive(Deserialize)] +pub(crate) struct QueryParams { + token: Option, + + #[serde(flatten)] + action: OptionalPostAuthAction, +} + #[tracing::instrument(name = "handlers.views.register.get", skip_all)] pub(crate) async fn get( mut rng: BoxRng, @@ -58,7 +71,7 @@ pub(crate) async fn get( State(site_config): State, mut repo: BoxRepository, activity_tracker: BoundActivityTracker, - Query(query): Query, + Query(query): Query, cookie_jar: CookieJar, ) -> Result { let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); @@ -71,21 +84,36 @@ pub(crate) async fn get( .record_browser_session(&clock, &session) .await; - let reply = query.go_next(&url_builder); + let reply = query.action.go_next(&url_builder); return Ok((cookie_jar, reply).into_response()); } + // Resolve the invite code the link carried, so that the form can show what + // it was issued for + let registration_token = match &query.token { + Some(token) => repo + .user_registration_token() + .find_by_token(token) + .await? + .filter(|token| token.is_valid(clock.now())), + None => None, + }; + let passwordless_invite = registration_token + .as_ref() + .is_some_and(|token| token.passwordless); + let providers = repo.upstream_oauth_provider().all_enabled().await?; // Without a password form there is nothing to show beyond the provider - // buttons, which isn't worth a page for one provider or none - if !site_config.password_registration_enabled { + // buttons, which isn't worth a page for one provider or none. A + // passwordless invite is a way to register on its own, so it keeps the page + if !site_config.password_registration_enabled && !passwordless_invite { if providers.len() == 1 { let provider = providers.into_iter().next().unwrap(); let mut destination = UpstreamOAuth2Authorize::new(provider.id); - if let Some(action) = query.post_auth_action { + if let Some(action) = query.action.post_auth_action { destination = destination.and_then(action); } @@ -93,12 +121,28 @@ pub(crate) async fn get( } if providers.is_empty() { - let destination = mas_router::Login::from(query.post_auth_action); + let destination = mas_router::Login::from(query.action.post_auth_action); return Ok((cookie_jar, url_builder.redirect(&destination)).into_response()); } } - let ctx = RegisterContext::new(&url_builder, providers, query.post_auth_action.as_ref()) + let mut ctx = RegisterContext::new( + &url_builder, + providers, + query.action.post_auth_action.as_ref(), + ); + + // The code travels back on submission through a hidden field, whether it + // resolved or not + if let Some(token) = query.token { + let mut form = FormState::default(); + form.set_value(RegisterFormField::Token, Some(token)); + ctx = ctx + .with_form_state(form) + .with_invite(InviteContext::new(registration_token.as_ref())); + } + + let ctx = ctx .with_captcha(site_config.captcha.clone()) .with_csrf(csrf_token.form_value()) .with_language(locale); @@ -170,7 +214,7 @@ pub(crate) async fn post( Option>, BoundActivityTracker, ), - Query(query): Query, + Query(query): Query, cookie_jar: CookieJar, Form(form): Form>, ) -> Result { @@ -179,7 +223,10 @@ pub(crate) async fn post( let Ok(mut form) = cookie_jar.verify_form(&clock, form) else { // An invalid token most likely comes from a page left open past the CSRF token lifetime tracing::debug!("Invalid CSRF token on the registration form, redirecting to a fresh one"); - let destination = Register::from(query.post_auth_action); + let mut destination = Register::from(query.action.post_auth_action); + if let Some(token) = query.token { + destination = destination.with_token(token); + } return Ok((cookie_jar, url_builder.redirect(&destination)).into_response()); }; @@ -220,7 +267,7 @@ pub(crate) async fn post( &mut repo, cookie_jar, &provider, - query.post_auth_action, + query.action.post_auth_action, carried_username, ) .await?; @@ -265,7 +312,7 @@ pub(crate) async fn post( repo, user_agent, &activity_tracker, - query, + query.action, cookie_jar, form, registration_token, @@ -287,6 +334,7 @@ mod tests { use mas_storage::{ RepositoryAccess, upstream_oauth2::{UpstreamOAuthProviderParams, UpstreamOAuthSessionRepository}, + user::UserRegistrationTokenRepository as _, }; use oauth2_types::scope::{OPENID, Scope}; use sqlx::PgPool; @@ -457,6 +505,101 @@ mod tests { ); } + /// Opening the page with an invite code resolves it server-side, and the + /// code travels back through the form + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_get_with_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_alice".to_owned(), + None, + None, + Some("alice".to_owned()), + Some("alice@example.com".to_owned()), + true, + ) + .await + .unwrap(); + repo.save().await.unwrap(); + + let request = Request::get("/register?token=invite_alice").empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + assert_eq!( + json_attribute(response.body(), "data-invite"), + serde_json::json!({ + "valid": true, + "username": "alice", + "email": "alice@example.com", + "passwordless": true, + }) + ); + assert_eq!( + json_attribute(response.body(), "data-form")["fields"]["token"]["value"], + serde_json::json!("invite_alice") + ); + + // A code the server doesn't know only says so + let request = Request::get("/register?token=nope").empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + assert_eq!( + json_attribute(response.body(), "data-invite"), + serde_json::json!({ "valid": false }) + ); + } + + /// A passwordless invite is a way to register on its own, so the page keeps + /// the form even when password registration is disabled + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_get_with_invite_without_password_registration(pool: PgPool) { + setup(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + password_login_enabled: false, + password_registration_enabled: false, + ..test_site_config() + }, + ) + .await + .unwrap(); + let mut rng = state.rng(); + + let mut repo = state.repository().await.unwrap(); + repo.user_registration_token() + .add( + &mut rng, + &state.clock, + "invite_alice".to_owned(), + None, + None, + Some("alice".to_owned()), + Some("alice@example.com".to_owned()), + true, + ) + .await + .unwrap(); + repo.save().await.unwrap(); + + let request = Request::get("/register?token=invite_alice").empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + + // Without the code, there is nothing to register with here + let request = Request::get("/register").empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::SEE_OTHER); + response.assert_header_value(LOCATION, "/login"); + } + /// Without password registration, the page is just the provider buttons #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_get_password_disabled_with_providers(pool: PgPool) { diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs index 64985e11a..c9fca9670 100644 --- a/crates/handlers/src/views/register/password.rs +++ b/crates/handlers/src/views/register/password.rs @@ -26,8 +26,8 @@ use mas_storage::{ user::{UserEmailRepository, UserRegistrationRepository as _, UserRepository}, }; use mas_templates::{ - FieldError, FormError, FormState, RegisterContext, RegisterFormField, TemplateContext, - Templates, ToFormState as _, + FieldError, FormError, FormState, InviteContext, RegisterContext, RegisterFormField, + TemplateContext, Templates, ToFormState as _, }; use zeroize::Zeroizing; @@ -277,6 +277,11 @@ pub(super) async fn register( }; if !state.is_valid() { + // Re-render with what the invite code resolved to, so the form keeps + // the shape the user submitted it in + let invite = + (!form.token.is_empty()).then(|| InviteContext::new(registration_token.as_ref())); + let content = render( locale, state, @@ -286,6 +291,7 @@ pub(super) async fn register( templates, url_builder, site_config.captcha.clone(), + invite, ) .await?; @@ -387,10 +393,17 @@ async fn render( templates: &Templates, url_builder: &UrlBuilder, captcha_config: Option, + invite: Option, ) -> Result { let providers = repo.upstream_oauth_provider().all_enabled().await?; - let ctx = RegisterContext::new(url_builder, providers, action.post_auth_action.as_ref()) - .with_form_state(form_state) + let mut ctx = RegisterContext::new(url_builder, providers, action.post_auth_action.as_ref()) + .with_form_state(form_state); + + if let Some(invite) = invite { + ctx = ctx.with_invite(invite); + } + + let ctx = ctx .with_captcha(captcha_config) .with_csrf(csrf_token.form_value()) .with_language(locale); diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index fe10854f7..49761080c 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -26,7 +26,7 @@ use mas_data_model::{ UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderOnBackchannelLogout, UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderTokenAuthMethod, User, UserEmailAuthentication, - UserEmailAuthenticationCode, UserRecoverySession, UserRegistration, + UserEmailAuthenticationCode, UserRecoverySession, UserRegistration, UserRegistrationToken, }; use mas_i18n::DataLocale; use mas_iana::jose::JsonWebSignatureAlg; @@ -681,6 +681,39 @@ impl RegisterPageProvider { } } +/// The identity an invite code was issued for +#[derive(Serialize)] +struct InvitePinnedIdentity { + username: Option, + email: Option, + passwordless: bool, +} + +/// The invite code the registration page was opened with, as the island sees it +#[derive(Serialize)] +pub struct InviteContext { + valid: bool, + + #[serde(flatten)] + pinned: Option, +} + +impl InviteContext { + /// Describe the invite code a link carried, `None` standing for a code + /// which is unknown, expired, revoked or exhausted + #[must_use] + pub fn new(token: Option<&UserRegistrationToken>) -> Self { + Self { + valid: token.is_some(), + pinned: token.map(|token| InvitePinnedIdentity { + username: token.username.clone(), + email: token.email.clone(), + passwordless: token.passwordless, + }), + } + } +} + /// Context used by the `register/index.html` template #[derive(Serialize)] pub struct RegisterContext { @@ -688,6 +721,7 @@ pub struct RegisterContext { login_link: String, form: FormState, graphql_endpoint: String, + invite: Option, } impl TemplateContext for RegisterContext { @@ -722,6 +756,7 @@ impl RegisterContext { login_link: url_builder.relative_url_for(&Login::from(post_auth_action.cloned())), form: FormState::default(), graphql_endpoint: url_builder.relative_url_for(&GraphQL), + invite: None, } } @@ -730,6 +765,15 @@ impl RegisterContext { pub fn with_form_state(self, form: FormState) -> Self { Self { form, ..self } } + + /// Set the invite code the page was opened with + #[must_use] + pub fn with_invite(self, invite: InviteContext) -> Self { + Self { + invite: Some(invite), + ..self + } + } } /// Context used by the `consent.html` template diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 136945f2c..0bb1b0c1b 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -41,11 +41,12 @@ pub use self::{ AccountInactiveContext, ApiDocContext, AppContext, CompatLoginPolicyViolationContext, CompatSsoContext, ConsentContext, DeviceConsentContext, DeviceLinkContext, DeviceLinkFormField, DeviceNameContext, EmailRecoveryContext, EmailVerificationContext, - EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField, - NotFoundContext, PolicyViolationContext, PostAuthContext, PostAuthContextInner, - RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField, - RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext, - RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, + EmptyContext, ErrorContext, FormPostContext, IndexContext, InviteContext, LoginContext, + LoginFormField, NotFoundContext, PolicyViolationContext, PostAuthContext, + PostAuthContextInner, RecoveryExpiredContext, RecoveryFinishContext, + RecoveryFinishFormField, RecoveryProgressContext, RecoveryStartContext, + RecoveryStartFormField, RegisterContext, RegisterFormField, + RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, RegisterStepsEmailInUseContext, RegisterStepsRegistrationTokenContext, RegisterStepsRegistrationTokenFormField, RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures, diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 007f27cd3..c16b77139 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -252,9 +252,13 @@ "continue_with_email": "Continue with email address", "continue_with_password": "Continue with password", "continue_with_provider": "Continue with {{provider}}", + "email_verification_notice": "We will send a verification code to {{email}}", + "invite_invalid": "This invite link is invalid or has expired", "or_separator": "Or", "password_confirm_label": "Confirm password", "password_label": "Password", + "passwordless_notice": "This account doesn't need a password. We'll send a verification code to your email address instead.", + "send_code": "Send code", "terms_of_service": "I agree to the Terms and Conditions", "username_available": "This username is available", "username_check_failed": "We couldn't check if this username is available", diff --git a/frontend/src/components/RegisterForm/RegisterForm.tsx b/frontend/src/components/RegisterForm/RegisterForm.tsx index 65278a0d4..e922adf13 100644 --- a/frontend/src/components/RegisterForm/RegisterForm.tsx +++ b/frontend/src/components/RegisterForm/RegisterForm.tsx @@ -3,13 +3,14 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. -import { Button, Form, Link } from "@vector-im/compound-web"; +import { Button, Form, Link, Text } from "@vector-im/compound-web"; import { Suspense, useRef, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { type FieldState, fieldErrorMessage, formErrorMessage, + type Invite, type Provider, type ServerError, } from "../../utils/registration"; @@ -89,6 +90,7 @@ export const PasswordRegisterForm: React.FC<{ fields: Partial>; providers: Provider[]; loginLink: string; + invite?: Invite; }> = ({ csrfToken, serverName, @@ -100,8 +102,19 @@ export const PasswordRegisterForm: React.FC<{ fields, providers, loginLink, + invite, }) => { const { t } = useTranslation(); + // A valid invite code may pin the identity the account is registered with, + // and waive the password entirely + const pinnedUsername = invite?.valid ? invite.username : null; + const pinnedEmail = invite?.valid ? invite.email : null; + const passwordless = invite?.valid === true && invite.passwordless; + // The server reports a code it refused both in the mount data and as an + // error on the `token` field, which has no control of its own to carry it + const inviteInvalid = + invite?.valid === false || (fields.token?.errors.length ?? 0) > 0; + const showEmailField = !pinnedEmail && (emailRequired || passwordless); // `null` until the widget has mounted; the handle reports `valid: true` // straight away when there is no captcha to solve. const captchaRef = useRef(null); @@ -114,9 +127,11 @@ export const PasswordRegisterForm: React.FC<{ ); const serverErrors = (field: string): ServerError[] => fieldErrors[field] ?? []; - // A submission which came back with errors went through both steps already + // A submission which came back with errors went through both steps already, + // and an invite link goes straight to the form it may have pinned fields of const [initialStep] = useState(() => providers.length > 0 && + !invite && formErrors.length === 0 && Object.values(fields).every((field) => !field?.errors.length) ? "username" @@ -186,6 +201,12 @@ export const PasswordRegisterForm: React.FC<{ )} + {inviteInvalid && ( +
+ {t("frontend.register.invite_invalid")} +
+ )} + {formErrors.map((error, index) => (
))} - + {/* A pinned username is not the user's to pick, so it isn't shown */} + {!pinnedUsername && ( + + )} {step === "password" && ( <> - {emailRequired && ( + {pinnedEmail && ( + + {t("frontend.register.email_verification_notice", { + email: pinnedEmail, + })} + + )} + + {showEmailField && ( 0} @@ -233,12 +265,21 @@ export const PasswordRegisterForm: React.FC<{ )} - + {passwordless ? ( + // The line about the pinned address already says a code is coming + !pinnedEmail && ( + + {t("frontend.register.passwordless_notice")} + + ) + ) : ( + + )} {tosUri && ( {t("action.continue")} + + {passwordless + ? t("frontend.register.send_code") + : t("action.continue")} + )} diff --git a/frontend/src/entrypoints/register.tsx b/frontend/src/entrypoints/register.tsx index ed7328c18..d39e84f6f 100644 --- a/frontend/src/entrypoints/register.tsx +++ b/frontend/src/entrypoints/register.tsx @@ -13,6 +13,7 @@ import { setGraphqlEndpoint } from "../graphql"; import { mountIsland } from "../utils/mountIsland"; import { fieldStateSchema, + inviteSchema, providerSchema, serverErrorSchema, } from "../utils/registration"; @@ -77,12 +78,16 @@ const schema = v.object({ providers: v.pipe(v.string(), v.parseJson(), v.array(providerSchema)), /** Href for the "already have an account?" call to action */ loginLink: v.string(), + invite: v.optional(v.pipe(v.string(), v.parseJson(), inviteSchema)), }); type Data = v.InferOutput; const RegisterPage: React.FC<{ data: Data }> = ({ data }) => - data.features.password_registration ? ( + // A passwordless invite is a way to register on its own, whatever the server + // allows otherwise + data.features.password_registration || + (data.invite?.valid === true && data.invite.passwordless) ? ( = ({ data }) => fields={data.form.fields} providers={data.providers} loginLink={data.loginLink} + invite={data.invite} /> ) : (