Skip to main content

max / makenotwork

Render the 2FA errors through the status partial that has CSS .save-error had zero rules anywhere in static/, so the three places a user is told they typed the wrong 2FA code or the wrong password were the three places the message was not red. Six sites onto SaveStatusTemplate, which emits .save-status.error and is what the other twenty write paths already return: the three TOTP handlers, the two passkey.js literals, and items/crud.rs, which returned "Saved." with no element at all. confirm's `invalid` closure returns Result<Response> now, since rendering the partial is fallible where a string literal was not.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 14:09 UTC
Signed with PGP, not checked
Commit: 264b9bb8ae494e2ed0eed5ddfedd43fe46cc31a7
Parent: 2899b1d
3 files changed, +37 insertions, -12 deletions
@@ -75,7 +75,7 @@
75 75 } catch (e) {
76 76 if (e.name === 'NotAllowedError') return; // User cancelled
77 77 if (errorEl) {
78 - errorEl.innerHTML = '<span class="save-error">Passkey login failed. Try again or use your password.</span>';
78 + errorEl.innerHTML = '<span class="save-status error">Passkey login failed. Try again or use your password.</span>';
79 79 }
80 80 }
81 81 };
@@ -142,7 +142,7 @@
142 142 } catch (e) {
143 143 if (e.name === 'NotAllowedError') return; // User cancelled
144 144 if (statusEl) {
145 - statusEl.innerHTML = '<span class="save-error">Registration failed. Please try again.</span>';
145 + statusEl.innerHTML = '<span class="save-status error">Registration failed. Please try again.</span>';
146 146 }
147 147 }
148 148 };
@@ -16,7 +16,7 @@
16 16 db,
17 17 error::{AppError, Result, ResultExt},
18 18 helpers::hx_toast,
19 - templates::{TotpSetupTemplate, TotpStatusTemplate},
19 + templates::{SaveStatusTemplate, TotpSetupTemplate, TotpStatusTemplate},
20 20 };
21 21
22 22 /// Generate a TOTP secret, QR code, and backup codes (does not enable 2FA yet).
@@ -103,19 +103,25 @@
103 103 // accepted step, and that step is then recorded. `setup` cleared the step to
104 104 // NULL (defaults to 0), so a genuine first code (step ~= now/30) always wins.
105 105 let now = chrono::Utc::now().timestamp() as u64;
106 - let invalid = || {
107 - (
106 + let invalid = || -> Result<Response> {
107 + Ok((
108 108 [
109 109 ("HX-Retarget", "#totp-confirm-status"),
110 110 ("HX-Reswap", "innerHTML"),
111 111 ],
112 - Html("<span class=\"save-error\">Invalid code. Please try again.</span>"),
112 + Html(
113 + SaveStatusTemplate {
114 + success: false,
115 + message: "Invalid code. Please try again.".to_string(),
116 + }
117 + .render_string()?,
118 + ),
113 119 )
114 - .into_response()
120 + .into_response())
115 121 };
116 122
117 123 let Some(step) = find_matching_step(&totp, &form.code, now) else {
118 - return Ok(invalid());
124 + return invalid();
119 125 };
120 126
121 127 // Record the matched step atomically; the guarded write is the authoritative
@@ -123,7 +129,7 @@
123 129 // the step loses here and is rejected, the step monotonicity is enforced by
124 130 // the DB, not by a separate read-then-write.
125 131 if !db::totp::set_totp_last_used_step(&db, user.id, step).await? {
126 - return Ok(invalid());
132 + return invalid();
127 133 }
128 134
129 135 db::totp::enable_totp(&db, user.id).await?;
@@ -161,7 +167,13 @@
161 167 ("HX-Retarget", "#totp-disable-status"),
162 168 ("HX-Reswap", "innerHTML"),
163 169 ],
164 - Html("<span class=\"save-error\">Incorrect password.</span>"),
170 + Html(
171 + SaveStatusTemplate {
172 + success: false,
173 + message: "Incorrect password.".to_string(),
174 + }
175 + .render_string()?,
176 + ),
165 177 )
166 178 .into_response());
167 179 }
@@ -202,7 +214,13 @@
202 214 ("HX-Retarget", "#backup-regen-status"),
203 215 ("HX-Reswap", "innerHTML"),
204 216 ],
205 - Html("<span class=\"save-error\">Incorrect password.</span>"),
217 + Html(
218 + SaveStatusTemplate {
219 + success: false,
220 + message: "Incorrect password.".to_string(),
221 + }
222 + .render_string()?,
223 + ),
206 224 )
207 225 .into_response());
208 226 }
@@ -280,7 +280,14 @@
280 280 db::projects::bump_cache_generation(&db, updated.project_id).await?;
281 281
282 282 if is_htmx_request(&headers) {
283 - return Ok(axum::response::Html("Saved.".to_string()).into_response());
283 + return Ok(axum::response::Html(
284 + SaveStatusTemplate {
285 + success: true,
286 + message: "Saved.".to_string(),
287 + }
288 + .render_string()?,
289 + )
290 + .into_response());
284 291 }
285 292
286 293 Ok(Json(ItemResponse {