Registration token step view

This commit is contained in:
Quentin Gliech
2025-06-03 08:13:39 +02:00
parent 685f4761cd
commit d4e2d06ed9
8 changed files with 351 additions and 2 deletions
+5
View File
@@ -392,6 +392,11 @@ where
get(self::views::register::steps::verify_email::get)
.post(self::views::register::steps::verify_email::post),
)
.route(
mas_router::RegisterToken::route(),
get(self::views::register::steps::registration_token::get)
.post(self::views::register::steps::registration_token::post),
)
.route(
mas_router::RegisterDisplayName::route(),
get(self::views::register::steps::display_name::get)
@@ -5,4 +5,5 @@
pub(crate) mod display_name;
pub(crate) mod finish;
pub(crate) mod registration_token;
pub(crate) mod verify_email;
@@ -0,0 +1,201 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
use anyhow::Context as _;
use axum::{
Form,
extract::{Path, State},
response::{Html, IntoResponse, Response},
};
use mas_axum_utils::{
InternalError,
cookies::CookieJar,
csrf::{CsrfExt as _, ProtectedForm},
};
use mas_router::{PostAuthAction, UrlBuilder};
use mas_storage::{BoxClock, BoxRepository, BoxRng};
use mas_templates::{
FieldError, RegisterStepsRegistrationTokenContext, RegisterStepsRegistrationTokenFormField,
TemplateContext as _, Templates, ToFormState,
};
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use crate::{PreferredLanguage, views::shared::OptionalPostAuthAction};
#[derive(Deserialize, Serialize)]
pub(crate) struct RegistrationTokenForm {
#[serde(default)]
token: String,
}
impl ToFormState for RegistrationTokenForm {
type Field = mas_templates::RegisterStepsRegistrationTokenFormField;
}
#[tracing::instrument(
name = "handlers.views.register.steps.registration_token.get",
fields(user_registration.id = %id),
skip_all,
)]
pub(crate) async fn get(
mut rng: BoxRng,
clock: BoxClock,
PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
mut repo: BoxRepository,
Path(id): Path<Ulid>,
cookie_jar: CookieJar,
) -> Result<Response, InternalError> {
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
let registration = repo
.user_registration()
.lookup(id)
.await?
.context("Could not find user registration")
.map_err(InternalError::from_anyhow)?;
// If the registration is completed, we can go to the registration destination
if registration.completed_at.is_some() {
let post_auth_action: Option<PostAuthAction> = registration
.post_auth_action
.map(serde_json::from_value)
.transpose()?;
return Ok((
cookie_jar,
OptionalPostAuthAction::from(post_auth_action)
.go_next(&url_builder)
.into_response(),
)
.into_response());
}
// If the registration already has a token, skip this step
if registration.user_registration_token_id.is_some() {
let destination = mas_router::RegisterDisplayName::new(registration.id);
return Ok((cookie_jar, url_builder.redirect(&destination)).into_response());
}
let ctx = RegisterStepsRegistrationTokenContext::new()
.with_csrf(csrf_token.form_value())
.with_language(locale);
let content = templates.render_register_steps_registration_token(&ctx)?;
Ok((cookie_jar, Html(content)).into_response())
}
#[tracing::instrument(
name = "handlers.views.register.steps.registration_token.post",
fields(user_registration.id = %id),
skip_all,
)]
pub(crate) async fn post(
mut rng: BoxRng,
clock: BoxClock,
PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
mut repo: BoxRepository,
Path(id): Path<Ulid>,
cookie_jar: CookieJar,
Form(form): Form<ProtectedForm<RegistrationTokenForm>>,
) -> Result<Response, InternalError> {
let registration = repo
.user_registration()
.lookup(id)
.await?
.context("Could not find user registration")
.map_err(InternalError::from_anyhow)?;
// If the registration is completed, we can go to the registration destination
if registration.completed_at.is_some() {
let post_auth_action: Option<PostAuthAction> = registration
.post_auth_action
.map(serde_json::from_value)
.transpose()?;
return Ok((
cookie_jar,
OptionalPostAuthAction::from(post_auth_action)
.go_next(&url_builder)
.into_response(),
)
.into_response());
}
let form = cookie_jar.verify_form(&clock, form)?;
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
// Validate the token
let token = form.token.trim();
if token.is_empty() {
let ctx = RegisterStepsRegistrationTokenContext::new()
.with_form_state(form.to_form_state().with_error_on_field(
RegisterStepsRegistrationTokenFormField::Token,
FieldError::Required,
))
.with_csrf(csrf_token.form_value())
.with_language(locale);
return Ok((
cookie_jar,
Html(templates.render_register_steps_registration_token(&ctx)?),
)
.into_response());
}
// Look up the token
let Some(registration_token) = repo.user_registration_token().find_by_token(token).await?
else {
let ctx = RegisterStepsRegistrationTokenContext::new()
.with_form_state(form.to_form_state().with_error_on_field(
RegisterStepsRegistrationTokenFormField::Token,
FieldError::Invalid,
))
.with_csrf(csrf_token.form_value())
.with_language(locale);
return Ok((
cookie_jar,
Html(templates.render_register_steps_registration_token(&ctx)?),
)
.into_response());
};
// Check if the token is still valid
if !registration_token.is_valid(clock.now()) {
tracing::warn!("Registration token isn't valid (expired or already used)");
let ctx = RegisterStepsRegistrationTokenContext::new()
.with_form_state(form.to_form_state().with_error_on_field(
RegisterStepsRegistrationTokenFormField::Token,
FieldError::Invalid,
))
.with_csrf(csrf_token.form_value())
.with_language(locale);
return Ok((
cookie_jar,
Html(templates.render_register_steps_registration_token(&ctx)?),
)
.into_response());
}
// Associate the token with the registration
let registration = repo
.user_registration()
.set_registration_token(registration, &registration_token)
.await?;
repo.save().await?;
// Continue to the next step
let destination = mas_router::RegisterFinish::new(registration.id);
Ok((cookie_jar, url_builder.redirect(&destination)).into_response())
}
+24
View File
@@ -382,6 +382,30 @@ impl From<Option<PostAuthAction>> for PasswordRegister {
}
}
/// `GET|POST /register/steps/{id}/token`
#[derive(Debug, Clone)]
pub struct RegisterToken {
id: Ulid,
}
impl RegisterToken {
#[must_use]
pub fn new(id: Ulid) -> Self {
Self { id }
}
}
impl Route for RegisterToken {
type Query = ();
fn route() -> &'static str {
"/register/steps/{id}/token"
}
fn path(&self) -> std::borrow::Cow<'static, str> {
format!("/register/steps/{}/token", self.id).into()
}
}
/// `GET|POST /register/steps/{id}/display-name`
#[derive(Debug, Clone)]
pub struct RegisterDisplayName {
+55
View File
@@ -1068,6 +1068,61 @@ impl TemplateContext for RegisterStepsDisplayNameContext {
}
}
/// Fields of the registration token form
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RegisterStepsRegistrationTokenFormField {
/// The registration token
Token,
}
impl FormField for RegisterStepsRegistrationTokenFormField {
fn keep(&self) -> bool {
match self {
Self::Token => true,
}
}
}
/// The registration token page context
#[derive(Serialize, Default)]
pub struct RegisterStepsRegistrationTokenContext {
form: FormState<RegisterStepsRegistrationTokenFormField>,
}
impl RegisterStepsRegistrationTokenContext {
/// Constructs a context for the registration token page
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Set the form state
#[must_use]
pub fn with_form_state(
mut self,
form_state: FormState<RegisterStepsRegistrationTokenFormField>,
) -> Self {
self.form = form_state;
self
}
}
impl TemplateContext for RegisterStepsRegistrationTokenContext {
fn sample(
_now: chrono::DateTime<chrono::Utc>,
_rng: &mut impl Rng,
_locales: &[DataLocale],
) -> Vec<Self>
where
Self: Sized,
{
vec![Self {
form: FormState::default(),
}]
}
}
/// Fields of the account recovery start form
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
+6 -1
View File
@@ -42,7 +42,8 @@ pub use self::{
RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField,
RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext,
RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
RegisterStepsEmailInUseContext, RegisterStepsVerifyEmailContext,
RegisterStepsEmailInUseContext, RegisterStepsRegistrationTokenContext,
RegisterStepsRegistrationTokenFormField, RegisterStepsVerifyEmailContext,
RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures,
TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField,
UpstreamSuggestLink, WithCaptcha, WithCsrf, WithLanguage, WithOptionalSession, WithSession,
@@ -340,6 +341,9 @@ register_templates! {
/// Render the display name page
pub fn render_register_steps_display_name(WithLanguage<WithCsrf<RegisterStepsDisplayNameContext>>) { "pages/register/steps/display_name.html" }
/// Render the registration token page
pub fn render_register_steps_registration_token(WithLanguage<WithCsrf<RegisterStepsRegistrationTokenContext>>) { "pages/register/steps/registration_token.html" }
/// Render the client consent page
pub fn render_consent(WithLanguage<WithCsrf<WithSession<ConsentContext>>>) { "pages/consent.html" }
@@ -444,6 +448,7 @@ impl Templates {
check::render_register_steps_verify_email(self, now, rng)?;
check::render_register_steps_email_in_use(self, now, rng)?;
check::render_register_steps_display_name(self, now, rng)?;
check::render_register_steps_registration_token(self, now, rng)?;
check::render_consent(self, now, rng)?;
check::render_policy_violation(self, now, rng)?;
check::render_sso_login(self, now, rng)?;
@@ -0,0 +1,44 @@
{#
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
-#}
{% extends "base.html" %}
{% block content %}
<header class="page-heading">
<div class="icon">
{{ icon.key_solid() }}
</div>
<div class="header">
<h1 class="title">{{ _("mas.registration_token.headline") }}</h1>
<p class="text">{{ _("mas.registration_token.description") }}</p>
</div>
</header>
<div class="cpd-form-root">
<form method="POST" class="cpd-form-root">
{% if form.errors is not empty %}
{% for error in form.errors %}
<div class="text-critical font-medium">
{{ errors.form_error_message(error=error) }}
</div>
{% endfor %}
{% endif %}
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
{% call(f) field.field(label=_("mas.registration_token.field"), name="token", form_state=form, class="mb-4") %}
<input {{ field.attributes(f) }}
id="cpd-text-control"
type="text"
class="cpd-text-control"
required />
{% endcall %}
{{ button.button(text=_("action.continue")) }}
</form>
</div>
{% endblock content %}
+15 -1
View File
@@ -10,7 +10,7 @@
},
"continue": "Continue",
"@continue": {
"context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48"
"context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48"
},
"create_account": "Create Account",
"@create_account": {
@@ -635,6 +635,20 @@
"context": "pages/register/password.html:51:35-95, pages/upstream_oauth2/do_register.html:179:35-95"
}
},
"registration_token": {
"description": "Enter a registration token provided by the homeserver administrator.",
"@description": {
"context": "pages/register/steps/registration_token.html:17:25-64"
},
"field": "Registration token",
"@field": {
"context": "pages/register/steps/registration_token.html:33:35-68"
},
"headline": "Registration token",
"@headline": {
"context": "pages/register/steps/registration_token.html:16:27-63"
}
},
"scope": {
"edit_profile": "Edit your profile and contact details",
"@edit_profile": {