Skip to main content

max / makenotwork

11.4 KB · 326 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 extract::{Path, Query, State},
9 http::header::HeaderMap,
10 response::{Html, IntoResponse, Redirect, Response},
11 Form,
12 };
13 use serde::Deserialize;
14 use tower_sessions::Session;
15
16 use crate::{
17 auth::{hash_password, login_user, track_session, AuthUser, MaybeUserVerified, SessionUser},
18 db::{self},
19 email,
20 error::{AppError, Result},
21 helpers::{get_csrf_token, is_htmx_request},
22 routes::pages::dashboard::wizards::build_step_nav,
23 templates::*,
24 AppState,
25 };
26
27 const JOIN_STEPS: &[&str] = &["account", "profile", "complete"];
28 const JOIN_LABELS: &[&str] = &["Account", "Profile", "Welcome"];
29
30 /// Query params for the join page.
31 #[derive(Debug, Deserialize)]
32 pub struct JoinQuery {
33 pub invite: Option<String>,
34 }
35
36 /// Render the full wizard page with step 1 inline.
37 /// Redirects logged-in users to `/dashboard`.
38 #[tracing::instrument(skip_all, name = "join_wizard::page")]
39 pub async fn wizard_page(
40 session: Session,
41 MaybeUserVerified(maybe_user): MaybeUserVerified,
42 Query(query): Query<JoinQuery>,
43 ) -> Response {
44 if maybe_user.is_some() {
45 return Redirect::to("/dashboard").into_response();
46 }
47 WizardJoinTemplate {
48 csrf_token: get_csrf_token(&session).await,
49 nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "account"),
50 invite_code: query.invite,
51 }
52 .into_response()
53 }
54
55 /// Form input for account creation (step 1).
56 #[derive(Debug, Deserialize)]
57 pub struct AccountForm {
58 pub username: String,
59 pub email: String,
60 pub password: String,
61 pub invite_code: Option<String>,
62 }
63
64 /// POST `/join/step/account`: create account and log in, then return step 2.
65 #[tracing::instrument(skip_all, name = "join_wizard::account_create")]
66 pub async fn step_account_create(
67 State(state): State<AppState>,
68 headers: HeaderMap,
69 session: Session,
70 Form(form): Form<AccountForm>,
71 ) -> Result<Response> {
72 let is_htmx = is_htmx_request(&headers);
73
74 let return_error = |summary: &str| -> Result<Response> {
75 if is_htmx {
76 Ok(Html(
77 LoginErrorTemplate {
78 message: summary.to_string(),
79 }
80 .render_string(),
81 )
82 .into_response())
83 } else {
84 Err(AppError::validation(summary.to_string()))
85 }
86 };
87
88 let username = match db::Username::new(&form.username) {
89 Ok(u) => u,
90 Err(e) => return return_error(&e.to_string()),
91 };
92
93 let email = match db::Email::new(&form.email) {
94 Ok(e) => e,
95 Err(_) => return return_error("Please enter a valid email address"),
96 };
97
98 // Check uniqueness
99 let username_taken = db::users::get_user_by_username(&state.db, &username)
100 .await?
101 .is_some();
102 let email_taken = db::users::get_user_by_email(&state.db, &email)
103 .await?
104 .is_some();
105 if username_taken && email_taken {
106 return return_error("This username and email are already registered");
107 } else if username_taken {
108 return return_error("This username is already taken");
109 } else if email_taken {
110 return return_error("This email is already registered");
111 }
112
113 let password_len = form.password.chars().count();
114 if password_len < 8 {
115 return return_error("Password must be at least 8 characters");
116 }
117 if password_len > 128 {
118 return return_error("Password must be 128 characters or fewer");
119 }
120
121 // Check for breached password (advisory only)
122 if let Some(count) = crate::auth::check_password_breach(&form.password).await {
123 tracing::warn!(event = "breached_password_signup", breach_count = count, "New user signed up with breached password");
124 session
125 .insert(
126 "password_warning",
127 format!(
128 "This password has appeared in {} known data breach(es). Consider changing it.",
129 count
130 ),
131 )
132 .await
133 .ok();
134 }
135
136 // Hash password and create user. The uniqueness checks above are
137 // best-effort — a concurrent signup with the same username or email can
138 // slip between the SELECT and the INSERT and raise a 23505. Catch it and
139 // surface as a validation error so the user sees a friendly message
140 // (with their typed values preserved) instead of a 500.
141 let password_hash = hash_password(&form.password)?;
142 let user = match db::users::create_user(&state.db, &username, &email, &password_hash).await {
143 Ok(u) => u,
144 Err(AppError::Database(sqlx::Error::Database(ref db_err)))
145 if db_err.code().as_deref() == Some("23505") =>
146 {
147 let constraint = db_err.constraint().unwrap_or("");
148 let msg = if constraint.contains("username") {
149 "This username is no longer available"
150 } else if constraint.contains("email") {
151 "This email is already registered"
152 } else {
153 "An account with these details already exists"
154 };
155 return return_error(msg);
156 }
157 Err(e) => return Err(e),
158 };
159
160 // Process invite code (if provided and valid)
161 if let Some(ref code_raw) = form.invite_code {
162 let code = code_raw.replace('-', "").trim().to_uppercase();
163 if !code.is_empty()
164 && let Some(invite) = db::invites::get_valid_invite_code(&state.db, &code).await?
165 {
166 db::invites::redeem_invite_code(&state.db, invite.id, user.id).await?;
167 db::waitlist::create_invited_waitlist_entry(&state.db, user.id, invite.creator_id)
168 .await?;
169
170 // Fire-and-forget: notify the inviter
171 let inviter_id = invite.creator_id;
172 let invitee_username = user.username.to_string();
173 let email_client = state.email.clone();
174 let db_pool = state.db.clone();
175 state.bg.spawn("invite-redeemed notification", async move {
176 if let Ok(Some(inviter)) = db::users::get_user_by_id(&db_pool, inviter_id).await {
177 let _ = email_client
178 .send_invite_redeemed(
179 &inviter.email,
180 inviter.display_name.as_deref(),
181 &invitee_username,
182 )
183 .await;
184 }
185 });
186 }
187 }
188
189 // Capture values for emails before moving into session
190 let user_id = user.id;
191 let user_email = user.email.clone();
192 let user_display_name = user.display_name.clone();
193
194 // Create session
195 let session_user = SessionUser {
196 id: user.id,
197 username: user.username,
198 email: user.email.into_inner(),
199 display_name: user.display_name,
200 can_create_projects: false,
201 suspended: false,
202 is_admin: false,
203 is_fan_plus: false,
204 creator_tier: None,
205 deactivated: false,
206 is_sandbox: false,
207 };
208 login_user(&session, session_user).await?;
209 track_session(&session, &state.db, user_id, &headers).await?;
210
211 // Send verification + welcome emails (async)
212 let verify_url = email::generate_verification_url(
213 &state.config.host_url,
214 user_id,
215 &user_email,
216 &state.config.signing_secret,
217 );
218 let email_client = state.email.clone();
219 let welcome_host_url = state.config.host_url.clone();
220 let welcome_db = state.db.clone();
221 state.bg.spawn("signup verification + welcome emails", async move {
222 if let Err(e) = email_client
223 .send_verification(&user_email, user_display_name.as_deref(), &verify_url)
224 .await
225 {
226 tracing::error!(error = ?e, "failed to send verification email");
227 }
228 if let Err(e) = email_client
229 .send_onboarding_welcome(
230 &user_email,
231 user_display_name.as_deref(),
232 &welcome_host_url,
233 )
234 .await
235 {
236 tracing::error!(error = ?e, "failed to send welcome email");
237 }
238 if let Err(e) = db::users::advance_onboarding_step(&welcome_db, user_id, 1).await {
239 tracing::warn!(user_id = %user_id, step = 1, error = ?e, "failed to advance onboarding step");
240 }
241 });
242
243 // Return step 2 partial
244 Ok(render_step_profile().into_response())
245 }
246
247 /// GET `/join/step/{step}`: load a step partial (for back navigation).
248 #[tracing::instrument(skip_all, name = "join_wizard::step_load")]
249 pub async fn step_load(
250 State(state): State<AppState>,
251 AuthUser(user): AuthUser,
252 Path(step): Path<String>,
253 ) -> Result<Response> {
254 render_step(&step, &state, user.id).await
255 }
256
257 /// POST `/join/step/{step}`: save and return next step.
258 #[tracing::instrument(skip_all, name = "join_wizard::step_save")]
259 pub async fn step_save(
260 State(state): State<AppState>,
261 AuthUser(user): AuthUser,
262 Path(step): Path<String>,
263 Form(form_data): Form<std::collections::HashMap<String, String>>,
264 ) -> Result<Response> {
265 match step.as_str() {
266 "profile" => {
267 let display_name = form_data.get("display_name").map(|s| s.trim().to_string());
268 let bio = form_data.get("bio").map(|s| s.trim().to_string());
269 let has_display_name = display_name.as_ref().is_some_and(|s: &String| !s.is_empty());
270 let has_bio = bio.as_ref().is_some_and(|s: &String| !s.is_empty());
271 if has_display_name || has_bio {
272 db::users::update_user_profile(
273 &state.db,
274 user.id,
275 display_name.as_ref().filter(|s: &&String| !s.is_empty()).map(|s| s.as_str()),
276 bio.as_ref().filter(|s: &&String| !s.is_empty()).map(|s| s.as_str()),
277 )
278 .await?;
279 }
280 render_step("complete", &state, user.id).await
281 }
282 _ => Err(AppError::NotFound),
283 }
284 }
285
286 /// Render the profile step partial (no DB access needed).
287 fn render_step_profile() -> Response {
288 WizardJoinProfileTemplate {
289 nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "profile"),
290 }
291 .into_response()
292 }
293
294 /// Render a step partial with the sidebar nav.
295 async fn render_step(step: &str, state: &AppState, user_id: db::UserId) -> Result<Response> {
296 match step {
297 "account" => {
298 Ok(WizardJoinAccountTemplate {
299 nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "account"),
300 csrf_token: None,
301 invite_code: None,
302 }
303 .into_response())
304 }
305 "profile" => Ok(render_step_profile()),
306 "complete" => {
307 let user = db::users::get_user_by_id(&state.db, user_id)
308 .await?
309 .ok_or(AppError::NotFound)?;
310 let has_invite = db::waitlist::get_waitlist_entry_by_user(&state.db, user_id)
311 .await?
312 .is_some();
313 Ok(WizardJoinCompleteTemplate {
314 nav: build_step_nav(JOIN_STEPS, JOIN_LABELS, "complete"),
315 display_name: user
316 .display_name
317 .unwrap_or_else(|| user.username.to_string()),
318 is_creator: user.can_create_projects,
319 has_invite,
320 }
321 .into_response())
322 }
323 _ => Err(AppError::NotFound),
324 }
325 }
326