Registration step to set a display name
This commit is contained in:
@@ -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),
|
||||
|
||||
182
crates/handlers/src/views/register/steps/display_name.rs
Normal file
182
crates/handlers/src/views/register/steps/display_name.rs
Normal 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());
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
52
templates/pages/register/steps/display_name.html
Normal file
52
templates/pages/register/steps/display_name.html
Normal 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 %}
|
||||
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user