Skip to content

Commit d4e2d06

Browse files
committed
Registration token step view
1 parent 685f476 commit d4e2d06

File tree

8 files changed

+351
-2
lines changed

8 files changed

+351
-2
lines changed

crates/handlers/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,11 @@ where
392392
get(self::views::register::steps::verify_email::get)
393393
.post(self::views::register::steps::verify_email::post),
394394
)
395+
.route(
396+
mas_router::RegisterToken::route(),
397+
get(self::views::register::steps::registration_token::get)
398+
.post(self::views::register::steps::registration_token::post),
399+
)
395400
.route(
396401
mas_router::RegisterDisplayName::route(),
397402
get(self::views::register::steps::display_name::get)

crates/handlers/src/views/register/steps/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@
55

66
pub(crate) mod display_name;
77
pub(crate) mod finish;
8+
pub(crate) mod registration_token;
89
pub(crate) mod verify_email;
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// Copyright 2025 New Vector Ltd.
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only
4+
// Please see LICENSE in the repository root for full details.
5+
6+
use anyhow::Context as _;
7+
use axum::{
8+
Form,
9+
extract::{Path, State},
10+
response::{Html, IntoResponse, Response},
11+
};
12+
use mas_axum_utils::{
13+
InternalError,
14+
cookies::CookieJar,
15+
csrf::{CsrfExt as _, ProtectedForm},
16+
};
17+
use mas_router::{PostAuthAction, UrlBuilder};
18+
use mas_storage::{BoxClock, BoxRepository, BoxRng};
19+
use mas_templates::{
20+
FieldError, RegisterStepsRegistrationTokenContext, RegisterStepsRegistrationTokenFormField,
21+
TemplateContext as _, Templates, ToFormState,
22+
};
23+
use serde::{Deserialize, Serialize};
24+
use ulid::Ulid;
25+
26+
use crate::{PreferredLanguage, views::shared::OptionalPostAuthAction};
27+
28+
#[derive(Deserialize, Serialize)]
29+
pub(crate) struct RegistrationTokenForm {
30+
#[serde(default)]
31+
token: String,
32+
}
33+
34+
impl ToFormState for RegistrationTokenForm {
35+
type Field = mas_templates::RegisterStepsRegistrationTokenFormField;
36+
}
37+
38+
#[tracing::instrument(
39+
name = "handlers.views.register.steps.registration_token.get",
40+
fields(user_registration.id = %id),
41+
skip_all,
42+
)]
43+
pub(crate) async fn get(
44+
mut rng: BoxRng,
45+
clock: BoxClock,
46+
PreferredLanguage(locale): PreferredLanguage,
47+
State(templates): State<Templates>,
48+
State(url_builder): State<UrlBuilder>,
49+
mut repo: BoxRepository,
50+
Path(id): Path<Ulid>,
51+
cookie_jar: CookieJar,
52+
) -> Result<Response, InternalError> {
53+
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
54+
55+
let registration = repo
56+
.user_registration()
57+
.lookup(id)
58+
.await?
59+
.context("Could not find user registration")
60+
.map_err(InternalError::from_anyhow)?;
61+
62+
// If the registration is completed, we can go to the registration destination
63+
if registration.completed_at.is_some() {
64+
let post_auth_action: Option<PostAuthAction> = registration
65+
.post_auth_action
66+
.map(serde_json::from_value)
67+
.transpose()?;
68+
69+
return Ok((
70+
cookie_jar,
71+
OptionalPostAuthAction::from(post_auth_action)
72+
.go_next(&url_builder)
73+
.into_response(),
74+
)
75+
.into_response());
76+
}
77+
78+
// If the registration already has a token, skip this step
79+
if registration.user_registration_token_id.is_some() {
80+
let destination = mas_router::RegisterDisplayName::new(registration.id);
81+
return Ok((cookie_jar, url_builder.redirect(&destination)).into_response());
82+
}
83+
84+
let ctx = RegisterStepsRegistrationTokenContext::new()
85+
.with_csrf(csrf_token.form_value())
86+
.with_language(locale);
87+
88+
let content = templates.render_register_steps_registration_token(&ctx)?;
89+
90+
Ok((cookie_jar, Html(content)).into_response())
91+
}
92+
93+
#[tracing::instrument(
94+
name = "handlers.views.register.steps.registration_token.post",
95+
fields(user_registration.id = %id),
96+
skip_all,
97+
)]
98+
pub(crate) async fn post(
99+
mut rng: BoxRng,
100+
clock: BoxClock,
101+
PreferredLanguage(locale): PreferredLanguage,
102+
State(templates): State<Templates>,
103+
State(url_builder): State<UrlBuilder>,
104+
mut repo: BoxRepository,
105+
Path(id): Path<Ulid>,
106+
cookie_jar: CookieJar,
107+
Form(form): Form<ProtectedForm<RegistrationTokenForm>>,
108+
) -> Result<Response, InternalError> {
109+
let registration = repo
110+
.user_registration()
111+
.lookup(id)
112+
.await?
113+
.context("Could not find user registration")
114+
.map_err(InternalError::from_anyhow)?;
115+
116+
// If the registration is completed, we can go to the registration destination
117+
if registration.completed_at.is_some() {
118+
let post_auth_action: Option<PostAuthAction> = registration
119+
.post_auth_action
120+
.map(serde_json::from_value)
121+
.transpose()?;
122+
123+
return Ok((
124+
cookie_jar,
125+
OptionalPostAuthAction::from(post_auth_action)
126+
.go_next(&url_builder)
127+
.into_response(),
128+
)
129+
.into_response());
130+
}
131+
132+
let form = cookie_jar.verify_form(&clock, form)?;
133+
134+
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
135+
136+
// Validate the token
137+
let token = form.token.trim();
138+
if token.is_empty() {
139+
let ctx = RegisterStepsRegistrationTokenContext::new()
140+
.with_form_state(form.to_form_state().with_error_on_field(
141+
RegisterStepsRegistrationTokenFormField::Token,
142+
FieldError::Required,
143+
))
144+
.with_csrf(csrf_token.form_value())
145+
.with_language(locale);
146+
147+
return Ok((
148+
cookie_jar,
149+
Html(templates.render_register_steps_registration_token(&ctx)?),
150+
)
151+
.into_response());
152+
}
153+
154+
// Look up the token
155+
let Some(registration_token) = repo.user_registration_token().find_by_token(token).await?
156+
else {
157+
let ctx = RegisterStepsRegistrationTokenContext::new()
158+
.with_form_state(form.to_form_state().with_error_on_field(
159+
RegisterStepsRegistrationTokenFormField::Token,
160+
FieldError::Invalid,
161+
))
162+
.with_csrf(csrf_token.form_value())
163+
.with_language(locale);
164+
165+
return Ok((
166+
cookie_jar,
167+
Html(templates.render_register_steps_registration_token(&ctx)?),
168+
)
169+
.into_response());
170+
};
171+
172+
// Check if the token is still valid
173+
if !registration_token.is_valid(clock.now()) {
174+
tracing::warn!("Registration token isn't valid (expired or already used)");
175+
let ctx = RegisterStepsRegistrationTokenContext::new()
176+
.with_form_state(form.to_form_state().with_error_on_field(
177+
RegisterStepsRegistrationTokenFormField::Token,
178+
FieldError::Invalid,
179+
))
180+
.with_csrf(csrf_token.form_value())
181+
.with_language(locale);
182+
183+
return Ok((
184+
cookie_jar,
185+
Html(templates.render_register_steps_registration_token(&ctx)?),
186+
)
187+
.into_response());
188+
}
189+
190+
// Associate the token with the registration
191+
let registration = repo
192+
.user_registration()
193+
.set_registration_token(registration, &registration_token)
194+
.await?;
195+
196+
repo.save().await?;
197+
198+
// Continue to the next step
199+
let destination = mas_router::RegisterFinish::new(registration.id);
200+
Ok((cookie_jar, url_builder.redirect(&destination)).into_response())
201+
}

