Registration step to set a display name

This commit is contained in:
Quentin Gliech
2025-01-15 15:27:40 +01:00
parent f03a817738
commit 3eed8d39fb
11 changed files with 348 additions and 4 deletions

View File

@@ -383,6 +383,11 @@ where
get(self::views::register::steps::verify_email::get)
.post(self::views::register::steps::verify_email::post),
)
.route(
mas_router::RegisterDisplayName::route(),
get(self::views::register::steps::display_name::get)
.post(self::views::register::steps::display_name::post),
)
.route(
mas_router::RegisterFinish::route(),
get(self::views::register::steps::finish::get),

View File

@@ -0,0 +1,182 @@
// 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::{
extract::{Path, State},
response::{Html, IntoResponse, Response},
Form,
};
use mas_axum_utils::{
cookies::CookieJar,
csrf::{CsrfExt as _, ProtectedForm},
FancyError,
};
use mas_router::{PostAuthAction, UrlBuilder};
use mas_storage::{BoxClock, BoxRepository, BoxRng};
use mas_templates::{
FieldError, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
TemplateContext as _, Templates, ToFormState,
};
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use crate::{views::shared::OptionalPostAuthAction, PreferredLanguage};
#[derive(Deserialize, Default)]
#[serde(rename_all = "snake_case")]
enum FormAction {
#[default]
Set,
Skip,
}
#[derive(Deserialize, Serialize)]
pub(crate) struct DisplayNameForm {
#[serde(skip_serializing, default)]
action: FormAction,
#[serde(default)]
display_name: String,
}
impl ToFormState for DisplayNameForm {
type Field = mas_templates::RegisterStepsDisplayNameFormField;
}
#[tracing::instrument(
name = "handlers.views.register.steps.display_name.get",
fields(user_registration.id = %id),
skip_all,
err,
)]
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, FancyError> {
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")?;
// If the registration is completed, we can go to the registration destination
// XXX: this might not be the right thing to do? Maybe an error page would be
// better?
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 ctx = RegisterStepsDisplayNameContext::new()
.with_csrf(csrf_token.form_value())
.with_language(locale);
let content = templates.render_register_steps_display_name(&ctx)?;
Ok((cookie_jar, Html(content)).into_response())
}
#[tracing::instrument(
name = "handlers.views.register.steps.display_name.post",
fields(user_registration.id = %id),
skip_all,
err,
)]
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<DisplayNameForm>>,
) -> Result<Response, FancyError> {
let registration = repo
.user_registration()
.lookup(id)
.await?
.context("Could not find user registration")?;
// If the registration is completed, we can go to the registration destination
// XXX: this might not be the right thing to do? Maybe an error page would be
// better?
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);
let display_name = match form.action {
FormAction::Set => {
let display_name = form.display_name.trim();
if display_name.is_empty() || display_name.len() > 255 {
let ctx = RegisterStepsDisplayNameContext::new()
.with_form_state(form.to_form_state().with_error_on_field(
RegisterStepsDisplayNameFormField::DisplayName,
FieldError::Invalid,
))
.with_csrf(csrf_token.form_value())
.with_language(locale);
return Ok((
cookie_jar,
Html(templates.render_register_steps_display_name(&ctx)?),
)
.into_response());
}
display_name.to_owned()
}
FormAction::Skip => {
// If the user chose to skip, we do the same as Synapse and use the localpart as
// default display name
registration.username.clone()
}
};
let registration = repo
.user_registration()
.set_display_name(registration, display_name)
.await?;
repo.save().await?;
let destination = mas_router::RegisterFinish::new(registration.id);
return Ok((cookie_jar, url_builder.redirect(&destination)).into_response());
}

View File

@@ -102,6 +102,14 @@ pub(crate) async fn get(
)));
}
// Check that the display name is set
if registration.display_name.is_none() {
return Ok((
cookie_jar,
url_builder.redirect(&mas_router::RegisterDisplayName::new(registration.id)),
));
}
// Everuthing is good, let's complete the registration
let registration = repo
.user_registration()

View File

@@ -3,5 +3,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
pub(crate) mod display_name;
pub(crate) mod finish;
pub(crate) mod verify_email;

View File

