Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

password-reset.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

password-reset.tsxBlame386 lines · 1 contributor
c63b860Claude1/**
2 * Block P1 — Password reset routes.
3 *
4 * GET /forgot-password → email-entry form
5 * POST /forgot-password → always redirects to ?sent=1
6 * GET /reset-password?token=… → new-password form (or invalid-link page)
7 * POST /reset-password → rotate password + redirect to /login
48c9efcClaude8 *
9 * 2026 polish: each page renders inside the shared `.auth-container`
10 * gateway with a display headline, supporting subtitle, "what happens
11 * next" copy, visible validation rules, and a loading-state submit
12 * button so the surface feels of-a-piece with the polished /login and
13 * /register pages. All form actions, POST handlers, redirects and
14 * validation semantics are preserved verbatim — only chrome changed.
c63b860Claude15 */
16
17import { Hono } from "hono";
18import { Layout } from "../views/layout";
48c9efcClaude19import { Form, FormGroup, Input, Alert, Text } from "../views/ui";
c63b860Claude20import { softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import {
23 createPasswordResetRequest,
24 consumeResetToken,
25 inspectResetToken,
26} from "../lib/password-reset";
27
28const passwordReset = new Hono<AuthEnv>();
29
48c9efcClaude30// ---------------------------------------------------------------------------
31// Shared CSS — scoped to `.auth-extra-*` so it can never collide with the
32// locked .auth-container rules in layout.tsx (which we do NOT touch).
33// The styles are emitted inline per-page; duplication is fine because the
34// browser's CSSOM dedupes identical rules and these pages are rarely
35// rendered back-to-back in the same session.
36// ---------------------------------------------------------------------------
37function ExtraStyles() {
38 return (
39 <style
40 dangerouslySetInnerHTML={{
41 __html: `
42 .auth-extra-headline {
43 font-family: var(--font-display);
44 font-weight: 700;
45 font-size: clamp(28px, 4.6vw, 40px);
46 line-height: 1.08;
47 letter-spacing: -0.028em;
48 color: var(--text-strong);
49 margin: 0 0 10px;
50 }
51 .auth-extra-sub {
52 color: var(--text-muted);
53 font-size: 14.5px;
54 line-height: 1.55;
55 margin: 0 0 22px;
56 }
57 .auth-extra-next {
58 margin-top: 14px;
59 padding: 12px 14px;
60 background: var(--bg-tertiary, var(--bg-secondary));
61 border: 1px solid var(--border);
62 border-radius: var(--r-sm, 6px);
63 color: var(--text-muted);
64 font-size: 13px;
65 line-height: 1.55;
66 }
67 .auth-extra-next strong { color: var(--text); font-weight: 600; }
68 .auth-extra-rules {
69 margin: 4px 0 14px;
70 padding: 0 0 0 16px;
71 color: var(--text-muted);
72 font-size: 12.5px;
73 line-height: 1.6;
74 }
75 .auth-extra-rules li { margin: 0; }
76 .auth-extra-submit {
77 width: 100%;
78 padding: 12px 16px;
79 font-size: 15px;
80 font-weight: 600;
81 margin-top: 4px;
82 }
83 /* Loading state — driven by inline script that toggles aria-busy
84 + a data attribute on submit. Spinner is a CSS-only pseudo-
85 element so we don't ship JS for the visual. */
86 .auth-extra-submit[aria-busy="true"] {
87 opacity: 0.78;
88 cursor: progress;
89 pointer-events: none;
90 }
91 .auth-extra-submit[aria-busy="true"]::after {
92 content: '';
93 display: inline-block;
94 width: 12px;
95 height: 12px;
96 margin-left: 8px;
97 vertical-align: -2px;
98 border: 2px solid currentColor;
99 border-right-color: transparent;
100 border-radius: 50%;
101 animation: auth-extra-spin 0.7s linear infinite;
102 }
103 @keyframes auth-extra-spin {
104 to { transform: rotate(360deg); }
105 }
106 .auth-extra-meta {
107 display: flex;
108 justify-content: center;
109 gap: 8px;
110 color: var(--text-muted);
111 font-size: 13px;
112 margin-top: 18px;
113 }
114 .auth-extra-meta a { color: var(--text); }
115 .auth-extra-divider-dot {
116 color: var(--text-faint);
117 }
118 `,
119 }}
120 />
121 );
122}
123
124/** Inline script — flips `aria-busy=true` on the form's submit button as
125 * soon as the form starts submitting so users get unambiguous feedback
126 * on slower connections. Plain DOM, no framework. Falls back to the
127 * browser's default behaviour if anything throws. */
128function SubmitBusyScript() {
129 return (
130 <script
131 dangerouslySetInnerHTML={{
132 __html: /* js */ `
133 (function () {
134 try {
135 var forms = document.querySelectorAll('form[data-auth-extra]');
136 forms.forEach(function (f) {
137 f.addEventListener('submit', function () {
138 var btn = f.querySelector('.auth-extra-submit');
139 if (btn) {
140 btn.setAttribute('aria-busy', 'true');
141 // Don't actually disable — disabled buttons get
142 // skipped on form submit by some browsers when the
143 // listener fires post-validation.
144 btn.dataset.label = btn.textContent || '';
145 }
146 });
147 });
148 } catch (e) { /* no-op */ }
149 })();
150 `,
151 }}
152 />
153 );
154}
155
c63b860Claude156passwordReset.get("/forgot-password", softAuth, (c) => {
157 const csrf = c.get("csrfToken") as string | undefined;
158 const sent = c.req.query("sent") === "1";
159
160 if (sent) {
161 return c.html(
162 <Layout title="Reset link sent" user={c.get("user") ?? null}>
163 <div class="auth-container">
48c9efcClaude164 <ExtraStyles />
165 <h2 class="auth-extra-headline">Check your inbox</h2>
166 <p class="auth-extra-sub">
167 We just dispatched a password-reset email — it usually lands within
168 a minute.
169 </p>
c63b860Claude170 <Alert variant="success">
171 If we have an account for that email, we've sent a reset link.
172 Check your inbox (and spam folder).
173 </Alert>
48c9efcClaude174 <div class="auth-extra-next">
175 <strong>What happens next:</strong> click the button in the email
176 within <strong>1 hour</strong> to set a new password. The link
177 works only once.
178 </div>
179 <p class="auth-switch">
180 <Text>
181 Didn't get it? <a href="/forgot-password">Send another link</a>.
182 </Text>
183 </p>
184 <p class="auth-switch">
185 <a href="/login">Back to sign in</a>
186 </p>
c63b860Claude187 </div>
188 </Layout>
189 );
190 }
191
192 return c.html(
193 <Layout title="Forgot password" user={c.get("user") ?? null}>
194 <div class="auth-container">
48c9efcClaude195 <ExtraStyles />
196 <h2 class="auth-extra-headline">Reset your password</h2>
197 <p class="auth-extra-sub">
198 Enter the email tied to your account and we'll send you a one-time
199 link to set a new password. No call to support needed.
c63b860Claude200 </p>
48c9efcClaude201 <Form
202 method="post"
203 action="/forgot-password"
204 csrfToken={csrf}
205 class="auth-extra-form"
206 >
c63b860Claude207 <FormGroup label="Email" htmlFor="email">
48c9efcClaude208 <Input
209 type="email"
210 name="email"
211 required
212 placeholder="you@example.com"
213 autocomplete="email"
214 aria-label="Email"
215 autofocus
216 />
c63b860Claude217 </FormGroup>
48c9efcClaude218 <button
219 type="submit"
220 class="btn btn-primary auth-extra-submit"
221 data-loading-label="Sending link…"
222 >
223 Send reset link
224 </button>
c63b860Claude225 </Form>
48c9efcClaude226 <div class="auth-extra-next" style="margin-top:18px">
227 <strong>What happens next:</strong> we'll email a reset link that
228 expires in 1 hour. If you don't see it, check spam — or come back
229 and request another.
230 </div>
231 <p class="auth-switch">
232 <Text>
233 Remembered it? <a href="/login">Sign in</a>
234 </Text>
235 </p>
236 {/* Hidden marker so the busy-script can find this form. */}
237 <script
238 dangerouslySetInnerHTML={{
239 __html: `document.currentScript.previousElementSibling && document.querySelectorAll('form').forEach(function(f){ f.setAttribute('data-auth-extra', '1'); });`,
240 }}
241 />
242 <SubmitBusyScript />
c63b860Claude243 </div>
244 </Layout>
245 );
246});
247
248passwordReset.post("/forgot-password", async (c) => {
249 const body = await c.req.parseBody();
250 const email = String(body.email || "").trim();
251 const ip =
252 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
253 c.req.header("x-real-ip") ||
254 undefined;
255 await createPasswordResetRequest(email, { requestIp: ip });
256 return c.redirect("/forgot-password?sent=1");
257});
258
259function InvalidLinkPage(props: { user: any }) {
260 return (
261 <Layout title="Link no longer valid" user={props.user ?? null}>
262 <div class="auth-container">
48c9efcClaude263 <ExtraStyles />
264 <h2 class="auth-extra-headline">This link is no longer valid</h2>
265 <p class="auth-extra-sub">
266 Reset links live for 1 hour and can only be used once. The link you
267 followed is expired, already used, or unknown.
268 </p>
c63b860Claude269 <Alert variant="error">
48c9efcClaude270 Reset links expire after 1 hour and can only be used once. This link
271 is expired, already used, or unknown.
c63b860Claude272 </Alert>
48c9efcClaude273 <p class="auth-switch" style="margin-top:16px">
274 <a href="/forgot-password">Request a new one</a>
275 </p>
276 <p class="auth-switch">
277 <a href="/login">Back to sign in</a>
278 </p>
c63b860Claude279 </div>
280 </Layout>
281 );
282}
283
284passwordReset.get("/reset-password", softAuth, async (c) => {
285 const token = String(c.req.query("token") || "").trim();
286 const csrf = c.get("csrfToken") as string | undefined;
287 const error = c.req.query("error");
288
289 if (!token) return c.html(<InvalidLinkPage user={c.get("user")} />);
290 const check = await inspectResetToken(token);
291 if (!check.valid) return c.html(<InvalidLinkPage user={c.get("user")} />);
292
293 return c.html(
294 <Layout title="Set a new password" user={c.get("user") ?? null}>
295 <div class="auth-container">
48c9efcClaude296 <ExtraStyles />
297 <h2 class="auth-extra-headline">Set a new password</h2>
298 <p class="auth-extra-sub">
299 Pick something fresh — your old sessions on other devices will be
300 signed out automatically once you save.
c63b860Claude301 </p>
48c9efcClaude302 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
303 <Form
304 method="post"
305 action="/reset-password"
306 csrfToken={csrf}
307 class="auth-extra-form"
308 >
c63b860Claude309 <input type="hidden" name="token" value={token} />
310 <FormGroup label="New password" htmlFor="password">
48c9efcClaude311 <Input
312 type="password"
313 name="password"
314 required
315 minLength={8}
316 placeholder="Min 8 characters"
317 autocomplete="new-password"
318 aria-label="New password"
319 autofocus
320 />
c63b860Claude321 </FormGroup>
48c9efcClaude322 <ul class="auth-extra-rules" aria-label="Password requirements">
323 <li>At least 8 characters</li>
324 <li>Mix of letters, numbers, or symbols recommended</li>
325 <li>Avoid passwords you use elsewhere</li>
326 </ul>
c63b860Claude327 <FormGroup label="Confirm new password" htmlFor="confirm">
48c9efcClaude328 <Input
329 type="password"
330 name="confirm"
331 required
332 minLength={8}
333 placeholder="Re-enter the new password"
334 autocomplete="new-password"
335 aria-label="Confirm new password"
336 />
c63b860Claude337 </FormGroup>
48c9efcClaude338 <button
339 type="submit"
340 class="btn btn-primary auth-extra-submit"
341 data-loading-label="Updating…"
342 >
343 Update password
344 </button>
c63b860Claude345 </Form>
48c9efcClaude346 <div class="auth-extra-next" style="margin-top:18px">
347 <strong>What happens next:</strong> we'll sign you out everywhere
348 else and bounce you to the sign-in page with your new password.
349 </div>
350 <p class="auth-switch">
351 <a href="/login">Cancel</a>
352 </p>
353 <script
354 dangerouslySetInnerHTML={{
355 __html: `document.querySelectorAll('form').forEach(function(f){ f.setAttribute('data-auth-extra', '1'); });`,
356 }}
357 />
358 <SubmitBusyScript />
c63b860Claude359 </div>
360 </Layout>
361 );
362});
363
364passwordReset.post("/reset-password", async (c) => {
365 const body = await c.req.parseBody();
366 const token = String(body.token || "").trim();
367 const password = String(body.password || "");
368 const confirm = String(body.confirm || "");
369
370 const back = (msg: string) =>
371 c.redirect(`/reset-password?token=${encodeURIComponent(token)}&error=${encodeURIComponent(msg)}`);
372
373 if (!token) return c.html(<InvalidLinkPage user={null} />);
374 if (!password || password.length < 8) return back("Password must be at least 8 characters");
375 if (password !== confirm) return back("Passwords do not match");
376
377 const result = await consumeResetToken(token, password);
378 if (!result.ok) {
379 if (result.reason === "weak") return back("Password must be at least 8 characters");
380 return c.html(<InvalidLinkPage user={null} />);
381 }
382
383 return c.redirect("/login?success=" + encodeURIComponent("Password updated — please sign in"));
384});
385
386export default passwordReset;