crates/router/src/endpoints.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,30 @@ impl From<Option<PostAuthAction>> for PasswordRegister {
382382
}
383383
}
384384

385+
/// `GET|POST /register/steps/{id}/token`
386+
#[derive(Debug, Clone)]
387+
pub struct RegisterToken {
388+
id: Ulid,
389+
}
390+
391+
impl RegisterToken {
392+
#[must_use]
393+
pub fn new(id: Ulid) -> Self {
394+
Self { id }
395+
}
396+
}
397+
398+
impl Route for RegisterToken {
399+
type Query = ();
400+
fn route() -> &'static str {
401+
"/register/steps/{id}/token"
402+
}
403+
404+
fn path(&self) -> std::borrow::Cow<'static, str> {
405+
format!("/register/steps/{}/token", self.id).into()
406+
}
407+
}
408+
385409
/// `GET|POST /register/steps/{id}/display-name`
386410
#[derive(Debug, Clone)]
387411
pub struct RegisterDisplayName {

crates/templates/src/context.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,6 +1068,61 @@ impl TemplateContext for RegisterStepsDisplayNameContext {
10681068
}
10691069
}
10701070

1071+
/// Fields of the registration token form
1072+
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
1073+
#[serde(rename_all = "snake_case")]
1074+
pub enum RegisterStepsRegistrationTokenFormField {
1075+
/// The registration token
1076+
Token,
1077+
}
1078+
1079+
impl FormField for RegisterStepsRegistrationTokenFormField {
1080+
fn keep(&self) -> bool {
1081+
match self {
1082+
Self::Token => true,
1083+
}
1084+
}
1085+
}
1086+
1087+
/// The registration token page context
1088+
#[derive(Serialize, Default)]
1089+
pub struct RegisterStepsRegistrationTokenContext {
1090+
form: FormState<RegisterStepsRegistrationTokenFormField>,
1091+
}
1092+
1093+
impl RegisterStepsRegistrationTokenContext {
1094+
/// Constructs a context for the registration token page
1095+
#[must_use]
1096+
pub fn new() -> Self {
1097+
Self::default()
1098+
}
1099+
1100+
/// Set the form state
1101+
#[must_use]
1102+
pub fn with_form_state(
1103+
mut self,
1104+
form_state: FormState<RegisterStepsRegistrationTokenFormField>,
1105+
) -> Self {
1106+
self.form = form_state;
1107+
self
1108+
}
1109+
}
1110+
1111+
impl TemplateContext for RegisterStepsRegistrationTokenContext {
1112+
fn sample(
1113+
_now: chrono::DateTime<chrono::Utc>,
1114+
_rng: &mut impl Rng,
1115+
_locales: &[DataLocale],
1116+
) -> Vec<Self>
1117+
where
1118+
Self: Sized,
1119+
{
1120+
vec![Self {
1121+
form: FormState::default(),
1122+
}]
1123+
}
1124+
}
1125+
10711126
/// Fields of the account recovery start form
10721127
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
10731128
#[serde(rename_all = "snake_case")]

