Skip to main content

max / makenotwork

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