Merge the password registration form into /register

`/register` was a username-first step handing off to
`/register/password`; now that the form checks availability as you type,
that step has nothing left to do. Both pages become one: the island
mounts on `/register` and renders the form, the upstream provider
buttons and the sign-in link, the way `/login` already lays them out. It
mounts even when password registration is disabled, since the providers
now live inside it.

`/register/password` and its `PasswordRegister` route are removed
outright rather than kept as an alias. `RegisterContext` and
`PasswordRegisterContext` become one context, which resolves the
provider URLs and the sign-in link server-side so that the island only
has to render them.
This commit is contained in:
Quentin Gliech
2026-08-11 17:44:22 +02:00
parent c0518aed43
commit ff0ae5fcd5
13 changed files with 1764 additions and 1542 deletions
-4
View File
@@ -395,10 +395,6 @@ where
mas_router::Register::route(),
get(self::views::register::get).post(self::views::register::post),
)
.route(
mas_router::PasswordRegister::route(),
get(self::views::register::password::get).post(self::views::register::password::post),
)
.route(
mas_router::RegisterVerifyEmail::route(),
get(self::views::register::steps::verify_email::get)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-76
View File
@@ -331,82 +331,6 @@ impl From<Option<PostAuthAction>> for Register {
}
}
/// `GET|POST /register/password`
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct PasswordRegister {
username: Option<String>,
#[serde(flatten)]
post_auth_action: Option<PostAuthAction>,
}
impl PasswordRegister {
#[must_use]
pub fn and_then(mut self, action: PostAuthAction) -> Self {
self.post_auth_action = Some(action);
self
}
/// Prefill the form with the given username
#[must_use]
pub fn with_username(mut self, username: String) -> Self {
self.username = Some(username);
self
}
#[must_use]
pub fn and_continue_grant(mut self, data: Ulid) -> Self {
self.post_auth_action = Some(PostAuthAction::continue_grant(data));
self
}
#[must_use]
pub fn and_continue_compat_sso_login(mut self, data: Ulid) -> Self {
self.post_auth_action = Some(PostAuthAction::continue_compat_sso_login(data));
self
}
/// Get a reference to the post auth action.
#[must_use]
pub fn post_auth_action(&self) -> Option<&PostAuthAction> {
self.post_auth_action.as_ref()
}
/// Get a reference to the username chosen by the user.
#[must_use]
pub fn username(&self) -> Option<&str> {
self.username.as_deref()
}
pub fn go_next(&self, url_builder: &UrlBuilder) -> axum::response::Redirect {
match &self.post_auth_action {
Some(action) => action.go_next(url_builder),
None => url_builder.redirect(&Index),
}
}
}
impl Route for PasswordRegister {
type Query = Self;
fn route() -> &'static str {
"/register/password"
}
fn query(&self) -> Option<&Self::Query> {
Some(self)
}
}
impl From<Option<PostAuthAction>> for PasswordRegister {
fn from(post_auth_action: Option<PostAuthAction>) -> Self {
Self {
username: None,
post_auth_action,
}
}
}
/// `GET|POST /register/steps/{id}/token`
#[derive(Debug, Clone)]
pub struct RegisterToken {
+53 -55
View File
@@ -31,7 +31,7 @@ use mas_data_model::{
use mas_i18n::DataLocale;
use mas_iana::jose::JsonWebSignatureAlg;
use mas_policy::{Violation, ViolationVariant};
use mas_router::{Account, GraphQL, PostAuthAction, UrlBuilder};
use mas_router::{Account, GraphQL, Login, PostAuthAction, UrlBuilder};
use oauth2_types::scope::{OPENID, Scope};
use rand::{
Rng, SeedableRng,
@@ -645,11 +645,44 @@ impl FormField for RegisterFormField {
}
}
/// Context used by the `register.html` template
#[derive(Serialize, Default)]
/// An upstream OAuth 2.0 provider, as rendered by the registration page island
#[derive(Serialize)]
struct RegisterPageProvider {
name: String,
brand: Option<String>,
/// Submitted back as the `provider` field of the registration form
id: String,
}
impl RegisterPageProvider {
fn new(provider: UpstreamOAuthProvider) -> Self {
let name = provider
.human_name
.or_else(|| {
provider
.issuer
.as_deref()
.map(|issuer| crate::functions::simplify_url(issuer, true))
})
.filter(|name| !name.is_empty())
.unwrap_or_else(|| provider.id.to_string());
Self {
name,
brand: provider.brand_name,
id: provider.id.to_string(),
}
}
}
/// Context used by the `register/index.html` template
#[derive(Serialize)]
pub struct RegisterContext {
providers: Vec<UpstreamOAuthProvider>,
providers: Vec<RegisterPageProvider>,
login_link: String,
form: FormState<RegisterFormField>,
next: Option<PostAuthContext>,
graphql_endpoint: String,
}
impl TemplateContext for RegisterContext {
@@ -661,69 +694,34 @@ impl TemplateContext for RegisterContext {
where
Self: Sized,
{
sample_list(vec![RegisterContext {
providers: Vec::new(),
next: None,
}])
let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None);
// TODO: samples with errors and with upstream providers
sample_list(vec![RegisterContext::new(&url_builder, Vec::new(), None)])
}
}
impl RegisterContext {
/// Create a new context with the given upstream providers
/// Create a new context with the given upstream providers, resolving the
/// URLs used by the client-side island from the given [`UrlBuilder`]
#[must_use]
pub fn new(providers: Vec<UpstreamOAuthProvider>) -> Self {
Self {
providers,
next: None,
}
}
/// Add a post authentication action to the context
#[must_use]
pub fn with_post_action(self, next: PostAuthContext) -> Self {
Self {
next: Some(next),
..self
}
}
}
/// Context used by the `password_register.html` template
#[derive(Serialize)]
pub struct PasswordRegisterContext {
form: FormState<RegisterFormField>,
next: Option<PostAuthContext>,
graphql_endpoint: String,
}
impl TemplateContext for PasswordRegisterContext {
fn sample<R: Rng>(
_now: chrono::DateTime<Utc>,
_rng: &mut R,
_locales: &[DataLocale],
) -> BTreeMap<SampleIdentifier, Self>
where
Self: Sized,
{
let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None);
// TODO: samples with errors
sample_list(vec![PasswordRegisterContext::new(&url_builder)])
}
}
impl PasswordRegisterContext {
/// Create a new context, resolving the GraphQL endpoint used by the
/// client-side form from the given [`UrlBuilder`]
#[must_use]
pub fn new(url_builder: &UrlBuilder) -> Self {
pub fn new(
url_builder: &UrlBuilder,
providers: Vec<UpstreamOAuthProvider>,
post_auth_action: Option<&PostAuthAction>,
) -> Self {
Self {
providers: providers
.into_iter()
.map(RegisterPageProvider::new)
.collect(),
login_link: url_builder.relative_url_for(&Login::from(post_auth_action.cloned())),
form: FormState::default(),
next: None,
graphql_endpoint: url_builder.relative_url_for(&GraphQL),
}
}
/// Add an error on the registration form
/// Set the state of the registration form
#[must_use]
pub fn with_form_state(self, form: FormState<RegisterFormField>) -> Self {
Self { form, ..self }
+15 -15
View File
@@ -1,4 +1,4 @@
// Copyright 2026 Element Creations Ltd.
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
//
@@ -105,11 +105,11 @@ fn filter_to_params(params: &Value, kwargs: Kwargs) -> Result<String, Error> {
}
}
/// Filter which simplifies a URL to its domain name for HTTP(S) URLs
fn filter_simplify_url(url: &str, kwargs: Kwargs) -> Result<String, minijinja::Error> {
/// Simplify a URL to its domain name for HTTP(S) URLs
pub(crate) fn simplify_url(url: &str, keep_path: bool) -> String {
// Do nothing if the URL is not valid
let Ok(mut url) = Url::from_str(url) else {
return Ok(url.to_owned());
return url.to_owned();
};
// Always at least remove the query parameters and fragment
@@ -118,28 +118,28 @@ fn filter_simplify_url(url: &str, kwargs: Kwargs) -> Result<String, minijinja::E
// Do nothing else for non-HTTPS URLs
if url.scheme() != "https" {
return Ok(url.to_string());
return url.to_string();
}
let keep_path = kwargs.get::<Option<bool>>("keep_path")?.unwrap_or_default();
kwargs.assert_all_used()?;
// Only return the domain name
let Some(domain) = url.domain() else {
return Ok(url.to_string());
return url.to_string();
};
if keep_path {
Ok(format!(
"{domain}{path}",
domain = domain,
path = url.path(),
))
format!("{domain}{path}", domain = domain, path = url.path())
} else {
Ok(domain.to_owned())
domain.to_owned()
}
}
fn filter_simplify_url(url: &str, kwargs: Kwargs) -> Result<String, minijinja::Error> {
let keep_path = kwargs.get::<Option<bool>>("keep_path")?.unwrap_or_default();
kwargs.assert_all_used()?;
Ok(simplify_url(url, keep_path))
}
/// Filter which computes a hash between 1 and 6 of an input string, identitical
/// to compound-web's `useIdColorHash`
fn filter_id_color_hash(input: &str) -> u32 {
+5 -9
View File
@@ -42,11 +42,10 @@ pub use self::{
CompatSsoContext, ConsentContext, DeviceConsentContext, DeviceLinkContext,
DeviceLinkFormField, DeviceNameContext, EmailRecoveryContext, EmailVerificationContext,
EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField,
NotFoundContext, PasswordRegisterContext, PolicyViolationContext, PostAuthContext,
PostAuthContextInner, RecoveryExpiredContext, RecoveryFinishContext,
RecoveryFinishFormField, RecoveryProgressContext, RecoveryStartContext,
RecoveryStartFormField, RegisterContext, RegisterFormField,
RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
NotFoundContext, PolicyViolationContext, PostAuthContext, PostAuthContextInner,
RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField,
RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext,
RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
RegisterStepsEmailInUseContext, RegisterStepsRegistrationTokenContext,
RegisterStepsRegistrationTokenFormField, RegisterStepsVerifyEmailContext,
RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures,
@@ -372,10 +371,7 @@ register_templates! {
pub fn render_login(WithLanguage<WithCsrf<LoginContext>>) { "pages/login.html" }
/// Render the registration page
pub fn render_register(WithLanguage<WithCsrf<RegisterContext>>) { "pages/register/index.html" }
/// Render the password registration page
pub fn render_password_register(WithLanguage<WithCsrf<WithCaptcha<PasswordRegisterContext>>>) { "pages/register/password.html" }
pub fn render_register(WithLanguage<WithCsrf<WithCaptcha<RegisterContext>>>) { "pages/register/index.html" }
/// Render the email verification page
pub fn render_register_steps_verify_email(WithLanguage<WithCsrf<RegisterStepsVerifyEmailContext>>) { "pages/register/steps/verify_email.html" }
+5 -1
View File
@@ -250,9 +250,13 @@
}
},
"register": {
"call_to_login": "Already have an account? Sign in",
"call_to_login": "Already have an account?",
"captcha_incomplete": "Please complete the CAPTCHA challenge before continuing",
"captcha_loading": "The CAPTCHA is still loading, please wait a moment",
"continue_with_email": "Continue with email address",
"continue_with_password": "Continue with password",
"continue_with_provider": "Continue with {{provider}}",
"or_separator": "Or",
"password_confirm_label": "Confirm password",
"password_label": "Password",
"terms_of_service": "I agree to the <a>Terms and Conditions</a>",
+198
View File
@@ -0,0 +1,198 @@
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
// Brand logos for upstream OAuth 2.0 providers. Must be kept in sync with
// templates/components/idp_brand.html, which the server-rendered login page
// still uses.
const LOGOS: Record<string, React.ReactElement> = {
google: (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M24.0022 12.276C24.0022 11.4603 23.9348 10.6402 23.7908 9.83771H12.2429V14.4585H18.8559C18.5814 15.9488 17.6997 17.2672 16.4086 18.1049V21.1032H20.3539C22.6707 19.0132 24.0022 15.9268 24.0022 12.276Z"
fill="#4285F4"
/>
<path
d="M12.2428 24C15.5448 24 18.3294 22.9374 20.3583 21.1032L16.413 18.1049C15.3154 18.8369 13.8983 19.2513 12.2473 19.2513C9.05332 19.2513 6.34517 17.1393 5.37347 14.2998H1.30225V17.3906C3.3806 21.4427 7.61377 24 12.2428 24Z"
fill="#34A853"
/>
<path
d="M5.36907 14.2998C4.85623 12.8095 4.85623 11.1957 5.36907 9.7054V6.61456H1.30234C-0.434114 10.0052 -0.434114 13.9999 1.30234 17.3906L5.36907 14.2998Z"
fill="#FBBC04"
/>
<path
d="M12.2428 4.7495C13.9883 4.72305 15.6753 5.36679 16.9394 6.54845L20.4348 3.12251C18.2215 1.08547 15.2839 -0.0344648 12.2428 0.000808639C7.61377 0.000808639 3.3806 2.55814 1.30225 6.61459L5.36898 9.70543C6.33617 6.8615 9.04883 4.7495 12.2428 4.7495Z"
fill="#EA4335"
/>
</svg>
),
gitlab: (
<svg
aria-hidden="true"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.0002 23.1079L16.4193 9.50749H7.58116L12.0002 23.1079Z"
fill="#E24329"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.9999 23.1079L7.58081 9.50749H1.38759L11.9999 23.1079Z"
fill="#FC6D26"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M1.38772 9.50749L0.0448467 13.6405C-0.0776384 14.0175 0.0565119 14.4305 0.377192 14.6634L12 23.1079L1.38772 9.50749Z"
fill="#FCA326"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M1.38759 9.50752H7.58081L4.91919 1.31612C4.78229 0.89457 4.18599 0.894684 4.0491 1.31612L1.38759 9.50752Z"
fill="#E24329"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.0001 23.1079L16.4192 9.50749H22.6124L12.0001 23.1079Z"
fill="#FC6D26"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M22.6124 9.50749L23.9552 13.6405C24.0777 14.0175 23.9436 14.4305 23.6229 14.6634L12.0001 23.1079L22.6124 9.50749Z"
fill="#FCA326"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M22.6122 9.50752H16.4189L19.0806 1.31612C19.2175 0.89457 19.8138 0.894684 19.9507 1.31612L22.6122 9.50752Z"
fill="#E24329"
/>
</svg>
),
twitter: (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
>
<path
d="M9.04155 21C6.6153 21 4.35363 20.2943 2.45 19.0767C4.06624 19.1813 6.91855 18.9308 8.69268 17.2386C6.0238 17.1161 4.82019 15.0692 4.6632 14.1945C4.88997 14.2819 5.97147 14.3869 6.582 14.142C3.51192 13.3722 3.04094 10.678 3.1456 9.85573C3.72124 10.2581 4.69809 10.3981 5.08185 10.3631C2.22109 8.31618 3.25027 5.23707 3.75613 4.57226C5.80911 7.4165 8.8859 9.01393 12.6923 9.10278C12.6205 8.78802 12.5826 8.46032 12.5826 8.12373C12.5826 5.70819 14.5351 3.75 16.9435 3.75C18.2019 3.75 19.3358 4.28457 20.1318 5.13963C20.9727 4.94258 22.2382 4.4813 22.8569 4.0824C22.5451 5.20208 21.5742 6.13612 20.9869 6.48231C20.9918 6.49408 20.9821 6.47048 20.9869 6.48231C21.5028 6.40428 22.8986 6.13603 23.45 5.76192C23.1773 6.39094 22.148 7.4368 21.3033 8.02232C21.4604 14.9535 16.1574 21 9.04155 21Z"
fill="#1D9BF0"
/>
</svg>
),
github: (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.9642 0C5.34833 0 0 5.38776 0 12.0531C0 17.3811 3.42686 21.8912 8.18082 23.4874C8.77518 23.6074 8.9929 23.2281 8.9929 22.909C8.9929 22.6296 8.97331 21.6718 8.97331 20.6738C5.64514 21.3923 4.95208 19.237 4.95208 19.237C4.41722 17.8401 3.62473 17.4811 3.62473 17.4811C2.53543 16.7427 3.70408 16.7427 3.70408 16.7427C4.91241 16.8225 5.54645 17.9799 5.54645 17.9799C6.61592 19.8157 8.33926 19.297 9.03257 18.9776C9.13151 18.1993 9.44865 17.6606 9.78539 17.3613C7.13094 17.0819 4.33812 16.0442 4.33812 11.4144C4.33812 10.0974 4.81322 9.01984 5.56604 8.1818C5.44727 7.88253 5.03118 6.64506 5.68506 4.98882C5.68506 4.98882 6.69527 4.66947 8.97306 6.22604C9.94827 5.9622 10.954 5.82799 11.9642 5.82686C12.9744 5.82686 14.0042 5.96669 14.9552 6.22604C17.2332 4.66947 18.2434 4.98882 18.2434 4.98882C18.8973 6.64506 18.481 7.88253 18.3622 8.1818C19.1349 9.01984 19.5904 10.0974 19.5904 11.4144C19.5904 16.0442 16.7976 17.0618 14.1233 17.3613C14.5592 17.7404 14.9353 18.4587 14.9353 19.5962C14.9353 21.2126 14.9158 22.5098 14.9158 22.9087C14.9158 23.2281 15.1337 23.6074 15.7278 23.4877C20.4818 21.8909 23.9087 17.3811 23.9087 12.0531C23.9282 5.38776 18.5603 0 11.9642 0Z"
fill="currentColor"
/>
</svg>
),
facebook: (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
>
<path
d="M10.02 23.88C4.32 22.86 0 17.94 0 12C0 5.4 5.4 0 12 0C18.6 0 24 5.4 24 12C24 17.94 19.68 22.86 13.98 23.88L13.32 23.34H10.68L10.02 23.88Z"
fill="url(#mas-facebook-logo-gradient)"
/>
<path
d="M16.68 15.36L17.22 12H14.04V9.66C14.04 8.7 14.4 7.98 15.84 7.98H17.4V4.92C16.56 4.8 15.6 4.68 14.76 4.68C12 4.68 10.08 6.36 10.08 9.36V12H7.08V15.36H10.08V23.82C10.74 23.94 11.4 24 12.06 24C12.72 24 13.38 23.94 14.04 23.82V15.36H16.68Z"
fill="white"
/>
<defs>
<linearGradient
id="mas-facebook-logo-gradient"
x1="12.0006"
y1="23.1654"
x2="12.0006"
y2="-0.00442066"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#0062E0" />
<stop offset="1" stopColor="#19AFFF" />
</linearGradient>
</defs>
</svg>
),
apple: (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="28"
viewBox="0 0 24 28"
fill="none"
>
<path
d="M20.9144 8.1816C20.7752 8.2896 18.3176 9.6744 18.3176 12.7536C18.3176 16.3152 21.4448 17.5752 21.5384 17.6064C21.524 17.6832 21.0416 19.332 19.8896 21.012C18.8624 22.4904 17.7896 23.9664 16.1576 23.9664C14.5256 23.9664 14.1056 23.0184 12.2216 23.0184C10.3856 23.0184 9.7328 23.9976 8.24 23.9976C6.7472 23.9976 5.7056 22.6296 4.508 20.9496C3.1208 18.9768 2 15.912 2 13.0032C2 8.3376 5.0336 5.8632 8.0192 5.8632C9.6056 5.8632 10.928 6.9048 11.924 6.9048C12.872 6.9048 14.3504 5.8008 16.1552 5.8008C16.8392 5.8008 19.2968 5.8632 20.9144 8.1816ZM15.2984 3.8256C16.0448 2.94 16.5728 1.7112 16.5728 0.4824C16.5728 0.312 16.5584 0.1392 16.5272 0C15.3128 0.0456 13.868 0.8088 12.9968 1.8192C12.3128 2.5968 11.6744 3.8256 11.6744 5.0712C11.6744 5.2584 11.7056 5.4456 11.72 5.5056C11.7968 5.52 11.9216 5.5368 12.0464 5.5368C13.136 5.5368 14.5064 4.8072 15.2984 3.8256Z"
fill="currentColor"
/>
</svg>
),
discord: (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M19.27 5.33C17.94 4.71 16.5 4.26 15 4a.1.1 0 0 0-.07.03c-.18.33-.39.76-.53 1.09a16.1 16.1 0 0 0-4.8 0c-.14-.34-.35-.76-.54-1.09c-.01-.02-.04-.03-.07-.03c-1.5.26-2.93.71-4.27 1.33c-.01 0-.02.01-.03.02c-2.72 4.07-3.47 8.03-3.1 11.95c0 .02.01.04.03.05c1.8 1.32 3.53 2.12 5.24 2.65c.03.01.06 0 .07-.02c.4-.55.76-1.13 1.07-1.74c.02-.04 0-.08-.04-.09c-.57-.22-1.11-.48-1.64-.78c-.04-.02-.04-.08-.01-.11c.11-.08.22-.17.33-.25c.02-.02.05-.02.07-.01c3.44 1.57 7.15 1.57 10.55 0c.02-.01.05-.01.07.01c.11.09.22.17.33.26c.04.03.04.09-.01.11c-.52.31-1.07.56-1.64.78c-.04.01-.05.06-.04.09c.32.61.68 1.19 1.07 1.74c.03.01.06.02.09.01c1.72-.53 3.45-1.33 5.25-2.65c.02-.01.03-.03.03-.05c.44-4.53-.73-8.46-3.1-11.95c-.01-.01-.02-.02-.04-.02M8.52 14.91c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.84 2.12-1.89 2.12m6.97 0c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.83 2.12-1.89 2.12"
/>
</svg>
),
};
/** Whether a brand has a logo, i.e. whether the button needs icon spacing. */
export const hasProviderLogo = (brand: string | null): boolean =>
brand !== null && brand in LOGOS;
const ProviderLogo: React.FC<{ brand: string | null }> = ({ brand }) =>
brand !== null && brand in LOGOS ? LOGOS[brand] : null;
export default ProviderLogo;
@@ -4,12 +4,13 @@
// Please see LICENSE files in the repository root for full details.
import { QueryClient, useQuery } from "@tanstack/react-query";
import { Button, Form, InlineSpinner } from "@vector-im/compound-web";
import { useCallback, useRef, useState } from "react";
import { Form, InlineSpinner } from "@vector-im/compound-web";
import { useCallback, useEffect, useRef, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import * as v from "valibot";
import { CaptchaSection } from "../components/Captcha";
import PasswordComplexityFeedback from "../components/PasswordComplexityFeedback";
import ProviderLogo, { hasProviderLogo } from "../components/ProviderLogo";
import { graphql } from "../gql";
import { graphqlRequest } from "../graphql";
import { mountIsland } from "../utils/mountIsland";
@@ -43,12 +44,22 @@ const fieldStateSchema = v.object({
errors: v.array(serverErrorSchema),
});
const providerSchema = v.object({
/** Display name, already resolved server-side */
name: v.string(),
/** Raw `brand_name`; only the brands we have a logo for get an icon */
brand: v.nullable(v.string()),
/** Submitted back as the `provider` field of the form */
id: v.string(),
});
type Provider = v.InferOutput<typeof providerSchema>;
// Parsed from the mount node's `data-*` attributes; structured values are
// JSON-encoded by the template.
const schema = v.object({
csrfToken: v.string(),
graphqlEndpoint: v.string(),
loginLink: v.string(),
captchaConfig: v.optional(
v.pipe(
v.string(),
@@ -75,6 +86,7 @@ const schema = v.object({
v.string(),
v.parseJson(),
v.object({
password_registration: v.boolean(),
password_registration_email_required: v.boolean(),
minimum_password_complexity: v.number(),
}),
@@ -87,6 +99,9 @@ const schema = v.object({
fields: v.record(v.string(), fieldStateSchema),
}),
),
providers: v.pipe(v.string(), v.parseJson(), v.array(providerSchema)),
/** Href for the "already have an account?" call to action */
loginLink: v.string(),
});
type Data = v.InferOutput<typeof schema>;
@@ -177,7 +192,9 @@ const UsernameField: React.FC<{
serverName: string;
defaultValue: string;
serverErrors: ServerError[];
}> = ({ serverName, defaultValue, serverErrors }) => {
/** Reports the settled verdict, which is what holds the chooser step back */
onAvailabilityChange: (available: boolean | undefined) => void;
}> = ({ serverName, defaultValue, serverErrors, onAvailabilityChange }) => {
const { t } = useTranslation();
const [username, setUsername] = useState(defaultValue);
// Until the user edits the field, what the POST came back with is the truth
@@ -203,6 +220,11 @@ const UsernameField: React.FC<{
dirty &&
isUsernameCheckable(normalized) &&
(isFetching || isDebouncePending);
const availability = settled ? data?.usernameAvailable : undefined;
useEffect(() => {
onAvailabilityChange(availability?.available);
}, [availability, onAvailabilityChange]);
return (
<Form.Field
@@ -233,7 +255,7 @@ const UsernameField: React.FC<{
<UsernameVerdict
checking={checking}
checkFailed={settled && isError}
availability={settled ? data?.usernameAvailable : undefined}
availability={availability}
/>
</div>
@@ -339,25 +361,171 @@ const PasswordFields: React.FC<{
);
};
/** Same look as the SSR `field.separator()` macro. */
const OrSeparator: React.FC = () => {
const { t } = useTranslation();
return (
<div className="separator">
<hr />
<p>{t("frontend.register.or_separator")}</p>
<hr />
</div>
);
};
/**
* Each provider is a submit button of the enclosing form, so that whatever was
* typed in the username field travels with the request which starts the
* upstream flow.
*/
const ProviderButtons: React.FC<{ providers: Provider[] }> = ({
providers,
}) => {
const { t } = useTranslation();
return (
<>
{providers.map((provider) => (
<button
key={provider.id}
type="submit"
name="provider"
value={provider.id}
// The username is advisory on this path: don't hold the user back
// over a field the upstream provider may well override
formNoValidate
className={
hasProviderLogo(provider.brand)
? "cpd-button has-icon"
: "cpd-button"
}
data-kind="secondary"
data-size="lg"
>
<ProviderLogo brand={provider.brand} />
{t("frontend.register.continue_with_provider", {
provider: provider.name,
})}
</button>
))}
</>
);
};
const LoginLink: React.FC<{ href: string }> = ({ href }) => {
const { t } = useTranslation();
return (
<a className="cpd-button" data-kind="tertiary" data-size="lg" href={href}>
{t("frontend.register.call_to_login")}
</a>
);
};
/**
* Which half of the flow is on screen: the chooser, where the username is
* picked and the way to continue is chosen, or the details the account needs.
*/
type Step = 1 | 2;
/** Reads the step back out of a history entry, defaulting to the chooser. */
const stepFromHistory = (state: unknown): Step =>
(state as { registerStep?: unknown } | null)?.registerStep === 2 ? 2 : 1;
const PasswordRegisterForm: React.FC<{ data: Data }> = ({ data }) => {
const { t } = useTranslation();
const { fields, errors: formErrors } = data.form;
const { providers } = data;
// `null` until the widget has mounted and told us it is ready; `true` right
// away when there is no captcha to solve.
const [captchaValid, setCaptchaValid] = useState<boolean | null>(
data.captchaConfig ? null : true,
);
const [captchaError, setCaptchaError] = useState<string | null>(null);
const [usernameAvailable, setUsernameAvailable] = useState<
boolean | undefined
>(undefined);
const onCaptchaValidChange = useCallback((valid: boolean) => {
setCaptchaValid(valid);
if (valid) setCaptchaError(null);
}, []);
// With no provider to pick from there is nothing to choose, so the whole form
// is shown at once
const twoStep = providers.length > 0;
// A render carrying a failed POST means the user has already been past the
// chooser, and putting them back in front of it would hide the errors
const submitted =
formErrors.length > 0 ||
Object.values(fields).some(
(field) => field.errors.length > 0 || !!field.value,
);
const initialStep: Step = twoStep && !submitted ? 1 : 2;
const [step, setStep] = useState(initialStep);
const details = useRef<HTMLFieldSetElement>(null);
// The chooser used to be a page of its own, so give it a history entry: the
// back button then goes back to it rather than off the page
useEffect(() => {
if (!twoStep) return;
history.replaceState({ registerStep: initialStep }, "");
const onPopState = (e: PopStateEvent) => setStep(stepFromHistory(e.state));
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, [twoStep, initialStep]);
const showDetails = useCallback(() => {
setStep(2);
history.pushState({ registerStep: 2 }, "");
}, []);
// Uncovering the details is a navigation of sorts: hand over the first field
// that just appeared, but leave a first render alone — the server errors it
// may carry get the focus instead
const shown = useRef(step);
useEffect(() => {
const revealed = shown.current === 1 && step === 2;
shown.current = step;
if (!revealed) return;
details.current
?.querySelector<HTMLElement>("input:not([type='hidden'])")
?.focus();
}, [step]);
// Captcha widgets size themselves to their container, which a hidden one
// doesn't have. Once mounted it stays, so a solved challenge survives a trip
// back to the chooser.
const [mountCaptcha, setMountCaptcha] = useState(initialStep === 2);
useEffect(() => {
if (step === 2) setMountCaptcha(true);
}, [step]);
return (
<Form.Root
method="POST"
onSubmit={(e) => {
// A provider button submits the form as-is: let the browser POST it,
// carrying the username along to the server, which starts the upstream
// flow from there
const { submitter } = e.nativeEvent as SubmitEvent;
if (
submitter instanceof HTMLButtonElement &&
submitter.name === "provider"
) {
return;
}
if (step === 1) {
e.preventDefault();
// Native validation has vetted the username already; all that is left
// is a settled verdict against it, which the field displays itself.
// Moving the focus into the details blurs the field, which is what
// normalizes whatever was typed in it.
if (usernameAvailable !== false) showDetails();
return;
}
// Enter-to-submit bypasses the field's onBlur, so normalize here too.
// Writing to the DOM is safe: the page navigates away right after.
const username = e.currentTarget.elements.namedItem("username");
@@ -397,96 +565,145 @@ const PasswordRegisterForm: React.FC<{ data: Data }> = ({ data }) => {
serverName={data.branding.server_name}
defaultValue={fields.username?.value ?? ""}
serverErrors={fields.username?.errors ?? []}
onAvailabilityChange={setUsernameAvailable}
/>
{data.features.password_registration_email_required && (
<Form.Field name="email" serverInvalid={!!fields.email?.errors.length}>
<Form.Label>{t("common.email_address")}</Form.Label>
<Form.TextControl
type="email"
required
autoComplete="email"
defaultValue={fields.email?.value ?? ""}
/>
<Form.ErrorMessage match="typeMismatch">
{t("frontend.errors.invalid_email")}
</Form.ErrorMessage>
<Form.ErrorMessage match="valueMissing">
{t("frontend.errors.field_required")}
</Form.ErrorMessage>
{fields.email?.errors.map((error, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static
<Form.ErrorMessage key={`${error.kind}-${index}`}>
{fieldErrorMessage(t, error)}
</Form.ErrorMessage>
))}
</Form.Field>
{/* Ahead of the details, so that hitting Enter in the username field
reaches this button and not the disabled final submit */}
{step === 1 && (
<>
<Form.Submit>
{data.features.password_registration_email_required
? t("frontend.register.continue_with_email")
: t("frontend.register.continue_with_password")}
</Form.Submit>
<OrSeparator />
<ProviderButtons providers={providers} />
</>
)}
<PasswordFields
minimumPasswordComplexity={data.features.minimum_password_complexity}
serverErrors={fields.password?.errors ?? []}
confirmServerErrors={fields.password_confirm?.errors ?? []}
/>
{data.branding.tos_uri && (
<Form.InlineField
name="accept_terms"
control={<Form.CheckboxControl required value="on" />}
serverInvalid={!!fields.accept_terms?.errors.length}
>
<Form.Label>
<Trans
i18nKey="frontend.register.terms_of_service"
components={{
a: (
// biome-ignore lint/a11y/useAnchorContent: content filled by Trans
<a
href={data.branding.tos_uri}
target="_blank"
rel="noreferrer"
className="cpd-link"
data-kind="primary"
/>
),
}}
{/* Kept mounted across steps so that nothing typed into it is lost;
`disabled` is what keeps the browser from validating, and the password
manager from filling, fields nobody can see */}
<fieldset
ref={details}
className="cpd-form-root min-w-0"
hidden={step === 1}
disabled={step === 1}
>
{data.features.password_registration_email_required && (
<Form.Field
name="email"
serverInvalid={!!fields.email?.errors.length}
>
<Form.Label>{t("common.email_address")}</Form.Label>
<Form.TextControl
type="email"
required
autoComplete="email"
defaultValue={fields.email?.value ?? ""}
/>
</Form.Label>
<Form.ErrorMessage match="valueMissing">
{t("frontend.errors.field_required")}
</Form.ErrorMessage>
{fields.accept_terms?.errors.map((error, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static
<Form.ErrorMessage key={`${error.kind}-${index}`}>
{fieldErrorMessage(t, error)}
<Form.ErrorMessage match="typeMismatch">
{t("frontend.errors.invalid_email")}
</Form.ErrorMessage>
))}
</Form.InlineField>
)}
<Form.ErrorMessage match="valueMissing">
{t("frontend.errors.field_required")}
</Form.ErrorMessage>
{fields.email?.errors.map((error, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static
<Form.ErrorMessage key={`${error.kind}-${index}`}>
{fieldErrorMessage(t, error)}
</Form.ErrorMessage>
))}
</Form.Field>
)}
<CaptchaSection
config={data.captchaConfig}
onValidChange={onCaptchaValidChange}
/>
<PasswordFields
minimumPasswordComplexity={data.features.minimum_password_complexity}
serverErrors={fields.password?.errors ?? []}
confirmServerErrors={fields.password_confirm?.errors ?? []}
/>
{captchaError && (
<div role="alert" className="text-critical font-medium">
{captchaError}
</div>
)}
{data.branding.tos_uri && (
<Form.InlineField
name="accept_terms"
control={<Form.CheckboxControl required value="on" />}
serverInvalid={!!fields.accept_terms?.errors.length}
>
<Form.Label>
<Trans
i18nKey="frontend.register.terms_of_service"
components={{
a: (
// biome-ignore lint/a11y/useAnchorContent: content filled by Trans
<a
href={data.branding.tos_uri}
target="_blank"
rel="noreferrer"
className="cpd-link"
data-kind="primary"
/>
),
}}
/>
</Form.Label>
<Form.ErrorMessage match="valueMissing">
{t("frontend.errors.field_required")}
</Form.ErrorMessage>
{fields.accept_terms?.errors.map((error, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static
<Form.ErrorMessage key={`${error.kind}-${index}`}>
{fieldErrorMessage(t, error)}
</Form.ErrorMessage>
))}
</Form.InlineField>
)}
<Form.Submit>{t("action.continue")}</Form.Submit>
{mountCaptcha && (
<CaptchaSection
config={data.captchaConfig}
onValidChange={onCaptchaValidChange}
/>
)}
<Button as="a" kind="tertiary" size="lg" href={data.loginLink}>
{t("frontend.register.call_to_login")}
</Button>
{captchaError && (
<div role="alert" className="text-critical font-medium">
{captchaError}
</div>
)}
<Form.Submit>{t("action.continue")}</Form.Submit>
</fieldset>
{/* The sign-in link is part of the chooser; without one it simply sits
under the form, where it has always been */}
{(step === 1 || !twoStep) && <LoginLink href={data.loginLink} />}
</Form.Root>
);
};
const RegisterPage: React.FC<{ data: Data }> = ({ data }) => {
// Without password registration there is nothing to fill in: the providers
// and the sign-in link are the whole page. The form is still what carries the
// provider buttons, so it stays, with nothing in it but the CSRF token.
if (!data.features.password_registration) {
return (
<form method="POST" className="cpd-form-root">
<input type="hidden" name="csrf" value={data.csrfToken} />
<ProviderButtons providers={data.providers} />
<LoginLink href={data.loginLink} />
</form>
);
}
return <PasswordRegisterForm data={data} />;
};
void mountIsland({
id: "password-register-form",
id: "register-form",
schema,
queryClient,
children: (data) => <PasswordRegisterForm data={data} />,
children: (data) => <RegisterPage data={data} />,
});
+43 -53
View File
@@ -9,60 +9,50 @@ Please see LICENSE files in the repository root for full details.
{% extends "base.html" %}
{% from "components/idp_brand.html" import logo %}
{% block head %}
{{ include_asset('src/entrypoints/register.tsx') | indent(4) | safe }}
{# Pre-load the locale data for the current language #}
{{ include_asset('locales/' ~ lang ~ '.json') | indent(4) | safe }}
{% endblock head %}
{% block content %}
<form method="POST" class="flex flex-col gap-10" action="{{ '/register' | prefix_url }}">
<header class="page-heading">
<div class="icon">
{{ icon.user_profile_solid() }}
</div>
<div class="header">
<h1 class="title">{{ _("mas.register.create_account.heading") }}</h1>
{% if features.password_registration %}
<p class="text">{{ _("mas.register.create_account.description") }}</p>
{% endif %}
</div>
</header>
{% if features.password_registration %}
{% call(f) field.field(label=_("common.username"), name="username", form_state=form) %}
<input {{ field.attributes(f) }} class="cpd-text-control" type="text" autocomplete="username" autocorrect="off" autocapitalize="off" data-choose-username />
<div class="cpd-form-message cpd-form-help-message" id="{{ f.id }}-help">
@username:{{ branding.server_name }}
</div>
{% endcall %}
{% endif %}
<div class="cpd-form-root">
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
{% for key, value in next["params"] | default({}) | items %}
<input type="hidden" name="{{ key }}" value="{{ value }}" />
{% endfor %}
{% if features.password_registration %}
{% if features.password_registration_email_required %}
{{ button.button(text=_("mas.register.continue_with_email")) }}
{% else %}
{{ button.button(text=_("mas.register.continue_with_password")) }}
{% endif %}
{% endif %}
{% if providers %}
{% for provider in providers %}
{% set name = provider.human_name or (provider.issuer | simplify_url(keep_path=True)) or provider.id %}
<button type="submit" name="provider" value="{{ provider.id }}" class="cpd-button {%- if provider.brand_name %} has-icon {%- endif %}" data-kind="secondary" data-size="lg">
{{ logo(provider.brand_name) }}
{{ _("mas.login.continue_with_provider", provider=name) }}
</button>
{% endfor %}
{% endif %}
{% set params = next["params"] | default({}) | to_params(prefix="?") %}
{{ button.link_tertiary(text=_("mas.register.call_to_login"), href="/login" ~ params) }}
<header class="page-heading">
<div class="icon">
{{ icon.user_profile_solid() }}
</div>
</form>
<div class="header">
<h1 class="title">{{ _("mas.register.create_account.heading") }}</h1>
</div>
</header>
<div id="register-form"
data-csrf-token="{{ csrf_token }}"
{%- if captcha_config %} data-captcha-config='{{ captcha_config | tojson }}'{% endif %}
data-branding='{{ branding | tojson }}'
data-features='{{ features | tojson }}'
data-form='{{ form | tojson }}'
data-providers='{{ providers | tojson }}'
data-login-link="{{ login_link }}"
data-graphql-endpoint="{{ graphql_endpoint }}">
<p class="sr-only">{{ _("mas.loading") }}</p>
{# Rough outline of the form, to keep the page from jumping once it mounts #}
<div class="register-skeleton" aria-hidden="true">
<div class="field"><span class="label"></span><span class="control"></span></div>
<div class="field"><span class="label"></span><span class="control"></span></div>
<span class="submit"></span>
</div>
</div>
<div id="register-form-error" class="critical-notice" hidden>
{{ _("mas.loading_failed") }}
</div>
<noscript>
{# The skeleton would otherwise sit above this message forever #}
<style>#register-form { display: none; }</style>
<p class="critical-notice">{{ _("mas.register.javascript_required") }}</p>
</noscript>
{% endblock content %}
-64
View File
@@ -1,64 +0,0 @@
{#
Copyright 2025, 2026 Element Creations Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2021-2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
-#}
{% extends "base.html" %}
{% block head %}
{{ include_asset('src/entrypoints/password-register.tsx') | indent(4) | safe }}
{# Pre-load the locale data for the current language #}
{{ include_asset('locales/' ~ lang ~ '.json') | indent(4) | safe }}
{% endblock head %}
{% block content %}
<header class="page-heading">
<div class="icon">
{{ icon.user_profile_solid() }}
</div>
<div class="header">
<h1 class="title">{{ _("mas.register.create_account.heading") }}</h1>
</div>
</header>
{% set params = next["params"] | default({}) | to_params(prefix="?") %}
<div id="password-register-form"
data-csrf-token="{{ csrf_token }}"
{%- if captcha_config %} data-captcha-config='{{ captcha_config | tojson }}'{% endif %}
data-branding='{{ branding | tojson }}'
data-features='{{ features | tojson }}'
data-form='{{ form | tojson }}'
data-graphql-endpoint="{{ graphql_endpoint }}"
data-login-link="{{ ("/login" ~ params) | prefix_url }}">
<p class="sr-only">{{ _("mas.loading") }}</p>
{# Rough outline of the form, to keep the page from jumping once it mounts #}
<div class="register-skeleton" aria-hidden="true">
<div class="field"><span class="label"></span><span class="control"></span></div>
<div class="field"><span class="label"></span><span class="control"></span></div>
<span class="submit"></span>
</div>
</div>
<div id="password-register-form-error" class="critical-notice" hidden>
{{ _("mas.loading_failed") }}
</div>
<noscript>
{# The skeleton would otherwise sit above this message forever #}
<style>#password-register-form { display: none; }</style>
<div class="flex flex-col gap-5">
<p class="critical-notice">{{ _("mas.register.javascript_required") }}</p>
{{ button.link_tertiary(text=_("mas.register.call_to_login"), href="/login" ~ params) }}
</div>
</noscript>
{% endblock content %}
+6 -19
View File
@@ -95,7 +95,7 @@
},
"username": "Username",
"@username": {
"context": "pages/login.html:51:39-59, pages/register/index.html:30:35-55, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:107:39-59"
"context": "pages/login.html:51:39-59, pages/upstream_oauth2/do_register.html:101:35-55, pages/upstream_oauth2/do_register.html:107:39-59"
}
},
"error": {
@@ -442,12 +442,12 @@
},
"loading": "Loading…",
"@loading": {
"context": "pages/register/password.html:40:26-42",
"context": "pages/register/index.html:38:26-42",
"description": "Announced to screen readers while a page loads"
},
"loading_failed": "Something went wrong while loading the page. Try reloading it.",
"@loading_failed": {
"context": "app.html:28:9-32, pages/register/password.html:51:7-30"
"context": "app.html:28:9-32, pages/register/index.html:49:7-30"
},
"login": {
"call_to_register": "Don't have an account yet?",
@@ -456,7 +456,7 @@
},
"continue_with_provider": "Continue with %(provider)s",
"@continue_with_provider": {
"context": "pages/login.html:81:15-67, pages/register/index.html:57:15-67",
"context": "pages/login.html:81:15-67",
"description": "Button to log in with an upstream provider"
},
"description": "Please sign in to continue:",
@@ -659,30 +659,17 @@
"register": {
"call_to_login": "Already have an account?",
"@call_to_login": {
"context": "pages/register/index.html:63:35-66, pages/register/password.html:61:35-66",
"description": "Displayed on the registration page to suggest to log in instead"
},
"continue_with_email": "Continue with email address",
"@continue_with_email": {
"context": "pages/register/index.html:45:32-69"
},
"continue_with_password": "Continue with password",
"@continue_with_password": {
"context": "pages/register/index.html:47:32-72"
},
"create_account": {
"description": "Choose a username to continue.",
"@description": {
"context": "pages/register/index.html:24:29-73"
},
"heading": "Create an account",
"@heading": {
"context": "pages/register/index.html:21:29-69, pages/register/password.html:25:27-67"
"context": "pages/register/index.html:25:27-67"
}
},
"javascript_required": "JavaScript is required to create an account. Please enable JavaScript in your browser and reload this page.",
"@javascript_required": {
"context": "pages/register/password.html:59:36-73"
"context": "pages/register/index.html:56:34-71"
},
"terms_of_service": "I agree to the <a href=\"%s\" data-kind=\"primary\" class=\"cpd-link\">Terms and Conditions</a>",
"@terms_of_service": {