Skip to main content

max / makenotwork

15.2 KB · 403 lines History Blame Raw
1 //! HTMX multi-step signup wizard.
2 //!
3 //! Step 1 creates the account (public, rate-limited). Steps 2-5 are optional
4 //! and update the newly authenticated user. Layout reuses the Phase 25 wizard
5 //! infrastructure (sidebar step indicator, HTMX partial swaps).
6
7 use axum::{
8 Form,
9 extract::{Path, Query, State},
10 http::header::HeaderMap,
11 response::{IntoResponse, Redirect, Response},
12 };
13 use serde::Deserialize;
14 use sqlx::PgPool;
15 use tower_sessions::Session;
16
17 use crate::{
18 auth::{
19 AuthUser, MaybeUserVerified, SessionUser, hash_password_async, login_user, track_session,
20 },
21 background::BackgroundTx,
22 config::Config,
23 db::{self},
24 email::{self, EmailClient},
25 error::{AppError, Result},
26 helpers::{get_csrf_token, is_htmx_request},
27 routes::pages::dashboard::wizards::build_step_nav,
28 templates::{
29 WizardJoinAccountTemplate, WizardJoinCompleteTemplate, WizardJoinProfileTemplate,
30 WizardJoinTemplate,
31 },
32 };
33
34 const JOIN_STEPS: &[&str] = &["account", "profile", "complete"];
35 const JOIN_LABELS: &[&str] = &["Account", "Profile", "Welcome"];
36
37 /// Query params for the join page.
38 #[derive(Debug, Deserialize)]
39 pub(crate) struct JoinQuery {
40 pub invite: Option<String>,
41 }
42
43 /// Render the full wizard page with step 1 inline.
44 /// Redirects logged-in users to `/dashboard`.
45 #[tracing::instrument(skip_all, name = "join_wizard::page")]
46 pub(crate) async fn wizard_page(
47 session: Session,
48 MaybeUserVerified(maybe_user): MaybeUserVerified,
49 Query(query): Query<JoinQuery>,
50 ) -> Response {
51 if maybe_user.is_some() {
52 return Redirect::to("/dashboard").into_response();
53 }
54 WizardJoinTemplate {
55 csrf_token: get_csrf_token(&session).await,
56 nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "account"),
57 invite_code: query.invite,
58 username: String::new(),
59 email: String::new(),
60 error: None,
61 error_field: None,
62 }
63 .into_response()
64 }
65
66 /// Form input for account creation (step 1).
67 #[derive(Debug, Deserialize)]
68 pub(crate) struct AccountForm {
69 pub username: String,
70 pub email: String,
71 pub password: String,
72 pub invite_code: Option<String>,
73 }
74
75 /// POST `/join/step/account`: create account and log in, then return step 2.
76 #[tracing::instrument(skip_all, name = "join_wizard::account_create")]
77 pub(crate) async fn step_account_create(
78 State(db): State<PgPool>,
79 State(config): State<Config>,
80 State(mailer): State<EmailClient>,
81 State(bg): State<BackgroundTx>,
82 headers: HeaderMap,
83 session: Session,
84 Form(form): Form<AccountForm>,
85 ) -> Result<Response> {
86 let is_htmx = is_htmx_request(&headers);
87 let csrf_token = get_csrf_token(&session).await;
88
89 let return_error = |field: Option<&str>, summary: &str| -> Result<Response> {
90 if is_htmx {
91 // HTMX swaps the response into #wizard-step. Re-render the account
92 // STEP PARTIAL with the typed username/email preserved and the bad
93 // field flagged, not a bare LoginErrorTemplate, which would replace
94 // the whole form with a single error line and drop all input (UX-S1).
95 Ok(WizardJoinAccountTemplate {
96 nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "account"),
97 csrf_token: csrf_token.clone(),
98 invite_code: form.invite_code.clone(),
99 username: form.username.clone(),
100 email: form.email.clone(),
101 error: Some(summary.to_string()),
102 error_field: field.map(std::string::ToString::to_string),
103 }
104 .into_response())
105 } else {
106 // Non-HTMX (JS disabled): re-render the full account step with the
107 // typed username/email preserved and the offending field marked
108 // invalid, instead of a generic 422 that drops everything entered.
109 Ok(WizardJoinTemplate {
110 csrf_token: csrf_token.clone(),
111 nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "account"),
112 invite_code: form.invite_code.clone(),
113 username: form.username.clone(),
114 email: form.email.clone(),
115 error: Some(summary.to_string()),
116 error_field: field.map(std::string::ToString::to_string),
117 }
118 .into_response())
119 }
120 };
121
122 let username = match db::Username::new(&form.username) {
123 Ok(u) => u,
124 Err(e) => return return_error(Some("username"), &e.to_string()),
125 };
126
127 let Ok(email) = db::Email::new(&form.email) else {
128 return return_error(Some("email"), "Please enter a valid email address");
129 };
130
131 // Check uniqueness
132 let username_taken = db::users::get_user_by_username(&db, &username)
133 .await?
134 .is_some();
135 let email_taken = db::users::get_user_by_email(&db, &email).await?.is_some();
136 // Username collisions are safe to reveal, usernames are public handles
137 // (profile URLs expose them) and the user must pick a free one. An EMAIL
138 // collision must NOT be revealed: "this email is already registered" is an
139 // account-existence oracle for a private identifier (ultra-fuzz Run 4 m1).
140 // So when only the email is taken, do not error, return the same step-2
141 // response a fresh signup returns and send an "account exists" email out of
142 // band. Only the real owner receives that email, so the recovery path
143 // reaches them without the page confirming the address. (Residual: a fresh
144 // signup also sets a session cookie, which this path cannot; the explicit
145 // textual reveal, the actual finding, is gone.)
146 if username_taken {
147 return return_error(Some("username"), "This username is already taken");
148 }
149 if email_taken {
150 let login_url = format!("{}/login", config.host_url);
151 let reset_url = format!("{}/forgot-password", config.host_url);
152 let email_client = mailer.clone();
153 let to_email = email.to_string();
154 bg.spawn("account-exists notice", async move {
155 if let Err(e) = email_client
156 .send_account_exists(&to_email, &login_url, &reset_url)
157 .await
158 {
159 tracing::error!(error = ?e, "failed to send account-exists email");
160 }
161 });
162 return Ok(render_step_profile().into_response());
163 }
164
165 let password_len = form.password.chars().count();
166 if password_len < crate::validation::limits::PASSWORD_MIN {
167 return return_error(Some("password"), "Password must be at least 8 characters");
168 }
169 if crate::validation::password_too_long(&form.password) {
170 return return_error(Some("password"), "Password must be 128 characters or fewer");
171 }
172
173 // Check for breached password (advisory only)
174 if let Some(count) = crate::auth::check_password_breach(&form.password).await {
175 tracing::warn!(
176 event = "breached_password_signup",
177 breach_count = count,
178 "New user signed up with breached password"
179 );
180 session
181 .insert(
182 "password_warning",
183 format!(
184 "This password has appeared in {count} known data breach(es). Consider changing it."
185 ),
186 )
187 .await
188 .ok();
189 }
190
191 // Hash password and create user. The uniqueness checks above are
192 // best-effort, a concurrent signup with the same username or email can
193 // slip between the SELECT and the INSERT and raise a 23505. Catch it and
194 // surface as a validation error so the user sees a friendly message
195 // (with their typed values preserved) instead of a 500.
196 let password_hash = hash_password_async(form.password.clone()).await?;
197 let user = match db::users::create_user(&db, &username, &email, &password_hash).await {
198 Ok(u) => u,
199 Err(AppError::Database(sqlx::Error::Database(ref db_err)))
200 if db_err.code().as_deref() == Some("23505") =>
201 {
202 let constraint = db_err.constraint().unwrap_or("");
203 let (field, msg): (Option<&str>, &str) = if constraint.contains("username") {
204 (Some("username"), "This username is no longer available")
205 } else if constraint.contains("email") {
206 (Some("email"), "This email is already registered")
207 } else {
208 (None, "An account with these details already exists")
209 };
210 return return_error(field, msg);
211 }
212 Err(e) => return Err(e),
213 };
214
215 // Process invite code (if provided and valid)
216 if let Some(ref code_raw) = form.invite_code {
217 let code = code_raw.replace('-', "").trim().to_uppercase();
218 if !code.is_empty()
219 && let Some(invite) = db::invites::get_valid_invite_code(&db, &code).await?
220 // Atomic claim: if a concurrent signup redeemed the same code first,
221 // this returns false and we skip the invite side-effects (the signup
222 // itself already succeeded).
223 && db::invites::redeem_invite_code(&db, invite.id, user.id).await?
224 {
225 db::waitlist::create_invited_waitlist_entry(&db, user.id, invite.creator_id).await?;
226
227 // Fire-and-forget: notify the inviter
228 let inviter_id = invite.creator_id;
229 let invitee_username = user.username.to_string();
230 let email_client = mailer.clone();
231 let db_pool = db.clone();
232 bg.spawn("invite-redeemed notification", async move {
233 if let Ok(Some(inviter)) = db::users::get_user_by_id(&db_pool, inviter_id).await {
234 let _ = email_client
235 .send_invite_redeemed(
236 &inviter.email,
237 inviter.display_name.as_deref(),
238 &invitee_username,
239 )
240 .await;
241 }
242 });
243 }
244 }
245
246 // Capture values for emails before moving into session
247 let user_id = user.id;
248 let user_email = user.email.clone();
249 let user_display_name = user.display_name.clone();
250
251 // Create session
252 let session_user = SessionUser {
253 id: user.id,
254 username: user.username,
255 email: user.email.into_inner(),
256 display_name: user.display_name,
257 can_create_projects: false,
258 suspended: false,
259 is_admin: false,
260 is_fan_plus: false,
261 creator_tier: None,
262 deactivated: false,
263 is_sandbox: false,
264 };
265 login_user(&session, session_user).await?;
266 track_session(&session, &db, user_id, &headers).await?;
267
268 // Send verification + welcome emails (async)
269 let verify_url = email::generate_verification_url(
270 &config.host_url,
271 user_id,
272 &user_email,
273 &config.signing_secret,
274 );
275 let email_client = mailer.clone();
276 let welcome_host_url = config.host_url.clone();
277 let welcome_db = db.clone();
278 bg.spawn("signup verification + welcome emails", async move {
279 if let Err(e) = email_client
280 .send_verification(&user_email, user_display_name.as_deref(), &verify_url)
281 .await
282 {
283 tracing::error!(error = ?e, "failed to send verification email");
284 }
285 if let Err(e) = email_client
286 .send_onboarding_welcome(
287 &user_email,
288 user_display_name.as_deref(),
289 &welcome_host_url,
290 )
291 .await
292 {
293 tracing::error!(error = ?e, "failed to send welcome email");
294 }
295 if let Err(e) = db::users::advance_onboarding_step(&welcome_db, user_id, 1).await {
296 tracing::warn!(user_id = %user_id, step = 1, error = ?e, "failed to advance onboarding step");
297 }
298 });
299
300 // Return step 2 partial
301 Ok(render_step_profile().into_response())
302 }
303
304 /// GET `/join/step/{step}`: load a step partial (for back navigation).
305 #[tracing::instrument(skip_all, name = "join_wizard::step_load")]
306 pub(crate) async fn step_load(
307 State(db): State<PgPool>,
308 AuthUser(user): AuthUser,
309 session: Session,
310 Path(step): Path<String>,
311 ) -> Result<Response> {
312 let csrf_token = get_csrf_token(&session).await;
313 render_step(&step, &db, user.id, csrf_token).await
314 }
315
316 /// POST `/join/step/{step}`: save and return next step.
317 #[tracing::instrument(skip_all, name = "join_wizard::step_save")]
318 pub(crate) async fn step_save(
319 State(db): State<PgPool>,
320 AuthUser(user): AuthUser,
321 Path(step): Path<String>,
322 Form(form_data): Form<std::collections::HashMap<String, String>>,
323 ) -> Result<Response> {
324 match step.as_str() {
325 "profile" => {
326 let display_name = form_data.get("display_name").map(|s| s.trim().to_string());
327 let bio = form_data.get("bio").map(|s| s.trim().to_string());
328 let has_display_name = display_name
329 .as_ref()
330 .is_some_and(|s: &String| !s.is_empty());
331 let has_bio = bio.as_ref().is_some_and(|s: &String| !s.is_empty());
332 if has_display_name || has_bio {
333 db::users::update_user_profile(
334 &db,
335 user.id,
336 display_name
337 .as_ref()
338 .filter(|s: &&String| !s.is_empty())
339 .map(std::string::String::as_str),
340 bio.as_ref()
341 .filter(|s: &&String| !s.is_empty())
342 .map(std::string::String::as_str),
343 )
344 .await?;
345 }
346 render_step("complete", &db, user.id, None).await
347 }
348 _ => Err(AppError::NotFound),
349 }
350 }
351
352 /// Render the profile step partial (no DB access needed).
353 fn render_step_profile() -> Response {
354 WizardJoinProfileTemplate {
355 nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "profile"),
356 }
357 .into_response()
358 }
359
360 /// Render a step partial with the sidebar nav.
361 async fn render_step(
362 step: &str,
363 db: &PgPool,
364 user_id: db::UserId,
365 csrf_token: Option<String>,
366 ) -> Result<Response> {
367 match step {
368 "account" => {
369 // Thread a real CSRF token so a back-nav to the account step renders a
370 // submittable form, not a token-less one that 403s (Run 12 UX MINOR).
371 Ok(WizardJoinAccountTemplate {
372 nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "account"),
373 csrf_token,
374 invite_code: None,
375 username: String::new(),
376 email: String::new(),
377 error: None,
378 error_field: None,
379 }
380 .into_response())
381 }
382 "profile" => Ok(render_step_profile()),
383 "complete" => {
384 let user = db::users::get_user_by_id(db, user_id)
385 .await?
386 .ok_or(AppError::NotFound)?;
387 let has_invite = db::waitlist::get_waitlist_entry_by_user(db, user_id)
388 .await?
389 .is_some();
390 Ok(WizardJoinCompleteTemplate {
391 nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "complete"),
392 display_name: user
393 .display_name
394 .unwrap_or_else(|| user.username.to_string()),
395 is_creator: user.can_create_projects,
396 has_invite,
397 }
398 .into_response())
399 }
400 _ => Err(AppError::NotFound),
401 }
402 }
403