use std::{future::Future, sync::Arc};
use topcoat::{
context::Cx,
router::{
Body, RouteFuture,
request::client_ip,
response::{IntoResponse, Response},
},
view::{BoxView, ViewExt},
};
use super::{
PanelUser, infrastructure_failure, input,
login::{NEXT_FIELD, login_url, next_from_query, safe_next},
panel_root,
session::{fresh_session, record},
};
use crate::{
BoxFuture,
auth::realm::{current_realm, hosted, panel_prefix},
flash::set_notification,
form::{FieldError, FieldErrors, FormInput, FormValues, parse_password, parse_scalar},
outcome::Notification,
schema::{Field, Schema, Source},
};
pub trait Registrar: Send + Sync + 'static {
type Input: FormInput;
type User: PanelUser;
fn validate(
&self,
_cx: &Cx,
_input: &Self::Input,
_ex: &mut dyn toasty::Executor,
) -> impl Future<Output = topcoat::Result<FieldErrors>> + Send {
async { Ok(FieldErrors::new()) }
}
fn register(
&self,
cx: &Cx,
input: Self::Input,
ex: &mut dyn toasty::Executor,
) -> impl Future<Output = topcoat::Result<Self::User>> + Send;
}
pub(crate) enum Outcome {
Registered(Arc<dyn PanelUser>),
Refused {
values: FormValues,
errors: FieldErrors,
},
}
pub(crate) trait DynRegistrar: Send + Sync {
fn schema(&self) -> Schema;
fn sign_up<'a>(
&'a self,
cx: &'a Cx,
values: FormValues,
) -> BoxFuture<'a, topcoat::Result<Outcome>>;
}
impl<R: Registrar> DynRegistrar for R {
fn schema(&self) -> Schema {
R::Input::schema()
}
fn sign_up<'a>(
&'a self,
cx: &'a Cx,
values: FormValues,
) -> BoxFuture<'a, topcoat::Result<Outcome>> {
Box::pin(async move {
let input =
match input::parse::<R::Input>(cx, &values, &FormValues::new(), "sign-up").await? {
Ok(input) => input,
Err(errors) => return Ok(Outcome::Refused { values, errors }),
};
let mut db = crate::db::db(cx)?;
let mut tx = db.transaction().await.map_err(infrastructure_failure)?;
let errors = self.validate(cx, &input, &mut tx).await?;
input::placed(&R::Input::schema(), &errors, "the registrar", "sign-up")?;
if !errors.is_empty() {
return Ok(Outcome::Refused { values, errors });
}
let user = self.register(cx, input, &mut tx).await?;
tx.commit().await.map_err(infrastructure_failure)?;
Ok(Outcome::Registered(Arc::new(user)))
})
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct SignUp {
pub name: String,
pub email: String,
pub password: String,
pub password_confirmation: String,
}
impl SignUp {
pub fn password_errors(&self) -> FieldErrors {
new_password_errors(&self.password, &self.password_confirmation)
}
}
impl std::fmt::Debug for SignUp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SignUp")
.field("name", &self.name)
.field("email", &self.email)
.field("password", &"<redacted>")
.field("password_confirmation", &"<redacted>")
.finish()
}
}
impl FormInput for SignUp {
fn schema() -> Schema {
let field = crate::form::required_input;
Schema::new((
field(Field::text_input::<String>("name").label(crate::text!("tablo-auth-name"))),
field(
Field::text_input::<String>("email")
.email()
.label(crate::text!("tablo-auth-email")),
),
field(
Field::text_input::<String>(PASSWORD_FIELD)
.password()
.label(crate::text!("tablo-auth-password")),
),
field(
Field::text_input::<String>(CONFIRMATION_FIELD)
.password()
.label(crate::text!("tablo-auth-confirm-password")),
),
))
}
fn parse(_cx: &Cx, values: &FormValues) -> Result<Self, Vec<FieldError>> {
let mut errors = Vec::new();
type Parse = fn(&str, &FormValues, Option<String>) -> Result<String, FieldError>;
let mut read = |key: &str, parse: Parse| {
parse(key, values, None)
.map_err(|error| errors.push(error))
.ok()
};
let name = read("name", parse_scalar::<String>);
let email = read("email", parse_scalar::<String>);
let password = read(PASSWORD_FIELD, parse_password);
let confirmation = read(CONFIRMATION_FIELD, parse_password);
match (name, email, password, confirmation) {
(Some(name), Some(email), Some(password), Some(password_confirmation))
if errors.is_empty() =>
{
Ok(Self {
name,
email,
password,
password_confirmation,
})
}
_ => Err(errors),
}
}
fn write(&self, out: &mut FormValues) {
out.insert("name".to_string(), self.name.clone());
out.insert("email".to_string(), self.email.clone());
}
}
const PASSWORD_FIELD: &str = "password";
const CONFIRMATION_FIELD: &str = "password_confirmation";
pub const MIN_PASSWORD_CHARS: usize = 8;
#[must_use]
pub fn new_password_errors(password: &str, confirmation: &str) -> FieldErrors {
let mut errors = FieldErrors::new();
if password.chars().count() < MIN_PASSWORD_CHARS {
errors.add(
PASSWORD_FIELD,
crate::text!("tablo-password-too-short", min = MIN_PASSWORD_CHARS),
);
}
if password != confirmation {
errors.add(CONFIRMATION_FIELD, crate::text!("tablo-password-mismatch"));
}
errors
}
pub(crate) const RESERVED_KEYS: [&str; 2] = [crate::csrf::FIELD_NAME, NEXT_FIELD];
pub(crate) fn register_url(cx: &Cx) -> String {
format!("{}/register", panel_prefix(cx))
}
pub(crate) fn with_next(url: String, next: &str) -> String {
if next.is_empty() {
return url;
}
let mut serializer = form_urlencoded::Serializer::new(String::new());
serializer.append_pair(NEXT_FIELD, next);
format!("{url}?{}", serializer.finish())
}
pub(crate) fn offered(cx: &Cx) -> bool {
current_realm(cx).is_some_and(|panel| panel.auth.registrar().is_some())
}
pub(super) fn register_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
Box::pin(async move {
let next = next_from_query(cx).unwrap_or_default();
page_response(cx, &FormValues::new(), &FieldErrors::new(), next, None).await
})
}
pub(super) fn register_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
Box::pin(async move {
let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
let registrar = panel
.auth
.registrar()
.ok_or_else(topcoat::router::error::not_found)?;
let form = crate::auth::input::read_form(cx, body).await?;
crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
let next = form
.last(NEXT_FIELD)
.and_then(safe_next)
.map(str::to_string)
.unwrap_or_default();
let schema = registrar.schema();
let values = schema.read_input(cx, &form.pairs, &RESERVED_KEYS)?;
let client = client_ip(cx);
if !panel.auth.throttle_sign_ups().attempt("", client) {
tracing::warn!(panel = %panel.prefix, client = ?client, "sign-up attempt throttled");
return page_response(
cx,
&values,
&FieldErrors::new(),
next,
Some(Alert::Throttled),
)
.await;
}
let user = match registrar.sign_up(cx, values).await {
Ok(Outcome::Registered(user)) => user,
Ok(Outcome::Refused { values, errors }) => {
return page_response(cx, &values, &errors, next, None).await;
}
Err(error) => {
let error = infrastructure_failure(error);
if crate::error::TabloError::is_infrastructure(&error) {
return unavailable(cx, next).await;
}
return Err(error);
}
};
if !user.can_access_panel() {
set_notification(
cx,
Notification::success(crate::t!(cx, "tablo-sign-up-created"))
.description(crate::t!(cx, "tablo-sign-up-pending")),
);
return topcoat::router::error::see_other(login_url(cx)).into_response(cx);
}
let recorded = match fresh_session(cx).await {
Ok(session) => record(cx, &session, &*user, panel).await,
Err(error) => Err(error),
};
if let Err(error) = recorded {
tracing::error!(error = %infrastructure_failure(error), "sign-up session not recorded");
set_notification(
cx,
Notification::success(crate::t!(cx, "tablo-sign-up-created"))
.description(crate::t!(cx, "tablo-sign-up-sign-in")),
);
return topcoat::router::error::see_other(login_url(cx)).into_response(cx);
}
super::verification::mail_after_sign_up(cx, &*user);
let target = if next.is_empty() {
panel_root(cx)
} else {
next
};
topcoat::router::error::see_other(target).into_response(cx)
})
}
async fn unavailable(cx: &Cx, next: String) -> topcoat::Result<Response> {
let values = FormValues::new();
page_response(
cx,
&values,
&FieldErrors::new(),
next,
Some(Alert::Unavailable),
)
.await
}
#[derive(Debug, Clone, Copy)]
enum Alert {
Throttled,
Unavailable,
}
impl Alert {
fn message(self) -> &'static str {
match self {
Self::Throttled => "tablo-sign-up-throttled",
Self::Unavailable => "tablo-sign-up-unavailable",
}
}
fn status(self) -> http::StatusCode {
match self {
Self::Throttled => http::StatusCode::TOO_MANY_REQUESTS,
Self::Unavailable => http::StatusCode::SERVICE_UNAVAILABLE,
}
}
}
async fn page_response(
cx: &Cx,
values: &FormValues,
errors: &FieldErrors,
next: String,
alert: Option<Alert>,
) -> topcoat::Result<Response> {
let status = match alert {
Some(alert) => Some(alert.status()),
None => (!errors.is_empty()).then_some(http::StatusCode::UNPROCESSABLE_ENTITY),
};
let page = render_page(cx, values, errors, next, alert, status).await?;
page.single().await?.into_response(cx)
}
async fn render_page<'a>(
cx: &'a Cx,
values: &FormValues,
errors: &FieldErrors,
next: String,
alert: Option<Alert>,
status: Option<http::StatusCode>,
) -> topcoat::Result<BoxView<'a>> {
let schema = current_realm(cx)
.and_then(|panel| panel.auth.registrar())
.map_or_else(Schema::empty, DynRegistrar::schema);
let csrf = crate::csrf::ensure_token(cx);
let action = register_url(cx);
let login = with_next(login_url(cx), &next);
let fields = schema.render(cx, Source::form(values, errors)).await?;
let form = topcoat::view::view! {
cx =>
<form method="post" action=(action) class="flex flex-col gap-5">
(crate::csrf::field(cx, &csrf))
<input type="hidden" name=(NEXT_FIELD) value=(next)>
if let Some(alert) = alert {
tablo_ui::alert(
variant: tablo_ui::AlertVariant::Destructive,
attrs: topcoat::view::attributes! { role="alert" },
tablo_ui::alert_title((crate::t!(cx, alert.message())))
)
}
(fields)
tablo_ui::button(
variant: tablo_ui::ButtonVariant::Primary,
attrs: topcoat::view::attributes! { type="submit" class="w-full" },
(crate::t!(cx, "tablo-sign-up-submit"))
)
</form>
}
.boxed();
super::page::render(
cx,
super::page::AuthPage {
title: crate::t!(cx, "tablo-sign-up-title"),
description: crate::t!(
cx,
"tablo-sign-up-description",
brand = hosted(cx)?.brand_name(cx)
),
status,
form,
switch: Some(super::page::switch_link(
cx,
"tablo-sign-up-have-account",
&login,
"tablo-sign-up-sign-in-link",
)),
note: None,
},
)
.await
}
/// The mistakes in a panel's [`Auth::registration`](super::Auth::registration): a registration
/// without auth, a registrar whose user is not the authenticator's, and a sign-up form field the
/// page cannot serve.
pub(super) fn declaration_errors(auth: &super::AuthConfig) -> Vec<crate::DeclarationErrorKind> {
use super::input::FieldFault;
use crate::{DeclarationErrorKind, declaration::SignUpFault};
let Some(registrar) = auth.registrar.as_deref() else {
return Vec::new();
};
if auth.authenticator().is_none() {
return vec![DeclarationErrorKind::SignUp(SignUpFault::AuthDisabled)];
}
let mut kinds = Vec::new();
let schema = registrar.schema();
kinds.extend(schema.declaration_errors());
kinds.extend(schema.empty_choices());
kinds.extend(
input::field_faults(&schema, &RESERVED_KEYS).map(|(fault, name)| {
DeclarationErrorKind::SignUp(match fault {
FieldFault::Reserved => SignUpFault::ReservedField(name),
FieldFault::File => SignUpFault::FileField(name),
FieldFault::Relationship => SignUpFault::RelationshipField(name),
})
}),
);
kinds
}
#[cfg(test)]
mod tests;