crates/templates/src/lib.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ pub use self::{
4242
RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField,
4343
RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext,
4444
RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
45-
RegisterStepsEmailInUseContext, RegisterStepsVerifyEmailContext,
45+
RegisterStepsEmailInUseContext, RegisterStepsRegistrationTokenContext,
46+
RegisterStepsRegistrationTokenFormField, RegisterStepsVerifyEmailContext,
4647
RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures,
4748
TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField,
4849
UpstreamSuggestLink, WithCaptcha, WithCsrf, WithLanguage, WithOptionalSession, WithSession,
@@ -340,6 +341,9 @@ register_templates! {
340341
/// Render the display name page
341342
pub fn render_register_steps_display_name(WithLanguage<WithCsrf<RegisterStepsDisplayNameContext>>) { "pages/register/steps/display_name.html" }
342343

344+
/// Render the registration token page
345+
pub fn render_register_steps_registration_token(WithLanguage<WithCsrf<RegisterStepsRegistrationTokenContext>>) { "pages/register/steps/registration_token.html" }
346+
343347
/// Render the client consent page
344348
pub fn render_consent(WithLanguage<WithCsrf<WithSession<ConsentContext>>>) { "pages/consent.html" }
345349

@@ -444,6 +448,7 @@ impl Templates {
444448
check::render_register_steps_verify_email(self, now, rng)?;
445449
check::render_register_steps_email_in_use(self, now, rng)?;
446450
check::render_register_steps_display_name(self, now, rng)?;
451+
check::render_register_steps_registration_token(self, now, rng)?;
447452
check::render_consent(self, now, rng)?;
448453
check::render_policy_violation(self, now, rng)?;
449454
check::render_sso_login(self, now, rng)?;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{#
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only
5+
Please see LICENSE in the repository root for full details.
6+
-#}
7+
8+
{% extends "base.html" %}
9+
10+
{% block content %}
11+
<header class="page-heading">
12+
<div class="icon">
13+
{{ icon.key_solid() }}
14+
</div>
15+
<div class="header">
16+
<h1 class="title">{{ _("mas.registration_token.headline") }}</h1>
17+
<p class="text">{{ _("mas.registration_token.description") }}</p>
18+
</div>
19+
</header>
20+
21+
<div class="cpd-form-root">
22+
<form method="POST" class="cpd-form-root">
23+
{% if form.errors is not empty %}
24+
{% for error in form.errors %}
25+
<div class="text-critical font-medium">
26+
{{ errors.form_error_message(error=error) }}
27+
</div>
28+
{% endfor %}
29+
{% endif %}
30+
31+
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
32+
33+
{% call(f) field.field(label=_("mas.registration_token.field"), name="token", form_state=form, class="mb-4") %}
34+
<input {{ field.attributes(f) }}
35+
id="cpd-text-control"
36+
type="text"
37+
class="cpd-text-control"
38+
required />
39+
{% endcall %}
40+
41+
{{ button.button(text=_("action.continue")) }}
42+
</form>
43+
</div>
44+
{% endblock content %}

0 commit comments

Comments
 (0)