//! HTMX multi-step signup wizard. //! //! Step 1 creates the account (public, rate-limited). Steps 2-5 are optional //! and update the newly authenticated user. Layout reuses the Phase 25 wizard //! infrastructure (sidebar step indicator, HTMX partial swaps). use crate::extractors::ValidatedQuery; use axum::{ Form, extract::{Path, State}, http::header::HeaderMap, response::{IntoResponse, Redirect, Response}, }; use serde::Deserialize; use sqlx::PgPool; use tower_sessions::Session; use crate::{ auth::{ AuthUser, MaybeUserVerified, SessionUser, hash_password_async, login_user, track_session, }, background::BackgroundTx, config::Config, db::{self}, email::{self, EmailClient}, error::{AppError, Result}, helpers::{get_csrf_token, is_htmx_request}, routes::pages::dashboard::wizards::build_step_nav, templates::{ WizardJoinAccountTemplate, WizardJoinCompleteTemplate, WizardJoinProfileTemplate, WizardJoinTemplate, }, }; const JOIN_STEPS: &[&str] = &["account", "profile", "complete"]; const JOIN_LABELS: &[&str] = &["Account", "Profile", "Welcome"]; /// Query params for the join page. #[derive(Debug, Deserialize)] pub(crate) struct JoinQuery { pub invite: Option, } /// Render the full wizard page with step 1 inline. /// Redirects logged-in users to `/dashboard`. #[tracing::instrument(skip_all, name = "join_wizard::page")] pub(crate) async fn wizard_page( session: Session, MaybeUserVerified(maybe_user): MaybeUserVerified, ValidatedQuery(query): ValidatedQuery, ) -> Response { if maybe_user.is_some() { return Redirect::to("/dashboard").into_response(); } WizardJoinTemplate { csrf_token: get_csrf_token(&session).await, nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "account"), invite_code: query.invite, username: String::new(), email: String::new(), error: None, error_field: None, } .into_response() } /// Form input for account creation (step 1). #[derive(Debug, Deserialize)] pub(crate) struct AccountForm { pub username: String, pub email: String, pub password: String, pub invite_code: Option, } /// POST `/join/step/account`: create account and log in, then return step 2. #[tracing::instrument(skip_all, name = "join_wizard::account_create")] pub(crate) async fn step_account_create( State(db): State, State(config): State, State(mailer): State, State(bg): State, headers: HeaderMap, session: Session, Form(form): Form, ) -> Result { let is_htmx = is_htmx_request(&headers); let csrf_token = get_csrf_token(&session).await; let return_error = |field: Option<&str>, summary: &str| -> Result { if is_htmx { // HTMX swaps the response into #wizard-step. Re-render the account // STEP PARTIAL with the typed username/email preserved and the bad // field flagged, not a bare LoginErrorTemplate, which would replace // the whole form with a single error line and drop all input (UX-S1). Ok(WizardJoinAccountTemplate { nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "account"), csrf_token: csrf_token.clone(), invite_code: form.invite_code.clone(), username: form.username.clone(), email: form.email.clone(), error: Some(summary.to_string()), error_field: field.map(std::string::ToString::to_string), } .into_response()) } else { // Non-HTMX (JS disabled): re-render the full account step with the // typed username/email preserved and the offending field marked // invalid, instead of a generic 422 that drops everything entered. Ok(WizardJoinTemplate { csrf_token: csrf_token.clone(), nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "account"), invite_code: form.invite_code.clone(), username: form.username.clone(), email: form.email.clone(), error: Some(summary.to_string()), error_field: field.map(std::string::ToString::to_string), } .into_response()) } }; let username = match db::Username::new(&form.username) { Ok(u) => u, Err(e) => return return_error(Some("username"), &e.to_string()), }; let Ok(email) = db::Email::new(&form.email) else { return return_error(Some("email"), "Please enter a valid email address"); }; // Check uniqueness let username_taken = db::users::get_user_by_username(&db, &username) .await? .is_some(); let email_taken = db::users::get_user_by_email(&db, &email).await?.is_some(); // Username collisions are safe to reveal, usernames are public handles // (profile URLs expose them) and the user must pick a free one. An EMAIL // collision must NOT be revealed: "this email is already registered" is an // account-existence oracle for a private identifier (ultra-fuzz Run 4 m1). // So when only the email is taken, do not error, return the same step-2 // response a fresh signup returns and send an "account exists" email out of // band. Only the real owner receives that email, so the recovery path // reaches them without the page confirming the address. (Residual: a fresh // signup also sets a session cookie, which this path cannot; the explicit // textual reveal, the actual finding, is gone.) if username_taken { return return_error(Some("username"), "This username is already taken"); } if email_taken { let login_url = format!("{}/login", config.host_url); let reset_url = format!("{}/forgot-password", config.host_url); let email_client = mailer.clone(); let to_email = email.to_string(); bg.spawn("account-exists notice", async move { if let Err(e) = email_client .send_account_exists(&to_email, &login_url, &reset_url) .await { tracing::error!(error = ?e, "failed to send account-exists email"); } }); return Ok(render_step_profile().into_response()); } let password_len = form.password.chars().count(); if password_len < crate::validation::limits::PASSWORD_MIN { return return_error(Some("password"), "Password must be at least 8 characters"); } if crate::validation::password_too_long(&form.password) { return return_error(Some("password"), "Password must be 128 characters or fewer"); } // Check for breached password (advisory only) if let Some(count) = crate::auth::check_password_breach(&form.password).await { tracing::warn!( event = "breached_password_signup", breach_count = count, "New user signed up with breached password" ); session .insert( "password_warning", format!( "This password has appeared in {count} known data breach(es). Consider changing it." ), ) .await .ok(); } // Hash password and create user. The uniqueness checks above are // best-effort, a concurrent signup with the same username or email can // slip between the SELECT and the INSERT and raise a 23505. Catch it and // surface as a validation error so the user sees a friendly message // (with their typed values preserved) instead of a 500. let password_hash = hash_password_async(form.password.clone()).await?; let user = match db::users::create_user(&db, &username, &email, &password_hash).await { Ok(u) => u, Err(AppError::Database(sqlx::Error::Database(ref db_err))) if db_err.code().as_deref() == Some("23505") => { let constraint = db_err.constraint().unwrap_or(""); let (field, msg): (Option<&str>, &str) = if constraint.contains("username") { (Some("username"), "This username is no longer available") } else if constraint.contains("email") { (Some("email"), "This email is already registered") } else { (None, "An account with these details already exists") }; return return_error(field, msg); } Err(e) => return Err(e), }; // Process invite code (if provided and valid) if let Some(ref code_raw) = form.invite_code { let code = code_raw.replace('-', "").trim().to_uppercase(); if !code.is_empty() && let Some(invite) = db::invites::get_valid_invite_code(&db, &code).await? // Atomic claim: if a concurrent signup redeemed the same code first, // this returns false and we skip the invite side-effects (the signup // itself already succeeded). && db::invites::redeem_invite_code(&db, invite.id, user.id).await? { db::waitlist::create_invited_waitlist_entry(&db, user.id, invite.creator_id).await?; // Fire-and-forget: notify the inviter let inviter_id = invite.creator_id; let invitee_username = user.username.to_string(); let email_client = mailer.clone(); let db_pool = db.clone(); let invite_host_url = config.host_url.clone(); let invite_signing_secret = config.signing_secret.clone(); bg.spawn("invite-redeemed notification", async move { if let Ok(Some(inviter)) = db::users::get_user_by_id(&db_pool, inviter_id).await { let unsub_url = crate::email::generate_unsubscribe_url( &invite_host_url, inviter.id, crate::email::UnsubscribeAction::Invite, &inviter.id.to_string(), &invite_signing_secret, ); let _ = email_client .send_invite_redeemed( inviter.id, &inviter.email, inviter.display_name.as_deref(), &invitee_username, Some(&unsub_url), ) .await; } }); } } // Capture values for emails before moving into session let user_id = user.id; let user_email = user.email.clone(); let user_display_name = user.display_name.clone(); // Create session let session_user = SessionUser { settlement_currency: user.settlement_currency, conversion_preference: user.conversion_preference, id: user.id, username: user.username, email: user.email.into_inner(), display_name: user.display_name, can_create_projects: false, suspended: false, is_admin: false, is_fan_plus: false, creator_tier: None, deactivated: false, is_sandbox: false, }; login_user(&session, session_user).await?; track_session(&session, &db, user_id, &headers).await?; // Send verification + welcome emails (async) let verify_url = email::generate_verification_url( &config.host_url, user_id, &user_email, &config.signing_secret, ); let email_client = mailer.clone(); let welcome_host_url = config.host_url.clone(); let welcome_db = db.clone(); bg.spawn("signup verification + welcome emails", async move { if let Err(e) = email_client .send_verification(&user_email, user_display_name.as_deref(), &verify_url) .await { tracing::error!(error = ?e, "failed to send verification email"); } if let Err(e) = email_client .send_onboarding_welcome( user_id, &user_email, user_display_name.as_deref(), &welcome_host_url, ) .await { tracing::error!(error = ?e, "failed to send welcome email"); } if let Err(e) = db::users::advance_onboarding_step(&welcome_db, user_id, 1).await { tracing::warn!(user_id = %user_id, step = 1, error = ?e, "failed to advance onboarding step"); } }); // Return step 2 partial Ok(render_step_profile().into_response()) } /// GET `/join/step/{step}`: load a step partial (for back navigation). #[tracing::instrument(skip_all, name = "join_wizard::step_load")] pub(crate) async fn step_load( State(db): State, AuthUser(user): AuthUser, session: Session, Path(step): Path, ) -> Result { let csrf_token = get_csrf_token(&session).await; render_step(&step, &db, user.id, csrf_token).await } /// POST `/join/step/{step}`: save and return next step. #[tracing::instrument(skip_all, name = "join_wizard::step_save")] pub(crate) async fn step_save( State(db): State, AuthUser(user): AuthUser, Path(step): Path, Form(form_data): Form>, ) -> Result { match step.as_str() { "profile" => { let display_name = form_data.get("display_name").map(|s| s.trim().to_string()); let bio = form_data.get("bio").map(|s| s.trim().to_string()); let has_display_name = display_name .as_ref() .is_some_and(|s: &String| !s.is_empty()); let has_bio = bio.as_ref().is_some_and(|s: &String| !s.is_empty()); if has_display_name || has_bio { db::users::update_user_profile( &db, user.id, display_name .as_ref() .filter(|s: &&String| !s.is_empty()) .map(std::string::String::as_str), bio.as_ref() .filter(|s: &&String| !s.is_empty()) .map(std::string::String::as_str), ) .await?; } render_step("complete", &db, user.id, None).await } _ => Err(AppError::NotFound), } } /// Render the profile step partial (no DB access needed). fn render_step_profile() -> Response { WizardJoinProfileTemplate { nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "profile"), } .into_response() } /// Render a step partial with the sidebar nav. async fn render_step( step: &str, db: &PgPool, user_id: db::UserId, csrf_token: Option, ) -> Result { match step { "account" => { // Thread a real CSRF token so a back-nav to the account step renders a // submittable form, not a token-less one that 403s (Run 12 UX MINOR). Ok(WizardJoinAccountTemplate { nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "account"), csrf_token, invite_code: None, username: String::new(), email: String::new(), error: None, error_field: None, } .into_response()) } "profile" => Ok(render_step_profile()), "complete" => { let user = db::users::get_user_by_id(db, user_id) .await? .ok_or(AppError::NotFound)?; let has_invite = db::waitlist::get_waitlist_entry_by_user(db, user_id) .await? .is_some(); Ok(WizardJoinCompleteTemplate { nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "complete"), display_name: user .display_name .unwrap_or_else(|| user.username.to_string()), is_creator: user.can_create_projects, has_invite, } .into_response()) } _ => Err(AppError::NotFound), } }