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
367 .auth
368 .throttle_sign_ups()
369 .attempt(cx, "sign-up", "", client)
370 .await?
371 {
372 tracing::warn!(panel = %panel.prefix, client = ?client, "sign-up attempt throttled");
373 return page_response(
374 cx,
375 &values,
376 &FieldErrors::new(),
377 next,
378 Some(Alert::Throttled),
379 )
380 .await;
381 }
382 let user = match registrar.sign_up(cx, values).await {
383 Ok(Outcome::Registered(user)) => user,
384 Ok(Outcome::Refused { values, errors }) => {
385 return page_response(cx, &values, &errors, next, None).await;
386 }
387 Err(error) => {
388 let error = infrastructure_failure(error);
389 if crate::error::TabloError::is_infrastructure(&error) {
390 return unavailable(cx, next).await;
391 }
392 return Err(error);
393 }
394 };
395 if !user.can_access_panel(&panel.prefix) {
396 set_notification(
397 cx,
398 Notification::success(crate::t!(cx, "tablo-sign-up-created"))
399 .description(crate::t!(cx, "tablo-sign-up-pending")),
400 );
401 return topcoat::router::error::see_other(login_url(cx)).into_response(cx);
402 }
403 let recorded = match fresh_session(cx).await {
405 Ok(session) => record(cx, &session, &*user, panel).await,
406 Err(error) => Err(error),
407 };
408 if let Err(error) = recorded {
409 tracing::error!(error = %infrastructure_failure(error), "sign-up session not recorded");
412 set_notification(
413 cx,
414 Notification::success(crate::t!(cx, "tablo-sign-up-created"))
415 .description(crate::t!(cx, "tablo-sign-up-sign-in")),
416 );
417 return topcoat::router::error::see_other(login_url(cx)).into_response(cx);
418 }
419 super::verification::mail_after_sign_up(cx, &*user);
420 let target = if next.is_empty() {
421 panel_root(cx)
422 } else {
423 next
424 };
425 topcoat::router::error::see_other(target).into_response(cx)
426 })
427}
428
429async fn unavailable(cx: &Cx, next: String) -> topcoat::Result<Response> {
431 let values = FormValues::new();
432 page_response(
433 cx,
434 &values,
435 &FieldErrors::new(),
436 next,
437 Some(Alert::Unavailable),
438 )
439 .await
440}
441
442#[derive(Debug, Clone, Copy)]
444enum Alert {
445 Throttled,
447 Unavailable,
449}
450
451impl Alert {
452 fn message(self) -> &'static str {
454 match self {
455 Self::Throttled => "tablo-sign-up-throttled",
456 Self::Unavailable => "tablo-sign-up-unavailable",
457 }
458 }
459
460 fn status(self) -> http::StatusCode {
461 match self {
462 Self::Throttled => http::StatusCode::TOO_MANY_REQUESTS,
463 Self::Unavailable => http::StatusCode::SERVICE_UNAVAILABLE,
464 }
465 }
466}
467
468async fn page_response(
470 cx: &Cx,
471 values: &FormValues,
472 errors: &FieldErrors,
473 next: String,
474 alert: Option<Alert>,
475) -> topcoat::Result<Response> {
476 let status = match alert {
477 Some(alert) => Some(alert.status()),
478 None => (!errors.is_empty()).then_some(http::StatusCode::UNPROCESSABLE_ENTITY),
479 };
480 let page = render_page(cx, values, errors, next, alert, status).await?;
481 page.single().await?.into_response(cx)
482}
483
484async fn render_page<'a>(
487 cx: &'a Cx,
488 values: &FormValues,
489 errors: &FieldErrors,
490 next: String,
491 alert: Option<Alert>,
492 status: Option<http::StatusCode>,
493) -> topcoat::Result<BoxView<'a>> {
494 let schema = current_realm(cx)
495 .and_then(|panel| panel.auth.registrar())
496 .map_or_else(Schema::empty, DynRegistrar::schema);
497 let csrf = crate::csrf::ensure_token(cx);
498 let action = register_url(cx);
499 let login = with_next(login_url(cx), &next);
500 let providers = super::oidc::buttons(cx, &next);
501 let fields = schema.render(cx, Source::form(values, errors)).await?;
502 let form = topcoat::view::view! {
503 <form method="post" action=(action) class="flex flex-col gap-5">
504 (crate::csrf::field(cx, &csrf))
505 <input type="hidden" name=(NEXT_FIELD) value=(next)>
506 if let Some(alert) = alert {
507 tablo_ui::alert(
508 variant: tablo_ui::AlertVariant::Destructive,
509 attrs: topcoat::view::attributes! { role="alert" },
510 tablo_ui::alert_title((crate::t!(cx, alert.message())))
511 )
512 }
513 (fields)
514 tablo_ui::button(
515 variant: tablo_ui::ButtonVariant::Primary,
516 attrs: topcoat::view::attributes! { type="submit" class="w-full" },
517 (crate::t!(cx, "tablo-sign-up-submit"))
518 )
519 </form>
520 if let Some(providers) = providers {
521 <div class="mt-5">(providers)</div>
522 }
523 }
524 .boxed();
525 super::page::render(
526 cx,
527 super::page::AuthPage {
528 title: crate::t!(cx, "tablo-sign-up-title"),
529 description: crate::t!(
530 cx,
531 "tablo-sign-up-description",
532 brand = hosted(cx)?.brand_name(cx)
533 ),
534 status,
535 form,
536 switch: Some(super::page::switch_link(
537 cx,
538 "tablo-sign-up-have-account",
539 &login,
540 "tablo-sign-up-sign-in-link",
541 )),
542 note: None,
543 },
544 )
545 .await
546}
547
548pub(super) fn declaration_errors(auth: &super::AuthConfig) -> Vec<crate::DeclarationErrorKind> {
552 use super::input::FieldFault;
553 use crate::{DeclarationErrorKind, declaration::SignUpFault};
554
555 let Some(registrar) = auth.registrar.as_deref() else {
556 return Vec::new();
557 };
558 if auth.authenticator().is_none() {
559 return vec![DeclarationErrorKind::SignUp(SignUpFault::AuthDisabled)];
560 }
561 let mut kinds = Vec::new();
562 let schema = registrar.schema();
563 kinds.extend(schema.declaration_errors());
564 kinds.extend(schema.empty_choices());
565 kinds.extend(
566 input::field_faults(&schema, &RESERVED_KEYS).map(|(fault, name)| {
567 DeclarationErrorKind::SignUp(match fault {
568 FieldFault::Reserved => SignUpFault::ReservedField(name),
569 FieldFault::File => SignUpFault::FileField(name),
570 FieldFault::Relationship => SignUpFault::RelationshipField(name),
571 })
572 }),
573 );
574 kinds
575}
576
577#[cfg(test)]
578mod tests;