mirror of
https://github.com/element-hq/matrix-authentication-service.git
synced 2026-10-15 19:51:02 +00:00
Pre-fill the registration form from an invite link
`/register?token=…` now resolves the invite code server-side and hands what it was issued for to the form island through a `data-invite` attribute: a pinned email address is shown as the line announcing where the verification code goes rather than as an input, a pinned username isn't shown at all, and a `passwordless` code replaces the password fields with a notice and relabels the submit button. A code the server refused only reports that, as an error above the form. Because a passwordless code is a way to register on its own, the shortcuts which send the page to the upstream provider or to `/login` when password registration is disabled are skipped when the link carries one. The code itself travels back on submission through a hidden field, so a form which comes back with errors keeps its shape, and so does the redirect the page sends the user to when the CSRF token has expired.
This commit is contained in:
@@ -25,7 +25,10 @@ use mas_storage::{
|
||||
BoxRepository, upstream_oauth2::UpstreamOAuthProviderRepository,
|
||||
user::UserRegistrationTokenRepository,
|
||||
};
|
||||
use mas_templates::{RegisterContext, RegisterFormField, TemplateContext, Templates, ToFormState};
|
||||
use mas_templates::{
|
||||
FormState, InviteContext, RegisterContext, RegisterFormField, TemplateContext, Templates,
|
||||
ToFormState,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use ulid::Ulid;
|
||||
@@ -48,6 +51,16 @@ pub use self::cookie::UserRegistrationSessions as UserRegistrationSessionsCookie
|
||||
#[error("Upstream OAuth 2.0 provider not found")]
|
||||
struct ProviderNotFound;
|
||||
|
||||
/// The query parameters of the registration page: an invite link deep-links
|
||||
/// with a registration token, on top of the usual post-auth action
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct QueryParams {
|
||||
token: Option<String>,
|
||||
|
||||
#[serde(flatten)]
|
||||
action: OptionalPostAuthAction,
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "handlers.views.register.get", skip_all)]
|
||||
pub(crate) async fn get(
|
||||
mut rng: BoxRng,
|
||||
@@ -58,7 +71,7 @@ pub(crate) async fn get(
|
||||
State(site_config): State<SiteConfig>,
|
||||
mut repo: BoxRepository,
|
||||
activity_tracker: BoundActivityTracker,
|
||||
Query(query): Query<OptionalPostAuthAction>,
|
||||
Query(query): Query<QueryParams>,
|
||||
cookie_jar: CookieJar,
|
||||
) -> Result<Response, InternalError> {
|
||||
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
|
||||
@@ -71,21 +84,36 @@ pub(crate) async fn get(
|
||||
.record_browser_session(&clock, &session)
|
||||
.await;
|
||||
|
||||
let reply = query.go_next(&url_builder);
|
||||
let reply = query.action.go_next(&url_builder);
|
||||
return Ok((cookie_jar, reply).into_response());
|
||||
}
|
||||
|
||||
// Resolve the invite code the link carried, so that the form can show what
|
||||
// it was issued for
|
||||
let registration_token = match &query.token {
|
||||
Some(token) => repo
|
||||
.user_registration_token()
|
||||
.find_by_token(token)
|
||||
.await?
|
||||
.filter(|token| token.is_valid(clock.now())),
|
||||
None => None,
|
||||
};
|
||||
let passwordless_invite = registration_token
|
||||
.as_ref()
|
||||
.is_some_and(|token| token.passwordless);
|
||||
|
||||
let providers = repo.upstream_oauth_provider().all_enabled().await?;
|
||||
|
||||
// Without a password form there is nothing to show beyond the provider
|
||||
// buttons, which isn't worth a page for one provider or none
|
||||
if !site_config.password_registration_enabled {
|
||||
// buttons, which isn't worth a page for one provider or none. A
|
||||
// passwordless invite is a way to register on its own, so it keeps the page
|
||||
if !site_config.password_registration_enabled && !passwordless_invite {
|
||||
if providers.len() == 1 {
|
||||
let provider = providers.into_iter().next().unwrap();
|
||||
|
||||
let mut destination = UpstreamOAuth2Authorize::new(provider.id);
|
||||
|
||||
if let Some(action) = query.post_auth_action {
|
||||
if let Some(action) = query.action.post_auth_action {
|
||||
destination = destination.and_then(action);
|
||||
}
|
||||
|
||||
@@ -93,12 +121,28 @@ pub(crate) async fn get(
|
||||
}
|
||||
|
||||
if providers.is_empty() {
|
||||
let destination = mas_router::Login::from(query.post_auth_action);
|
||||
let destination = mas_router::Login::from(query.action.post_auth_action);
|
||||
return Ok((cookie_jar, url_builder.redirect(&destination)).into_response());
|
||||
}
|
||||
}
|
||||
|
||||
let ctx = RegisterContext::new(&url_builder, providers, query.post_auth_action.as_ref())
|
||||
let mut ctx = RegisterContext::new(
|
||||
&url_builder,
|
||||
providers,
|
||||
query.action.post_auth_action.as_ref(),
|
||||
);
|
||||
|
||||
// The code travels back on submission through a hidden field, whether it
|
||||
// resolved or not
|
||||
if let Some(token) = query.token {
|
||||
let mut form = FormState::default();
|
||||
form.set_value(RegisterFormField::Token, Some(token));
|
||||
ctx = ctx
|
||||
.with_form_state(form)
|
||||
.with_invite(InviteContext::new(registration_token.as_ref()));
|
||||
}
|
||||
|
||||
let ctx = ctx
|
||||
.with_captcha(site_config.captcha.clone())
|
||||
.with_csrf(csrf_token.form_value())
|
||||
.with_language(locale);
|
||||
@@ -170,7 +214,7 @@ pub(crate) async fn post(
|
||||
Option<TypedHeader<headers::UserAgent>>,
|
||||
BoundActivityTracker,
|
||||
),
|
||||
Query(query): Query<OptionalPostAuthAction>,
|
||||
Query(query): Query<QueryParams>,
|
||||
cookie_jar: CookieJar,
|
||||
Form(form): Form<ProtectedForm<RegisterForm>>,
|
||||
) -> Result<Response, InternalError> {
|
||||
@@ -179,7 +223,10 @@ pub(crate) async fn post(
|
||||
let Ok(mut form) = cookie_jar.verify_form(&clock, form) else {
|
||||
// An invalid token most likely comes from a page left open past the CSRF token lifetime
|
||||
tracing::debug!("Invalid CSRF token on the registration form, redirecting to a fresh one");
|
||||
let destination = Register::from(query.post_auth_action);
|
||||
let mut destination = Register::from(query.action.post_auth_action);
|
||||
if let Some(token) = query.token {
|
||||
destination = destination.with_token(token);
|
||||
}
|
||||
return Ok((cookie_jar, url_builder.redirect(&destination)).into_response());
|
||||
};
|
||||
|
||||
@@ -220,7 +267,7 @@ pub(crate) async fn post(
|
||||
&mut repo,
|
||||
cookie_jar,
|
||||
&provider,
|
||||
query.post_auth_action,
|
||||
query.action.post_auth_action,
|
||||
carried_username,
|
||||
)
|
||||
.await?;
|
||||
@@ -265,7 +312,7 @@ pub(crate) async fn post(
|
||||
repo,
|
||||
user_agent,
|
||||
&activity_tracker,
|
||||
query,
|
||||
query.action,
|
||||
cookie_jar,
|
||||
form,
|
||||
registration_token,
|
||||
@@ -287,6 +334,7 @@ mod tests {
|
||||
use mas_storage::{
|
||||
RepositoryAccess,
|
||||
upstream_oauth2::{UpstreamOAuthProviderParams, UpstreamOAuthSessionRepository},
|
||||
user::UserRegistrationTokenRepository as _,
|
||||
};
|
||||
use oauth2_types::scope::{OPENID, Scope};
|
||||
use sqlx::PgPool;
|
||||
@@ -457,6 +505,101 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Opening the page with an invite code resolves it server-side, and the
|
||||
/// code travels back through the form
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_get_with_invite(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool(pool).await.unwrap();
|
||||
let mut rng = state.rng();
|
||||
|
||||
let mut repo = state.repository().await.unwrap();
|
||||
repo.user_registration_token()
|
||||
.add(
|
||||
&mut rng,
|
||||
&state.clock,
|
||||
"invite_alice".to_owned(),
|
||||
None,
|
||||
None,
|
||||
Some("alice".to_owned()),
|
||||
Some("alice@example.com".to_owned()),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.save().await.unwrap();
|
||||
|
||||
let request = Request::get("/register?token=invite_alice").empty();
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::OK);
|
||||
assert_eq!(
|
||||
json_attribute(response.body(), "data-invite"),
|
||||
serde_json::json!({
|
||||
"valid": true,
|
||||
"username": "alice",
|
||||
"email": "alice@example.com",
|
||||
"passwordless": true,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
json_attribute(response.body(), "data-form")["fields"]["token"]["value"],
|
||||
serde_json::json!("invite_alice")
|
||||
);
|
||||
|
||||
// A code the server doesn't know only says so
|
||||
let request = Request::get("/register?token=nope").empty();
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::OK);
|
||||
assert_eq!(
|
||||
json_attribute(response.body(), "data-invite"),
|
||||
serde_json::json!({ "valid": false })
|
||||
);
|
||||
}
|
||||
|
||||
/// A passwordless invite is a way to register on its own, so the page keeps
|
||||
/// the form even when password registration is disabled
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_get_with_invite_without_password_registration(pool: PgPool) {
|
||||
setup();
|
||||
let state = TestState::from_pool_with_site_config(
|
||||
pool,
|
||||
SiteConfig {
|
||||
password_login_enabled: false,
|
||||
password_registration_enabled: false,
|
||||
..test_site_config()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut rng = state.rng();
|
||||
|
||||
let mut repo = state.repository().await.unwrap();
|
||||
repo.user_registration_token()
|
||||
.add(
|
||||
&mut rng,
|
||||
&state.clock,
|
||||
"invite_alice".to_owned(),
|
||||
None,
|
||||
None,
|
||||
Some("alice".to_owned()),
|
||||
Some("alice@example.com".to_owned()),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.save().await.unwrap();
|
||||
|
||||
let request = Request::get("/register?token=invite_alice").empty();
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::OK);
|
||||
|
||||
// Without the code, there is nothing to register with here
|
||||
let request = Request::get("/register").empty();
|
||||
let response = state.request(request).await;
|
||||
response.assert_status(StatusCode::SEE_OTHER);
|
||||
response.assert_header_value(LOCATION, "/login");
|
||||
}
|
||||
|
||||
/// Without password registration, the page is just the provider buttons
|
||||
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
|
||||
async fn test_get_password_disabled_with_providers(pool: PgPool) {
|
||||
|
||||
@@ -26,8 +26,8 @@ use mas_storage::{
|
||||
user::{UserEmailRepository, UserRegistrationRepository as _, UserRepository},
|
||||
};
|
||||
use mas_templates::{
|
||||
FieldError, FormError, FormState, RegisterContext, RegisterFormField, TemplateContext,
|
||||
Templates, ToFormState as _,
|
||||
FieldError, FormError, FormState, InviteContext, RegisterContext, RegisterFormField,
|
||||
TemplateContext, Templates, ToFormState as _,
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
@@ -277,6 +277,11 @@ pub(super) async fn register(
|
||||
};
|
||||
|
||||
if !state.is_valid() {
|
||||
// Re-render with what the invite code resolved to, so the form keeps
|
||||
// the shape the user submitted it in
|
||||
let invite =
|
||||
(!form.token.is_empty()).then(|| InviteContext::new(registration_token.as_ref()));
|
||||
|
||||
let content = render(
|
||||
locale,
|
||||
state,
|
||||
@@ -286,6 +291,7 @@ pub(super) async fn register(
|
||||
templates,
|
||||
url_builder,
|
||||
site_config.captcha.clone(),
|
||||
invite,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -387,10 +393,17 @@ async fn render(
|
||||
templates: &Templates,
|
||||
url_builder: &UrlBuilder,
|
||||
captcha_config: Option<CaptchaConfig>,
|
||||
invite: Option<InviteContext>,
|
||||
) -> Result<String, InternalError> {
|
||||
let providers = repo.upstream_oauth_provider().all_enabled().await?;
|
||||
let ctx = RegisterContext::new(url_builder, providers, action.post_auth_action.as_ref())
|
||||
.with_form_state(form_state)
|
||||
let mut ctx = RegisterContext::new(url_builder, providers, action.post_auth_action.as_ref())
|
||||
.with_form_state(form_state);
|
||||
|
||||
if let Some(invite) = invite {
|
||||
ctx = ctx.with_invite(invite);
|
||||
}
|
||||
|
||||
let ctx = ctx
|
||||
.with_captcha(captcha_config)
|
||||
.with_csrf(csrf_token.form_value())
|
||||
.with_language(locale);
|
||||
|
||||
@@ -26,7 +26,7 @@ use mas_data_model::{
|
||||
UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderDiscoveryMode,
|
||||
UpstreamOAuthProviderOnBackchannelLogout, UpstreamOAuthProviderPkceMode,
|
||||
UpstreamOAuthProviderTokenAuthMethod, User, UserEmailAuthentication,
|
||||
UserEmailAuthenticationCode, UserRecoverySession, UserRegistration,
|
||||
UserEmailAuthenticationCode, UserRecoverySession, UserRegistration, UserRegistrationToken,
|
||||
};
|
||||
use mas_i18n::DataLocale;
|
||||
use mas_iana::jose::JsonWebSignatureAlg;
|
||||
@@ -681,6 +681,39 @@ impl RegisterPageProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// The identity an invite code was issued for
|
||||
#[derive(Serialize)]
|
||||
struct InvitePinnedIdentity {
|
||||
username: Option<String>,
|
||||
email: Option<String>,
|
||||
passwordless: bool,
|
||||
}
|
||||
|
||||
/// The invite code the registration page was opened with, as the island sees it
|
||||
#[derive(Serialize)]
|
||||
pub struct InviteContext {
|
||||
valid: bool,
|
||||
|
||||
#[serde(flatten)]
|
||||
pinned: Option<InvitePinnedIdentity>,
|
||||
}
|
||||
|
||||
impl InviteContext {
|
||||
/// Describe the invite code a link carried, `None` standing for a code
|
||||
/// which is unknown, expired, revoked or exhausted
|
||||
#[must_use]
|
||||
pub fn new(token: Option<&UserRegistrationToken>) -> Self {
|
||||
Self {
|
||||
valid: token.is_some(),
|
||||
pinned: token.map(|token| InvitePinnedIdentity {
|
||||
username: token.username.clone(),
|
||||
email: token.email.clone(),
|
||||
passwordless: token.passwordless,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Context used by the `register/index.html` template
|
||||
#[derive(Serialize)]
|
||||
pub struct RegisterContext {
|
||||
@@ -688,6 +721,7 @@ pub struct RegisterContext {
|
||||
login_link: String,
|
||||
form: FormState<RegisterFormField>,
|
||||
graphql_endpoint: String,
|
||||
invite: Option<InviteContext>,
|
||||
}
|
||||
|
||||
impl TemplateContext for RegisterContext {
|
||||
@@ -722,6 +756,7 @@ impl RegisterContext {
|
||||
login_link: url_builder.relative_url_for(&Login::from(post_auth_action.cloned())),
|
||||
form: FormState::default(),
|
||||
graphql_endpoint: url_builder.relative_url_for(&GraphQL),
|
||||
invite: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -730,6 +765,15 @@ impl RegisterContext {
|
||||
pub fn with_form_state(self, form: FormState<RegisterFormField>) -> Self {
|
||||
Self { form, ..self }
|
||||
}
|
||||
|
||||
/// Set the invite code the page was opened with
|
||||
#[must_use]
|
||||
pub fn with_invite(self, invite: InviteContext) -> Self {
|
||||
Self {
|
||||
invite: Some(invite),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Context used by the `consent.html` template
|
||||
|
||||
@@ -41,11 +41,12 @@ pub use self::{
|
||||
AccountInactiveContext, ApiDocContext, AppContext, CompatLoginPolicyViolationContext,
|
||||
CompatSsoContext, ConsentContext, DeviceConsentContext, DeviceLinkContext,
|
||||
DeviceLinkFormField, DeviceNameContext, EmailRecoveryContext, EmailVerificationContext,
|
||||
EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField,
|
||||
NotFoundContext, PolicyViolationContext, PostAuthContext, PostAuthContextInner,
|
||||
RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField,
|
||||
RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext,
|
||||
RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
|
||||
EmptyContext, ErrorContext, FormPostContext, IndexContext, InviteContext, LoginContext,
|
||||
LoginFormField, NotFoundContext, PolicyViolationContext, PostAuthContext,
|
||||
PostAuthContextInner, RecoveryExpiredContext, RecoveryFinishContext,
|
||||
RecoveryFinishFormField, RecoveryProgressContext, RecoveryStartContext,
|
||||
RecoveryStartFormField, RegisterContext, RegisterFormField,
|
||||
RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
|
||||
RegisterStepsEmailInUseContext, RegisterStepsRegistrationTokenContext,
|
||||
RegisterStepsRegistrationTokenFormField, RegisterStepsVerifyEmailContext,
|
||||
RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures,
|
||||
|
||||
@@ -252,9 +252,13 @@
|
||||
"continue_with_email": "Continue with email address",
|
||||
"continue_with_password": "Continue with password",
|
||||
"continue_with_provider": "Continue with {{provider}}",
|
||||
"email_verification_notice": "We will send a verification code to {{email}}",
|
||||
"invite_invalid": "This invite link is invalid or has expired",
|
||||
"or_separator": "Or",
|
||||
"password_confirm_label": "Confirm password",
|
||||
"password_label": "Password",
|
||||
"passwordless_notice": "This account doesn't need a password. We'll send a verification code to your email address instead.",
|
||||
"send_code": "Send code",
|
||||
"terms_of_service": "I agree to the <a>Terms and Conditions</a>",
|
||||
"username_available": "This username is available",
|
||||
"username_check_failed": "We couldn't check if this username is available",
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import { Button, Form, Link } from "@vector-im/compound-web";
|
||||
import { Button, Form, Link, Text } from "@vector-im/compound-web";
|
||||
import { Suspense, useRef, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import {
|
||||
type FieldState,
|
||||
fieldErrorMessage,
|
||||
formErrorMessage,
|
||||
type Invite,
|
||||
type Provider,
|
||||
type ServerError,
|
||||
} from "../../utils/registration";
|
||||
@@ -89,6 +90,7 @@ export const PasswordRegisterForm: React.FC<{
|
||||
fields: Partial<Record<string, FieldState>>;
|
||||
providers: Provider[];
|
||||
loginLink: string;
|
||||
invite?: Invite;
|
||||
}> = ({
|
||||
csrfToken,
|
||||
serverName,
|
||||
@@ -100,8 +102,19 @@ export const PasswordRegisterForm: React.FC<{
|
||||
fields,
|
||||
providers,
|
||||
loginLink,
|
||||
invite,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// A valid invite code may pin the identity the account is registered with,
|
||||
// and waive the password entirely
|
||||
const pinnedUsername = invite?.valid ? invite.username : null;
|
||||
const pinnedEmail = invite?.valid ? invite.email : null;
|
||||
const passwordless = invite?.valid === true && invite.passwordless;
|
||||
// The server reports a code it refused both in the mount data and as an
|
||||
// error on the `token` field, which has no control of its own to carry it
|
||||
const inviteInvalid =
|
||||
invite?.valid === false || (fields.token?.errors.length ?? 0) > 0;
|
||||
const showEmailField = !pinnedEmail && (emailRequired || passwordless);
|
||||
// `null` until the widget has mounted; the handle reports `valid: true`
|
||||
// straight away when there is no captcha to solve.
|
||||
const captchaRef = useRef<CaptchaHandle>(null);
|
||||
@@ -114,9 +127,11 @@ export const PasswordRegisterForm: React.FC<{
|
||||
);
|
||||
const serverErrors = (field: string): ServerError[] =>
|
||||
fieldErrors[field] ?? [];
|
||||
// A submission which came back with errors went through both steps already
|
||||
// A submission which came back with errors went through both steps already,
|
||||
// and an invite link goes straight to the form it may have pinned fields of
|
||||
const [initialStep] = useState<Step>(() =>
|
||||
providers.length > 0 &&
|
||||
!invite &&
|
||||
formErrors.length === 0 &&
|
||||
Object.values(fields).every((field) => !field?.errors.length)
|
||||
? "username"
|
||||
@@ -186,6 +201,12 @@ export const PasswordRegisterForm: React.FC<{
|
||||
<input type="hidden" name="token" value={fields.token.value} />
|
||||
)}
|
||||
|
||||
{inviteInvalid && (
|
||||
<div role="alert" className="text-critical font-medium">
|
||||
{t("frontend.register.invite_invalid")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formErrors.map((error, index) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: the server error list is static
|
||||
@@ -197,15 +218,26 @@ export const PasswordRegisterForm: React.FC<{
|
||||
</div>
|
||||
))}
|
||||
|
||||
<UsernameField
|
||||
serverName={serverName}
|
||||
defaultValue={fields.username?.value ?? ""}
|
||||
serverErrors={serverErrors("username")}
|
||||
/>
|
||||
{/* A pinned username is not the user's to pick, so it isn't shown */}
|
||||
{!pinnedUsername && (
|
||||
<UsernameField
|
||||
serverName={serverName}
|
||||
defaultValue={fields.username?.value ?? ""}
|
||||
serverErrors={serverErrors("username")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "password" && (
|
||||
<>
|
||||
{emailRequired && (
|
||||
{pinnedEmail && (
|
||||
<Text size="sm" className="text-secondary">
|
||||
{t("frontend.register.email_verification_notice", {
|
||||
email: pinnedEmail,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{showEmailField && (
|
||||
<Form.Field
|
||||
name="email"
|
||||
serverInvalid={serverErrors("email").length > 0}
|
||||
@@ -233,12 +265,21 @@ export const PasswordRegisterForm: React.FC<{
|
||||
</Form.Field>
|
||||
)}
|
||||
|
||||
<PasswordFields
|
||||
autoFocus={advanced && !emailRequired}
|
||||
minimumPasswordComplexity={minimumPasswordComplexity}
|
||||
serverErrors={serverErrors("password")}
|
||||
confirmServerErrors={serverErrors("password_confirm")}
|
||||
/>
|
||||
{passwordless ? (
|
||||
// The line about the pinned address already says a code is coming
|
||||
!pinnedEmail && (
|
||||
<Text size="sm" className="text-secondary">
|
||||
{t("frontend.register.passwordless_notice")}
|
||||
</Text>
|
||||
)
|
||||
) : (
|
||||
<PasswordFields
|
||||
autoFocus={advanced && !showEmailField}
|
||||
minimumPasswordComplexity={minimumPasswordComplexity}
|
||||
serverErrors={serverErrors("password")}
|
||||
confirmServerErrors={serverErrors("password_confirm")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tosUri && (
|
||||
<Form.InlineField
|
||||
@@ -284,7 +325,11 @@ export const PasswordRegisterForm: React.FC<{
|
||||
|
||||
{/* The form's first submit button, so that Enter in any of the fields
|
||||
registers rather than picking a provider */}
|
||||
<Form.Submit>{t("action.continue")}</Form.Submit>
|
||||
<Form.Submit>
|
||||
{passwordless
|
||||
? t("frontend.register.send_code")
|
||||
: t("action.continue")}
|
||||
</Form.Submit>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { setGraphqlEndpoint } from "../graphql";
|
||||
import { mountIsland } from "../utils/mountIsland";
|
||||
import {
|
||||
fieldStateSchema,
|
||||
inviteSchema,
|
||||
providerSchema,
|
||||
serverErrorSchema,
|
||||
} from "../utils/registration";
|
||||
@@ -77,12 +78,16 @@ const schema = v.object({
|
||||
providers: v.pipe(v.string(), v.parseJson(), v.array(providerSchema)),
|
||||
/** Href for the "already have an account?" call to action */
|
||||
loginLink: v.string(),
|
||||
invite: v.optional(v.pipe(v.string(), v.parseJson(), inviteSchema)),
|
||||
});
|
||||
|
||||
type Data = v.InferOutput<typeof schema>;
|
||||
|
||||
const RegisterPage: React.FC<{ data: Data }> = ({ data }) =>
|
||||
data.features.password_registration ? (
|
||||
// A passwordless invite is a way to register on its own, whatever the server
|
||||
// allows otherwise
|
||||
data.features.password_registration ||
|
||||
(data.invite?.valid === true && data.invite.passwordless) ? (
|
||||
<PasswordRegisterForm
|
||||
csrfToken={data.csrfToken}
|
||||
serverName={data.branding.server_name}
|
||||
@@ -94,6 +99,7 @@ const RegisterPage: React.FC<{ data: Data }> = ({ data }) =>
|
||||
fields={data.form.fields}
|
||||
providers={data.providers}
|
||||
loginLink={data.loginLink}
|
||||
invite={data.invite}
|
||||
/>
|
||||
) : (
|
||||
<ProvidersOnlyForm
|
||||
|
||||
@@ -29,7 +29,21 @@ export const providerSchema = v.object({
|
||||
id: v.string(),
|
||||
});
|
||||
|
||||
/** The invite code the page was opened with, resolved server-side */
|
||||
export const inviteSchema = v.union([
|
||||
v.object({ valid: v.literal(false) }),
|
||||
v.object({
|
||||
valid: v.literal(true),
|
||||
/** The username the code was issued for, hidden from the user */
|
||||
username: v.nullable(v.string()),
|
||||
/** The email address the code was issued for */
|
||||
email: v.nullable(v.string()),
|
||||
passwordless: v.boolean(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type FieldState = v.InferOutput<typeof fieldStateSchema>;
|
||||
export type Invite = v.InferOutput<typeof inviteSchema>;
|
||||
export type Provider = v.InferOutput<typeof providerSchema>;
|
||||
|
||||
/** Valid Matrix localpart: lowercase ascii, digits, and a few special chars */
|
||||
|
||||
@@ -54,6 +54,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
{% endif %}
|
||||
{% elif error.kind == "password_mismatch" %}
|
||||
{{ _("mas.errors.password_mismatch") }}
|
||||
{% elif error.kind == "invalid" %}
|
||||
{{ _("mas.errors.field_invalid") }}
|
||||
{% else %}
|
||||
{{ error.kind }}
|
||||
{% endif %}
|
||||
|
||||
@@ -29,6 +29,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
<div id="register-form"
|
||||
data-csrf-token="{{ csrf_token }}"
|
||||
{%- if captcha_config %} data-captcha-config='{{ captcha_config | tojson }}'{% endif %}
|
||||
{%- if invite %} data-invite='{{ invite | tojson }}'{% endif %}
|
||||
data-branding='{{ branding | tojson }}'
|
||||
data-features='{{ features | tojson }}'
|
||||
data-form='{{ form | tojson }}'
|
||||
|
||||
@@ -387,6 +387,10 @@
|
||||
"@email_not_allowed": {
|
||||
"context": "components/field.html:49:11-44"
|
||||
},
|
||||
"field_invalid": "This field is invalid",
|
||||
"@field_invalid": {
|
||||
"context": "components/field.html:58:9-38"
|
||||
},
|
||||
"field_required": "This field is required",
|
||||
"@field_required": {
|
||||
"context": "components/field.html:28:9-39"
|
||||
@@ -442,11 +446,11 @@
|
||||
},
|
||||
"loading": "Loading…",
|
||||
"@loading": {
|
||||
"context": "pages/register/index.html:38:26-42"
|
||||
"context": "pages/register/index.html:39:26-42"
|
||||
},
|
||||
"loading_failed": "Something went wrong while loading the page. Try reloading it.",
|
||||
"@loading_failed": {
|
||||
"context": "app.html:28:9-32, pages/register/index.html:50:7-30"
|
||||
"context": "app.html:28:9-32, pages/register/index.html:51:7-30"
|
||||
},
|
||||
"login": {
|
||||
"call_to_register": "Don't have an account yet?",
|
||||
@@ -521,7 +525,7 @@
|
||||
},
|
||||
"or_separator": "Or",
|
||||
"@or_separator": {
|
||||
"context": "components/field.html:114:10-31",
|
||||
"context": "components/field.html:116:10-31",
|
||||
"description": "Separator between the login methods"
|
||||
},
|
||||
"policy_violation": {
|
||||
@@ -688,7 +692,7 @@
|
||||
},
|
||||
"javascript_required": "JavaScript is required to create an account. Please enable JavaScript in your browser and reload this page.",
|
||||
"@javascript_required": {
|
||||
"context": "pages/register/index.html:57:34-71"
|
||||
"context": "pages/register/index.html:58: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": {
|
||||
|
||||
Reference in New Issue
Block a user