@@ -444,6 +444,30 @@ impl From<Option<PostAuthAction>> for PasswordRegister {
}
}
/// `GET|POST /register/steps/:id/display-name`
#[derive(Debug, Clone)]
pub struct RegisterDisplayName {
id: Ulid,
}
impl RegisterDisplayName {
#[must_use]
pub fn new(id: Ulid) -> Self {
Self { id }
}
}
impl Route for RegisterDisplayName {
type Query = ();
fn route() -> &'static str {
"/register/steps/:id/display-name"
}
fn path(&self) -> std::borrow::Cow<'static, str> {
format!("/register/steps/{}/display-name", self.id).into()
}
}
/// `GET|POST /register/steps/:id/verify-email`
#[derive(Debug, Clone)]
pub struct RegisterVerifyEmail {

View File

@@ -1000,6 +1000,57 @@ impl TemplateContext for RegisterStepsVerifyEmailContext {
}
}
/// Fields for the display name form
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RegisterStepsDisplayNameFormField {
/// The display name
DisplayName,
}
impl FormField for RegisterStepsDisplayNameFormField {
fn keep(&self) -> bool {
match self {
Self::DisplayName => true,
}
}
}
/// Context used by the `display_name.html` template
#[derive(Serialize, Default)]
pub struct RegisterStepsDisplayNameContext {
form: FormState<RegisterStepsDisplayNameFormField>,
}
impl RegisterStepsDisplayNameContext {
/// Constructs a context for the display name 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<RegisterStepsDisplayNameFormField>,
) -> Self {
self.form = form_state;
self
}
}
impl TemplateContext for RegisterStepsDisplayNameContext {
fn sample(_now: chrono::DateTime<chrono::Utc>, _rng: &mut impl Rng) -> 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")]

View File

@@ -41,6 +41,7 @@ pub use self::{
PostAuthContextInner, ReauthContext, ReauthFormField, RecoveryExpiredContext,
RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext,
RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField,
RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField, SiteBranding,
SiteConfigExt, SiteFeatures, TemplateContext, UpstreamExistingLinkContext,
UpstreamRegister, UpstreamRegisterFormField, UpstreamSuggestLink, WithCaptcha, WithCsrf,
@@ -335,6 +336,9 @@ register_templates! {
/// Render the email verification page
pub fn render_register_steps_verify_email(WithLanguage<WithCsrf<RegisterStepsVerifyEmailContext>>) { "pages/register/steps/verify_email.html" }
/// Render the display name page
pub fn render_register_steps_display_name(WithLanguage<WithCsrf<RegisterStepsDisplayNameContext>>) { "pages/register/steps/display_name.html" }
/// Render the client consent page
pub fn render_consent(WithLanguage<WithCsrf<WithSession<ConsentContext>>>) { "pages/consent.html" }
@@ -428,6 +432,7 @@ impl Templates {
check::render_register(self, now, rng)?;
check::render_password_register(self, now, rng)?;
check::render_register_steps_verify_email(self, now, rng)?;
check::render_register_steps_display_name(self, now, rng)?;
check::render_consent(self, now, rng)?;
check::render_policy_violation(self, now, rng)?;
check::render_sso_login(self, now, rng)?;

View File

@@ -29,6 +29,7 @@ Please see LICENSE in the repository root for full details.
class="",
value="",
disabled=False,
kind="primary",
size="lg",
autocomplete=False,
autocorrect=False,
@@ -39,7 +40,7 @@ Please see LICENSE in the repository root for full details.
type="{{ type }}"
{% if disabled %}disabled{% endif %}
class="cpd-button {{ class }}"
data-kind="primary"
data-kind="{{ kind }}"
data-size="{{ size }}"
{% if autocapitalize %}autocapitilize="{{ autocapitilize }}"{% endif %}
{% if autocomplete %}autocomplete="{{ autocomplete }}"{% endif %}

View File

@@ -0,0 +1,52 @@
{#
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.visibility_on() }}
</div>
<div class="header">
<h1 class="title">{{ _("mas.choose_display_name.headline") }}</h1>
<p class="text">{{ _("mas.choose_display_name.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 }}" />
<input type="hidden" name="action" value="set" />
{% call(f) field.field(label=_("common.display_name"), name="display_name", form_state=form, class="mb-4") %}
<input {{ field.attributes(f) }}
id="cpd-text-control"
type="text"
maxlength="256"
class="cpd-text-control"
required />
{% endcall %}
{{ button.button(text=_("action.continue")) }}
</form>
<form method="POST" class="cpd-form-root">
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
<input type="hidden" name="action" value="skip" />
{{ button.button(text=_("action.skip"), kind="tertiary") }}
</form>
</div>
{% endblock content %}

View File

@@ -33,7 +33,6 @@ Please see LICENSE in the repository root for full details.
{% call(f) field.field(label=_("mas.verify_email.6_digit_code"), name="code", form_state=form, class="mb-4 self-center") %}
<div class="cpd-mfa-container">
<input {{ field.attributes(f) }}
id="mfa-code-input"
inputmode="numeric"
type="text"
minlength="0"

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:123:13-33, pages/device_link.html:40:26-46, pages/login.html:62: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/verify_email.html:52: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:123:13-33, pages/device_link.html:40:26-46, pages/login.html:62: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"
},
"create_account": "Create Account",
"@create_account": {
@@ -24,6 +24,10 @@
"@sign_out": {
"context": "pages/consent.html:65:28-48, pages/device_consent.html:135:30-50, pages/index.html:28:28-48, pages/policy_violation.html:38:28-48, pages/sso.html:45:28-48, pages/upstream_oauth2/link_mismatch.html:24:24-44, pages/upstream_oauth2/suggest_link.html:32:26-46"
},
"skip": "Skip",
"@skip": {
"context": "pages/register/steps/display_name.html:49:28-44"
},
"start_over": "Start over",
"@start_over": {
"context": "pages/recovery/consumed.html:22:32-54, pages/recovery/expired.html:30:32-54"
@@ -71,7 +75,7 @@
"common": {
"display_name": "Display Name",
"@display_name": {
"context": "pages/upstream_oauth2/do_register.html:146:37-61"
"context": "pages/register/steps/display_name.html:34:35-59, pages/upstream_oauth2/do_register.html:146:37-61"
},
"email_address": "Email address",
"@email_address": {
@@ -140,6 +144,18 @@
"description": "Field for the user's new password"
}
},
"choose_display_name": {
"description": "This is the name other people will see. You can change this at any time.",
"@description": {
"context": "pages/register/steps/display_name.html:17:25-65",
"description": "During the registration flow, the user is asked to choose a display name. This is the description of that form."
},
"headline": "Choose your display name",
"@headline": {
"context": "pages/register/steps/display_name.html:16:27-64",
"description": "During the registration flow, the user is asked to choose a display name. This is the headline of that form."
}
},
"consent": {
"client_wants_access": "<span>%(client_name)s</span> at <span>%(redirect_uri)s</span> wants to access your account.",
"@client_wants_access": {