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