1use std::{future::Future, sync::Arc};
5
6use topcoat::{
7 context::Cx,
8 router::{
9 Body, RouteFuture,
10 request::client_ip,
11 response::{IntoResponse, Response},
12 },
13 view::{BoxView, ViewExt},
14};
15
16use super::{
17 PanelUser, infrastructure_failure, input,
18 login::{NEXT_FIELD, login_url, next_from_query, safe_next},
19 panel_root,
20 session::{fresh_session, record},
21};
22use crate::{
23 BoxFuture,
24 auth::realm::{current_realm, hosted, panel_prefix},
25 flash::set_notification,
26 form::{FieldError, FieldErrors, FormInput, FormValues, parse_password, parse_scalar},
27 outcome::Notification,
28 schema::{Field, Schema, Source},
29};
30
31pub trait Registrar: Send + Sync + 'static {
102 type Input: FormInput;
106
107 type User: PanelUser;
110
111 fn validate(
119 &self,
120 _cx: &Cx,
121 _input: &Self::Input,
122 _ex: &mut dyn toasty::Executor,
123 ) -> impl Future<Output = topcoat::Result<FieldErrors>> + Send {
124 async { Ok(FieldErrors::new()) }
125 }
126
127 fn register(
130 &self,
131 cx: &Cx,
132 input: Self::Input,
133 ex: &mut dyn toasty::Executor,
134 ) -> impl Future<Output = topcoat::Result<Self::User>> + Send;
135}
136
137pub(crate) enum Outcome {
139 Registered(Arc<dyn PanelUser>),
141 Refused {
143 values: FormValues,
144 errors: FieldErrors,
145 },
146}
147
148pub(crate) trait DynRegistrar: Send + Sync {
150 fn schema(&self) -> Schema;
152 fn sign_up<'a>(
154 &'a self,
155 cx: &'a Cx,
156 values: FormValues,
157 ) -> BoxFuture<'a, topcoat::Result<Outcome>>;
158}
159
160impl<R: Registrar> DynRegistrar for R {
161 fn schema(&self) -> Schema {
162 R::Input::schema()
163 }
164
165 fn sign_up<'a>(
166 &'a self,
167 cx: &'a Cx,
168 values: FormValues,
169 ) -> BoxFuture<'a, topcoat::Result<Outcome>> {
170 Box::pin(async move {
171 let input =
172 match input::parse::<R::Input>(cx, &values, &FormValues::new(), "sign-up").await? {
173 Ok(input) => input,
174 Err(errors) => return Ok(Outcome::Refused { values, errors }),
175 };
176 let mut db = crate::db::db(cx)?;
177 let mut tx = db.transaction().await.map_err(infrastructure_failure)?;
178 let errors = self.validate(cx, &input, &mut tx).await?;
179 input::placed(&R::Input::schema(), &errors, "the registrar", "sign-up")?;
180 if !errors.is_empty() {
181 return Ok(Outcome::Refused { values, errors });
182 }
183 let user = self.register(cx, input, &mut tx).await?;
184 tx.commit().await.map_err(infrastructure_failure)?;
185 Ok(Outcome::Registered(Arc::new(user)))
186 })
187 }
188}
189
190#[derive(Clone, PartialEq, Eq)]
194pub struct SignUp {
195 pub name: String,
197 pub email: String,
199 pub password: String,
201 pub password_confirmation: String,
203}
204
205impl SignUp {
206 pub fn password_errors(&self) -> FieldErrors {
208 new_password_errors(&self.password, &self.password_confirmation)
209 }
210}
211
212impl std::fmt::Debug for SignUp {
214 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215 f.debug_struct("SignUp")
216 .field("name", &self.name)
217 .field("email", &self.email)
218 .field("password", &"<redacted>")
219 .field("password_confirmation", &"<redacted>")
220 .finish()
221 }
222}
223
224impl FormInput for SignUp {
225 type Field = std::convert::Infallible;
226
227 fn key(field: Self::Field) -> &'static str {
228 match field {}
229 }
230
231 fn schema() -> Schema {
232 let field = crate::form::required_input;
233 Schema::new((
234 field(Field::text_input::<String>("name").label(crate::text!("tablo-auth-name"))),
235 field(
236 Field::text_input::<String>("email")
237 .email()
238 .label(crate::text!("tablo-auth-email")),
239 ),
240 field(
241 Field::text_input::<String>(PASSWORD_FIELD)
242 .password()
243 .label(crate::text!("tablo-auth-password")),
244 ),
245 field(
246 Field::text_input::<String>(CONFIRMATION_FIELD)
247 .password()
248 .label(crate::text!("tablo-auth-confirm-password")),
249 ),
250 ))
251 }
252
253 fn parse(_cx: &Cx, values: &FormValues) -> Result<Self, Vec<FieldError>> {
254 let mut errors = Vec::new();
255 type Parse = fn(&str, &FormValues, Option<String>) -> Result<String, FieldError>;
256 let mut read = |key: &str, parse: Parse| {
257 parse(key, values, None)
258 .map_err(|error| errors.push(error))
259 .ok()
260 };
261 let name = read("name", parse_scalar::<String>);
262 let email = read("email", parse_scalar::<String>);
263 let password = read(PASSWORD_FIELD, parse_password);
264 let confirmation = read(CONFIRMATION_FIELD, parse_password);
265 match (name, email, password, confirmation) {
266 (Some(name), Some(email), Some(password), Some(password_confirmation))
267 if errors.is_empty() =>
268 {
269 Ok(Self {
270 name,
271 email,
272 password,
273 password_confirmation,
274 })
275 }
276 _ => Err(errors),
277 }
278 }
279
280 fn write(&self, out: &mut FormValues) {
281 out.insert("name".to_string(), self.name.clone());
282 out.insert("email".to_string(), self.email.clone());
283 }
284}
285
286const PASSWORD_FIELD: &str = "password";
288const CONFIRMATION_FIELD: &str = "password_confirmation";
290
291pub const MIN_PASSWORD_CHARS: usize = 8;
294
295#[must_use]
299pub fn new_password_errors(password: &str, confirmation: &str) -> FieldErrors {
300 let mut errors = FieldErrors::new();
301 if password.chars().count() < MIN_PASSWORD_CHARS {
302 errors.add(
303 PASSWORD_FIELD,
304 crate::text!("tablo-password-too-short", min = MIN_PASSWORD_CHARS),
305 );
306 }
307 if password != confirmation {
308 errors.add(CONFIRMATION_FIELD, crate::text!("tablo-password-mismatch"));
309 }
310 errors
311}
312
313pub(crate) const RESERVED_KEYS: [&str; 2] = [crate::csrf::FIELD_NAME, NEXT_FIELD];
315
316pub(crate) fn register_url(cx: &Cx) -> String {
318 format!("{}/register", panel_prefix(cx))
319}
320
321pub(crate) fn with_next(url: String, next: &str) -> String {
323 if next.is_empty() {
324 return url;
325 }
326 let mut serializer = form_urlencoded::Serializer::new(String::new());
327 serializer.append_pair(NEXT_FIELD, next);
328 format!("{url}?{}", serializer.finish())
329}
330
331pub(crate) fn offered(cx: &Cx) -> bool {
333 current_realm(cx).is_some_and(|panel| panel.auth.registrar().is_some())
334}
335
336pub(super) fn register_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
338 Box::pin(async move {
339 let next = next_from_query(cx).unwrap_or_default();
340 let values = current_realm(cx)
342 .and_then(|panel| panel.auth.registrar())
343 .map_or_else(FormValues::new, |registrar| registrar.schema().defaults());
344 page_response(cx, &values, &FieldErrors::new(), next, None).await
345 })
346}
347
348pub(super) fn register_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
350 Box::pin(async move {
351 let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
352 let registrar = panel
353 .auth
354 .registrar()
355 .ok_or_else(topcoat::router::error::not_found)?;
356 let form = crate::auth::input::read_form(cx, body).await?;
357 crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
358 let next = form
359 .last(NEXT_FIELD)
360 .and_then(safe_next)
361 .map(str::to_string)
362 .unwrap_or_default();
363 let schema = registrar.schema();
364 let values = schema.read_input(cx, &form.pairs, &RESERVED_KEYS)?;
365 let client = client_ip(cx);
366 if !panel.auth.throttle_sign_ups().attempt("", client) {
367 tracing::warn!(panel = %panel.prefix, client = ?client, "sign-up attempt throttled");
368 return page_response(
369 cx,
370 &values,
371 &FieldErrors::new(),
372 next,
373 Some(Alert::Throttled),
374 )
375 .await;
376 }
377 let user = match registrar.sign_up(cx, values).await {
378 Ok(Outcome::Registered(user)) => user,
379 Ok(Outcome::Refused { values, errors }) => {
380 return page_response(cx, &values, &errors, next, None).await;
381 }
382 Err(error) => {
383 let error = infrastructure_failure(error);
384 if crate::error::TabloError::is_infrastructure(&error) {
385 return unavailable(cx, next).await;
386 }
387 return Err(error);
388 }
389 };
390 if !user.can_access_panel(&panel.prefix) {
391 set_notification(
392 cx,
393 Notification::success(crate::t!(cx, "tablo-sign-up-created"))
394 .description(crate::t!(cx, "tablo-sign-up-pending")),
395 );
396 return topcoat::router::error::see_other(login_url(cx)).into_response(cx);
397 }
398 let recorded = match fresh_session(cx).await {
400 Ok(session) => record(cx, &session, &*user, panel).await,
401 Err(error) => Err(error),
402 };
403 if let Err(error) = recorded {
404 tracing::error!(error = %infrastructure_failure(error), "sign-up session not recorded");
407 set_notification(
408 cx,
409 Notification::success(crate::t!(cx, "tablo-sign-up-created"))
410 .description(crate::t!(cx, "tablo-sign-up-sign-in")),
411 );
412 return topcoat::router::error::see_other(login_url(cx)).into_response(cx);
413 }
414 super::verification::mail_after_sign_up(cx, &*user);
415 let target = if next.is_empty() {
416 panel_root(cx)
417 } else {
418 next
419 };
420 topcoat::router::error::see_other(target).into_response(cx)
421 })
422}
423
424async fn unavailable(cx: &Cx, next: String) -> topcoat::Result<Response> {
426 let values = FormValues::new();
427 page_response(
428 cx,
429 &values,
430 &FieldErrors::new(),
431 next,
432 Some(Alert::Unavailable),
433 )
434 .await
435}
436
437#[derive(Debug, Clone, Copy)]
439enum Alert {
440 Throttled,
442 Unavailable,
444}
445
446impl Alert {
447 fn message(self) -> &'static str {
449 match self {
450 Self::Throttled => "tablo-sign-up-throttled",
451 Self::Unavailable => "tablo-sign-up-unavailable",
452 }
453 }
454
455 fn status(self) -> http::StatusCode {
456 match self {
457 Self::Throttled => http::StatusCode::TOO_MANY_REQUESTS,
458 Self::Unavailable => http::StatusCode::SERVICE_UNAVAILABLE,
459 }
460 }
461}
462
463async fn page_response(
465 cx: &Cx,
466 values: &FormValues,
467 errors: &FieldErrors,
468 next: String,
469 alert: Option<Alert>,
470) -> topcoat::Result<Response> {
471 let status = match alert {
472 Some(alert) => Some(alert.status()),
473 None => (!errors.is_empty()).then_some(http::StatusCode::UNPROCESSABLE_ENTITY),
474 };
475 let page = render_page(cx, values, errors, next, alert, status).await?;
476 page.single().await?.into_response(cx)
477}
478
479async fn render_page<'a>(
482 cx: &'a Cx,
483 values: &FormValues,
484 errors: &FieldErrors,
485 next: String,
486 alert: Option<Alert>,
487 status: Option<http::StatusCode>,
488) -> topcoat::Result<BoxView<'a>> {
489 let schema = current_realm(cx)
490 .and_then(|panel| panel.auth.registrar())
491 .map_or_else(Schema::empty, DynRegistrar::schema);
492 let csrf = crate::csrf::ensure_token(cx);
493 let action = register_url(cx);
494 let login = with_next(login_url(cx), &next);
495 let fields = schema.render(cx, Source::form(values, errors)).await?;
496 let form = topcoat::view::view! {
497 <form method="post" action=(action) class="flex flex-col gap-5">
498 (crate::csrf::field(cx, &csrf))
499 <input type="hidden" name=(NEXT_FIELD) value=(next)>
500 if let Some(alert) = alert {
501 tablo_ui::alert(
502 variant: tablo_ui::AlertVariant::Destructive,
503 attrs: topcoat::view::attributes! { role="alert" },
504 tablo_ui::alert_title((crate::t!(cx, alert.message())))
505 )
506 }
507 (fields)
508 tablo_ui::button(
509 variant: tablo_ui::ButtonVariant::Primary,
510 attrs: topcoat::view::attributes! { type="submit" class="w-full" },
511 (crate::t!(cx, "tablo-sign-up-submit"))
512 )
513 </form>
514 }
515 .boxed();
516 super::page::render(
517 cx,
518 super::page::AuthPage {
519 title: crate::t!(cx, "tablo-sign-up-title"),
520 description: crate::t!(
521 cx,
522 "tablo-sign-up-description",
523 brand = hosted(cx)?.brand_name(cx)
524 ),
525 status,
526 form,
527 switch: Some(super::page::switch_link(
528 cx,
529 "tablo-sign-up-have-account",
530 &login,
531 "tablo-sign-up-sign-in-link",
532 )),
533 note: None,
534 },
535 )
536 .await
537}
538
539pub(super) fn declaration_errors(auth: &super::AuthConfig) -> Vec<crate::DeclarationErrorKind> {
543 use super::input::FieldFault;
544 use crate::{DeclarationErrorKind, declaration::SignUpFault};
545
546 let Some(registrar) = auth.registrar.as_deref() else {
547 return Vec::new();
548 };
549 if auth.authenticator().is_none() {
550 return vec![DeclarationErrorKind::SignUp(SignUpFault::AuthDisabled)];
551 }
552 let mut kinds = Vec::new();
553 let schema = registrar.schema();
554 kinds.extend(schema.declaration_errors());
555 kinds.extend(schema.empty_choices());
556 kinds.extend(
557 input::field_faults(&schema, &RESERVED_KEYS).map(|(fault, name)| {
558 DeclarationErrorKind::SignUp(match fault {
559 FieldFault::Reserved => SignUpFault::ReservedField(name),
560 FieldFault::File => SignUpFault::FileField(name),
561 FieldFault::Relationship => SignUpFault::RelationshipField(name),
562 })
563 }),
564 );
565 kinds
566}
567
568#[cfg(test)]
569mod tests;