1use std::{any::TypeId, 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 session,
14 view::{BoxView, ViewExt},
15};
16
17use super::{
18 PanelUser, infrastructure_failure,
19 login::{NEXT_FIELD, login_url, next_from_query, safe_next},
20 panel_root,
21 session::{delete_session, record},
22};
23use crate::{
24 ActionInput, BoxFuture,
25 form::{FieldError, FieldErrors, FormValues, parse_password, parse_scalar},
26 notification::{Notification, set_notification},
27 panel::{Panel, panel_prefix, state::current},
28 schema::{Field, Schema, Source},
29};
30
31pub trait Registrar: Send + Sync + 'static {
101 type Input: ActionInput;
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 fn user_type(&self) -> (TypeId, &'static str);
160}
161
162impl<R: Registrar> DynRegistrar for R {
163 fn schema(&self) -> Schema {
164 R::Input::schema()
165 }
166
167 fn sign_up<'a>(
168 &'a self,
169 cx: &'a Cx,
170 values: FormValues,
171 ) -> BoxFuture<'a, topcoat::Result<Outcome>> {
172 Box::pin(async move {
173 let schema = R::Input::schema();
174 let mut errors = FieldErrors::new();
175 let parsed = match R::Input::parse(cx, &values) {
176 Ok(parsed) => Some(parsed),
177 Err(refused) => {
178 for error in refused {
179 errors.push(error);
180 }
181 None
182 }
183 };
184 schema
185 .check_controls(cx, &values, &FormValues::new(), &mut errors)
186 .await;
187 let refused = |errors| Outcome::Refused {
188 values: values.clone(),
189 errors,
190 };
191 if let Some(key) = schema.unplaced_error(&errors) {
192 return Err(crate::error::declaration(format!(
193 "the sign-up input refused key `{key}`, which no sign-up control renders"
194 )));
195 }
196 let Some(input) = parsed.filter(|_| errors.is_empty()) else {
197 return Ok(refused(errors));
198 };
199 let mut db = crate::db::db(cx);
200 let mut tx = db.transaction().await.map_err(infrastructure_failure)?;
201 let errors = self.validate(cx, &input, &mut tx).await?;
202 if let Some(key) = schema.unplaced_error(&errors) {
203 return Err(crate::error::declaration(format!(
204 "the registrar refused key `{key}`, which no sign-up control renders"
205 )));
206 }
207 if !errors.is_empty() {
208 return Ok(refused(errors));
209 }
210 let user = self.register(cx, input, &mut tx).await?;
211 tx.commit().await.map_err(infrastructure_failure)?;
212 Ok(Outcome::Registered(Arc::new(user)))
213 })
214 }
215
216 fn user_type(&self) -> (TypeId, &'static str) {
217 (TypeId::of::<R::User>(), std::any::type_name::<R::User>())
218 }
219}
220
221#[derive(Clone, PartialEq, Eq)]
225pub struct SignUp {
226 pub name: String,
228 pub email: String,
230 pub password: String,
232 pub password_confirmation: String,
234}
235
236impl SignUp {
237 pub fn password_errors(&self) -> FieldErrors {
239 new_password_errors(&self.password, &self.password_confirmation)
240 }
241}
242
243impl std::fmt::Debug for SignUp {
245 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 f.debug_struct("SignUp")
247 .field("name", &self.name)
248 .field("email", &self.email)
249 .field("password", &"<redacted>")
250 .field("password_confirmation", &"<redacted>")
251 .finish()
252 }
253}
254
255impl ActionInput for SignUp {
256 fn schema() -> Schema {
257 let field = crate::resource::required_input;
258 Schema::new((
259 field(Field::text_input::<String>("name").label("Name")),
260 field(Field::text_input::<String>("email").email().label("Email")),
261 field(
262 Field::text_input::<String>(PASSWORD_FIELD)
263 .password()
264 .label("Password"),
265 ),
266 field(
267 Field::text_input::<String>(CONFIRMATION_FIELD)
268 .password()
269 .label("Confirm password"),
270 ),
271 ))
272 }
273
274 fn parse(_cx: &Cx, values: &FormValues) -> Result<Self, Vec<FieldError>> {
275 let mut errors = Vec::new();
276 type Parse = fn(&str, &FormValues, Option<String>) -> Result<String, FieldError>;
277 let mut read = |key: &str, parse: Parse| {
278 parse(key, values, None)
279 .map_err(|error| errors.push(error))
280 .ok()
281 };
282 let name = read("name", parse_scalar::<String>);
283 let email = read("email", parse_scalar::<String>);
284 let password = read(PASSWORD_FIELD, parse_password);
285 let confirmation = read(CONFIRMATION_FIELD, parse_password);
286 match (name, email, password, confirmation) {
287 (Some(name), Some(email), Some(password), Some(password_confirmation))
288 if errors.is_empty() =>
289 {
290 Ok(Self {
291 name,
292 email,
293 password,
294 password_confirmation,
295 })
296 }
297 _ => Err(errors),
298 }
299 }
300}
301
302const PASSWORD_FIELD: &str = "password";
304const CONFIRMATION_FIELD: &str = "password_confirmation";
306
307pub const MIN_PASSWORD_CHARS: usize = 8;
310
311#[must_use]
315pub fn new_password_errors(password: &str, confirmation: &str) -> FieldErrors {
316 let mut errors = FieldErrors::new();
317 if password.chars().count() < MIN_PASSWORD_CHARS {
318 errors.add(
319 PASSWORD_FIELD,
320 format!("Password must be at least {MIN_PASSWORD_CHARS} characters"),
321 );
322 }
323 if password != confirmation {
324 errors.add(CONFIRMATION_FIELD, "Passwords do not match");
325 }
326 errors
327}
328
329pub(crate) const RESERVED_KEYS: [&str; 2] = [crate::csrf::FIELD_NAME, NEXT_FIELD];
331
332pub(crate) fn register_url(cx: &Cx) -> String {
334 format!("{}/register", panel_prefix(cx))
335}
336
337pub(crate) fn with_next(url: String, next: &str) -> String {
339 if next.is_empty() {
340 return url;
341 }
342 let mut serializer = form_urlencoded::Serializer::new(String::new());
343 serializer.append_pair(NEXT_FIELD, next);
344 format!("{url}?{}", serializer.finish())
345}
346
347pub(crate) fn offered(cx: &Cx) -> bool {
349 current(cx).is_some_and(|panel| panel.auth.registrar().is_some())
350}
351
352pub(crate) fn register_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
354 Box::pin(async move {
355 let next = next_from_query(cx).unwrap_or_default();
356 page_response(cx, &FormValues::new(), &FieldErrors::new(), next, None).await
357 })
358}
359
360pub(crate) fn register_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
362 Box::pin(async move {
363 let panel = current(cx).ok_or_else(topcoat::router::error::not_found)?;
364 let registrar = panel
365 .auth
366 .registrar()
367 .ok_or_else(topcoat::router::error::not_found)?;
368 let form = crate::panel::parse_form_body(cx, body).await?;
369 crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
370 let next = form
371 .last(NEXT_FIELD)
372 .and_then(safe_next)
373 .map(str::to_string)
374 .unwrap_or_default();
375 let schema = registrar.schema();
376 let values = schema.read_input(&form.pairs, &RESERVED_KEYS)?;
377 let client = client_ip(cx);
378 if !panel.auth.throttle_sign_ups().attempt("", client) {
379 tracing::warn!(panel = %panel.prefix, client = ?client, "sign-up attempt throttled");
380 return page_response(
381 cx,
382 &values,
383 &FieldErrors::new(),
384 next,
385 Some(Alert::Throttled),
386 )
387 .await;
388 }
389 let user = match registrar.sign_up(cx, values).await {
390 Ok(Outcome::Registered(user)) => user,
391 Ok(Outcome::Refused { values, errors }) => {
392 return page_response(cx, &values, &errors, next, None).await;
393 }
394 Err(error) => {
395 let error = infrastructure_failure(error);
396 if crate::error::TabloError::is_infrastructure(&error) {
397 return unavailable(cx, next).await;
398 }
399 return Err(error);
400 }
401 };
402 if !user.can_access_panel() {
403 set_notification(
404 cx,
405 Notification::success("Account created")
406 .description("You can sign in once it is approved."),
407 );
408 return topcoat::router::error::see_other(login_url(cx)).into_response(cx);
409 }
410 if let Some(hash) = session::token_hash(cx).await? {
412 delete_session(cx, &hash).await?;
413 }
414 let session = session::start(cx).await?;
415 if let Err(error) = record(cx, &session, &*user, panel).await {
416 tracing::error!(error = %infrastructure_failure(error), "sign-up session not recorded");
419 set_notification(
420 cx,
421 Notification::success("Account created").description("Sign in to continue."),
422 );
423 return topcoat::router::error::see_other(login_url(cx)).into_response(cx);
424 }
425 let target = if next.is_empty() {
426 panel_root(cx)
427 } else {
428 next
429 };
430 topcoat::router::error::see_other(target).into_response(cx)
431 })
432}
433
434async fn unavailable(cx: &Cx, next: String) -> topcoat::Result<Response> {
436 let values = FormValues::new();
437 page_response(
438 cx,
439 &values,
440 &FieldErrors::new(),
441 next,
442 Some(Alert::Unavailable),
443 )
444 .await
445}
446
447#[derive(Debug, Clone, Copy)]
449enum Alert {
450 Throttled,
452 Unavailable,
454}
455
456impl Alert {
457 fn message(self) -> &'static str {
458 match self {
459 Self::Throttled => "Too many sign-up attempts. Try again shortly.",
460 Self::Unavailable => "Sign-up is unavailable right now. Try again shortly.",
461 }
462 }
463
464 fn status(self) -> http::StatusCode {
465 match self {
466 Self::Throttled => http::StatusCode::TOO_MANY_REQUESTS,
467 Self::Unavailable => http::StatusCode::SERVICE_UNAVAILABLE,
468 }
469 }
470}
471
472async fn page_response(
474 cx: &Cx,
475 values: &FormValues,
476 errors: &FieldErrors,
477 next: String,
478 alert: Option<Alert>,
479) -> topcoat::Result<Response> {
480 let status = match alert {
481 Some(alert) => Some(alert.status()),
482 None => (!errors.is_empty()).then_some(http::StatusCode::UNPROCESSABLE_ENTITY),
483 };
484 let page = render_page(cx, values, errors, next, alert, status).await?;
485 page.single().await?.into_response(cx)
486}
487
488async fn render_page<'a>(
491 cx: &'a Cx,
492 values: &FormValues,
493 errors: &FieldErrors,
494 next: String,
495 alert: Option<Alert>,
496 status: Option<http::StatusCode>,
497) -> topcoat::Result<BoxView<'a>> {
498 let schema = current(cx)
499 .and_then(|panel| panel.auth.registrar())
500 .map_or_else(Schema::empty, DynRegistrar::schema);
501 let csrf = crate::csrf::ensure_token(cx);
502 let action = register_url(cx);
503 let login = with_next(login_url(cx), &next);
504 let brand = Panel::render_brand(cx).await?;
505 let fields = schema.render(cx, Source::form(values, errors)).await?;
506 let body = topcoat::view::view! {
507 cx =>
508 <div class="flex min-h-svh items-center justify-center bg-muted p-6">
509 <div
510 class="flex w-full max-w-sm flex-col gap-6 rounded-xl border border-border bg-card p-6 text-card-foreground shadow-sm"
511 >
512 if let Some(status) = status {
513 (status)
514 }
515 <div class="flex flex-col items-center gap-2">
516 (brand)
517 <h1 class="text-lg font-semibold text-foreground">
518 "Create an account"
519 </h1>
520 </div>
521 <form method="post" action=(action) class="flex flex-col gap-4">
522 (crate::csrf::field(cx, &csrf))
523 <input type="hidden" name=(NEXT_FIELD) value=(next)>
524 if let Some(alert) = alert {
525 tablo_ui::alert(
526 variant: tablo_ui::AlertVariant::Destructive,
527 attrs: topcoat::view::attributes! { role="alert" },
528 tablo_ui::alert_title((alert.message()))
529 )
530 }
531 (fields)
532 tablo_ui::button(
533 variant: tablo_ui::ButtonVariant::Primary,
534 attrs: topcoat::view::attributes! { type="submit" class="w-full" },
535 "Sign up"
536 )
537 </form>
538 <p class="text-center text-sm text-muted-foreground">
539 "Already have an account? "
540 <a
541 (crate::navigation::runtime_link(cx, &login))
542 class="font-medium text-foreground underline underline-offset-4"
543 >
544 "Sign in"
545 </a>
546 </p>
547 </div>
548 </div>
549 }
550 .boxed();
551 Panel::render_document(cx, "Sign up".to_string(), body).await
552}
553
554#[cfg(test)]
555mod tests;