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