//! 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 axum::{ extract::{Path, Query, State}, http::header::HeaderMap, response::{Html, IntoResponse, Redirect, Response}, Form, }; use serde::Deserialize; use tower_sessions::Session; use crate::{ auth::{hash_password, login_user, track_session, AuthUser, MaybeUserVerified, SessionUser}, db::{self}, email, error::{AppError, Result}, helpers::{get_csrf_token, is_htmx_request}, routes::pages::dashboard::wizards::build_step_nav, templates::*, AppState, }; const JOIN_STEPS: &[&str] = &["account", "profile", "complete"]; const JOIN_LABELS: &[&str] = &["Account", "Profile", "Welcome"]; /// Query params for the join page. #[derive(Debug, Deserialize)] pub 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 async fn wizard_page( session: Session, MaybeUserVerified(maybe_user): MaybeUserVerified, Query(query): Query, ) -> 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, } .into_response() } /// Form input for account creation (step 1). #[derive(Debug, Deserialize)] pub 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 async fn step_account_create( State(state): State, headers: HeaderMap, session: Session, Form(form): Form, ) -> Result { let is_htmx = is_htmx_request(&headers); let return_error = |summary: &str| -> Result { if is_htmx { Ok(Html( LoginErrorTemplate { message: summary.to_string(), } .render_string(), ) .into_response()) } else { Err(AppError::validation(summary.to_string())) } }; let username = match db::Username::new(&form.username) { Ok(u) => u, Err(e) => return return_error(&e.to_string()), }; let email = match db::Email::new(&form.email) { Ok(e) => e, Err(_) => return return_error("Please enter a valid email address"), }; // Check uniqueness let username_taken = db::users::get_user_by_username(&state.db, &username) .await? .is_some(); let email_taken = db::users::get_user_by_email(&state.db, &email) .await? .is_some(); if username_taken && email_taken { return return_error("This username and email are already registered"); } else if username_taken { return return_error("This username is already taken"); } else if email_taken { return return_error("This email is already registered"); } let password_len = form.password.chars().count(); if password_len < 8 { return return_error("Password must be at least 8 characters"); } if password_len > 128 { return return_error("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 {} known data breach(es). Consider changing it.", count ), ) .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(&form.password)?; let user = match db::users::create_user(&state.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 msg = if constraint.contains("username") { "This username is no longer available" } else if constraint.contains("email") { "This email is already registered" } else { "An account with these details already exists" }; return return_error(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(&state.db, &code).await? { db::invites::redeem_invite_code(&state.db, invite.id, user.id).await?; db::waitlist::create_invited_waitlist_entry(&state.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 = state.email.clone(); let db_pool = state.db.clone(); state.bg.spawn("invite-redeemed notification", async move { if let Ok(Some(inviter)) = db::users::get_user_by_id(&db_pool, inviter_id).await { let _ = email_client .send_invite_redeemed( &inviter.email, inviter.display_name.as_deref(), &invitee_username, ) .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 { 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, &state.db, user_id, &headers).await?; // Send verification + welcome emails (async) let verify_url = email::generate_verification_url( &state.config.host_url, user_id, &user_email, &state.config.signing_secret, ); let email_client = state.email.clone(); let welcome_host_url = state.config.host_url.clone(); let welcome_db = state.db.clone(); state.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_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 async fn step_load( State(state): State, AuthUser(user): AuthUser, Path(step): Path, ) -> Result { render_step(&step, &state, user.id).await } /// POST `/join/step/{step}`: save and return next step. #[tracing::instrument(skip_all, name = "join_wizard::step_save")] pub async fn step_save( State(state): 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( &state.db, user.id, display_name.as_ref().filter(|s: &&String| !s.is_empty()).map(|s| s.as_str()), bio.as_ref().filter(|s: &&String| !s.is_empty()).map(|s| s.as_str()), ) .await?; } render_step("complete", &state, user.id).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, state: &AppState, user_id: db::UserId) -> Result { match step { "account" => { Ok(WizardJoinAccountTemplate { nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "account"), csrf_token: None, invite_code: None, } .into_response()) } "profile" => Ok(render_step_profile()), "complete" => { let user = db::users::get_user_by_id(&state.db, user_id) .await? .ok_or(AppError::NotFound)?; let has_invite = db::waitlist::get_waitlist_entry_by_user(&state.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), } }