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.
| c63b860 | 1 | /** |
| 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 | |
| 8 | */ | |
| 9 | ||
| 10 | import { Hono } from "hono"; | |
| 11 | import { Layout } from "../views/layout"; | |
| 12 | import { Form, FormGroup, Input, Button, Alert, Text } from "../views/ui"; | |
| 13 | import { softAuth } from "../middleware/auth"; | |
| 14 | import type { AuthEnv } from "../middleware/auth"; | |
| 15 | import { | |
| 16 | createPasswordResetRequest, | |
| 17 | consumeResetToken, | |
| 18 | inspectResetToken, | |
| 19 | } from "../lib/password-reset"; | |
| 20 | ||
| 21 | const passwordReset = new Hono<AuthEnv>(); | |
| 22 | ||
| 23 | passwordReset.get("/forgot-password", softAuth, (c) => { | |
| 24 | const csrf = c.get("csrfToken") as string | undefined; | |
| 25 | const sent = c.req.query("sent") === "1"; | |
| 26 | ||
| 27 | if (sent) { | |
| 28 | return c.html( | |
| 29 | <Layout title="Reset link sent" user={c.get("user") ?? null}> | |
| 30 | <div class="auth-container"> | |
| 31 | <h2>Check your inbox</h2> | |
| 32 | <Alert variant="success"> | |
| 33 | If we have an account for that email, we've sent a reset link. | |
| 34 | Check your inbox (and spam folder). | |
| 35 | </Alert> | |
| 36 | <p class="auth-switch"><Text>The link expires in 1 hour.</Text></p> | |
| 37 | <p class="auth-switch"><a href="/login">Back to sign in</a></p> | |
| 38 | </div> | |
| 39 | </Layout> | |
| 40 | ); | |
| 41 | } | |
| 42 | ||
| 43 | return c.html( | |
| 44 | <Layout title="Forgot password" user={c.get("user") ?? null}> | |
| 45 | <div class="auth-container"> | |
| 46 | <h2>Reset your password</h2> | |
| 47 | <p class="auth-switch" style="margin-bottom:16px;margin-top:0"> | |
| 48 | <Text>Enter the email tied to your account and we'll send you a link to set a new password.</Text> | |
| 49 | </p> | |
| 50 | <Form method="post" action="/forgot-password" csrfToken={csrf}> | |
| 51 | <FormGroup label="Email" htmlFor="email"> | |
| 52 | <Input type="email" name="email" required placeholder="you@example.com" autocomplete="email" aria-label="Email" /> | |
| 53 | </FormGroup> | |
| 54 | <Button type="submit" variant="primary">Send reset link</Button> | |
| 55 | </Form> | |
| 56 | <p class="auth-switch"><Text>Remembered it? <a href="/login">Sign in</a></Text></p> | |
| 57 | </div> | |
| 58 | </Layout> | |
| 59 | ); | |
| 60 | }); | |
| 61 | ||
| 62 | passwordReset.post("/forgot-password", async (c) => { | |
| 63 | const body = await c.req.parseBody(); | |
| 64 | const email = String(body.email || "").trim(); | |
| 65 | const ip = | |
| 66 | c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || | |
| 67 | c.req.header("x-real-ip") || | |
| 68 | undefined; | |
| 69 | await createPasswordResetRequest(email, { requestIp: ip }); | |
| 70 | return c.redirect("/forgot-password?sent=1"); | |
| 71 | }); | |
| 72 | ||
| 73 | function InvalidLinkPage(props: { user: any }) { | |
| 74 | return ( | |
| 75 | <Layout title="Link no longer valid" user={props.user ?? null}> | |
| 76 | <div class="auth-container"> | |
| 77 | <h2>This link is no longer valid</h2> | |
| 78 | <Alert variant="error"> | |
| 79 | Reset links expire after 1 hour and can only be used once. This link is expired, already used, or unknown. | |
| 80 | </Alert> | |
| 81 | <p class="auth-switch" style="margin-top:16px"><a href="/forgot-password">Request a new one</a></p> | |
| 82 | <p class="auth-switch"><a href="/login">Back to sign in</a></p> | |
| 83 | </div> | |
| 84 | </Layout> | |
| 85 | ); | |
| 86 | } | |
| 87 | ||
| 88 | passwordReset.get("/reset-password", softAuth, async (c) => { | |
| 89 | const token = String(c.req.query("token") || "").trim(); | |
| 90 | const csrf = c.get("csrfToken") as string | undefined; | |
| 91 | const error = c.req.query("error"); | |
| 92 | ||
| 93 | if (!token) return c.html(<InvalidLinkPage user={c.get("user")} />); | |
| 94 | const check = await inspectResetToken(token); | |
| 95 | if (!check.valid) return c.html(<InvalidLinkPage user={c.get("user")} />); | |
| 96 | ||
| 97 | return c.html( | |
| 98 | <Layout title="Set a new password" user={c.get("user") ?? null}> | |
| 99 | <div class="auth-container"> | |
| 100 | <h2>Set a new password</h2> | |
| 101 | {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>} | |
| 102 | <p class="auth-switch" style="margin-bottom:16px;margin-top:0"> | |
| 103 | <Text>Choose a new password — at least 8 characters. Signing in from other devices will be required afterwards.</Text> | |
| 104 | </p> | |
| 105 | <Form method="post" action="/reset-password" csrfToken={csrf}> | |
| 106 | <input type="hidden" name="token" value={token} /> | |
| 107 | <FormGroup label="New password" htmlFor="password"> | |
| 108 | <Input type="password" name="password" required minLength={8} placeholder="Min 8 characters" autocomplete="new-password" aria-label="New password" /> | |
| 109 | </FormGroup> | |
| 110 | <FormGroup label="Confirm new password" htmlFor="confirm"> | |
| 111 | <Input type="password" name="confirm" required minLength={8} placeholder="Re-enter the new password" autocomplete="new-password" aria-label="Confirm new password" /> | |
| 112 | </FormGroup> | |
| 113 | <Button type="submit" variant="primary">Update password</Button> | |
| 114 | </Form> | |
| 115 | <p class="auth-switch"><a href="/login">Cancel</a></p> | |
| 116 | </div> | |
| 117 | </Layout> | |
| 118 | ); | |
| 119 | }); | |
| 120 | ||
| 121 | passwordReset.post("/reset-password", async (c) => { | |
| 122 | const body = await c.req.parseBody(); | |
| 123 | const token = String(body.token || "").trim(); | |
| 124 | const password = String(body.password || ""); | |
| 125 | const confirm = String(body.confirm || ""); | |
| 126 | ||
| 127 | const back = (msg: string) => | |
| 128 | c.redirect(`/reset-password?token=${encodeURIComponent(token)}&error=${encodeURIComponent(msg)}`); | |
| 129 | ||
| 130 | if (!token) return c.html(<InvalidLinkPage user={null} />); | |
| 131 | if (!password || password.length < 8) return back("Password must be at least 8 characters"); | |
| 132 | if (password !== confirm) return back("Passwords do not match"); | |
| 133 | ||
| 134 | const result = await consumeResetToken(token, password); | |
| 135 | if (!result.ok) { | |
| 136 | if (result.reason === "weak") return back("Password must be at least 8 characters"); | |
| 137 | return c.html(<InvalidLinkPage user={null} />); | |
| 138 | } | |
| 139 | ||
| 140 | return c.redirect("/login?success=" + encodeURIComponent("Password updated — please sign in")); | |
| 141 | }); | |
| 142 | ||
| 143 | export default passwordReset; |