Blame · Line-by-line history
email-verification.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 P2 — email verification routes. | |
| 3 | * | |
| 4 | * GET /verify-email?token=… Consume a token. On success: 302 to | |
| 5 | * /dashboard?verified=1 and fire-and-forget | |
| 6 | * the welcome email. On failure: render a | |
| 7 | * "link expired" page. | |
| 8 | * POST /verify-email/resend requireAuth. Issues a fresh verification | |
| 9 | * token. Rate-limited per user (3/hour). | |
| 48c9efc | 10 | * |
| 11 | * 2026 polish: the dead-link page renders inside the shared `.auth-container` | |
| 12 | * gateway with a display headline, supporting subtitle, an Alert banner that | |
| 13 | * matches the rest of the auth surface, and an explicit "what to do next" | |
| 14 | * block — plus a resend form so the user can recover in-place if they're | |
| 15 | * already signed in (the heavy lifting still happens server-side in the | |
| 16 | * existing POST handler; the form is just the polished trigger). | |
| c63b860 | 17 | */ |
| 18 | ||
| 19 | import { Hono } from "hono"; | |
| 20 | import { eq } from "drizzle-orm"; | |
| 21 | import { db } from "../db"; | |
| 22 | import { users } from "../db/schema"; | |
| 23 | import { Layout } from "../views/layout"; | |
| 48c9efc | 24 | import { Alert, Text } from "../views/ui"; |
| c63b860 | 25 | import { softAuth, requireAuth } from "../middleware/auth"; |
| 26 | import type { AuthEnv } from "../middleware/auth"; | |
| 826eccf | 27 | import { config } from "../lib/config"; |
| c63b860 | 28 | import { |
| 29 | consumeVerificationToken, | |
| 30 | startEmailVerification, | |
| 31 | sendWelcomeEmail, | |
| 32 | } from "../lib/email-verification"; | |
| 33 | ||
| 34 | const verify = new Hono<AuthEnv>(); | |
| 35 | ||
| 36 | const RESEND_LIMIT = 3; | |
| 37 | const RESEND_WINDOW_MS = 60 * 60 * 1000; | |
| 38 | const _resendLog: Map<string, number[]> = new Map(); | |
| 39 | ||
| 40 | function checkResendRate(userId: string): { allowed: boolean; remaining: number } { | |
| 41 | const now = Date.now(); | |
| 42 | const cutoff = now - RESEND_WINDOW_MS; | |
| 43 | const recent = (_resendLog.get(userId) || []).filter((t) => t > cutoff); | |
| 44 | if (recent.length >= RESEND_LIMIT) { | |
| 45 | _resendLog.set(userId, recent); | |
| 46 | return { allowed: false, remaining: 0 }; | |
| 47 | } | |
| 48 | recent.push(now); | |
| 49 | _resendLog.set(userId, recent); | |
| 50 | return { allowed: true, remaining: RESEND_LIMIT - recent.length }; | |
| 51 | } | |
| 52 | ||
| 53 | /** Test-only: wipe the in-memory rate-limit counters. */ | |
| 54 | export function __resetResendRateLimitForTests(): void { | |
| 55 | _resendLog.clear(); | |
| 56 | } | |
| 57 | ||
| 48c9efc | 58 | // --------------------------------------------------------------------------- |
| 59 | // Per-page CSS — `.auth-extra-ev-*` so it can never collide with the | |
| 60 | // locked .auth-container rules in layout.tsx. | |
| 61 | // --------------------------------------------------------------------------- | |
| 62 | function VerifyExtraStyles() { | |
| 63 | return ( | |
| 64 | <style | |
| 65 | dangerouslySetInnerHTML={{ | |
| 66 | __html: ` | |
| 67 | .auth-extra-ev-headline { | |
| 68 | font-family: var(--font-display); | |
| 69 | font-weight: 700; | |
| 70 | font-size: clamp(28px, 4.6vw, 40px); | |
| 71 | line-height: 1.08; | |
| 72 | letter-spacing: -0.028em; | |
| 73 | color: var(--text-strong); | |
| 74 | margin: 0 0 10px; | |
| 75 | } | |
| 76 | .auth-extra-ev-sub { | |
| 77 | color: var(--text-muted); | |
| 78 | font-size: 14.5px; | |
| 79 | line-height: 1.55; | |
| 80 | margin: 0 0 22px; | |
| 81 | } | |
| 82 | .auth-extra-ev-next { | |
| 83 | margin-top: 14px; | |
| 84 | padding: 12px 14px; | |
| 85 | background: var(--bg-tertiary, var(--bg-secondary)); | |
| 86 | border: 1px solid var(--border); | |
| 87 | border-radius: var(--r-sm, 6px); | |
| 88 | color: var(--text-muted); | |
| 89 | font-size: 13px; | |
| 90 | line-height: 1.55; | |
| 91 | } | |
| 92 | .auth-extra-ev-next strong { color: var(--text); font-weight: 600; } | |
| 93 | .auth-extra-ev-meta { | |
| 94 | color: var(--text-muted); | |
| 95 | font-size: 13px; | |
| 96 | line-height: 1.55; | |
| 97 | margin: 6px 0 0; | |
| 98 | text-align: center; | |
| 99 | } | |
| 100 | .auth-extra-ev-meta code { | |
| 101 | font-family: var(--font-mono); | |
| 102 | font-size: 12px; | |
| 103 | background: var(--bg-tertiary, var(--bg-secondary)); | |
| 104 | padding: 1px 6px; | |
| 105 | border-radius: 3px; | |
| 106 | } | |
| 107 | .auth-extra-ev-resend { | |
| 108 | margin-top: 16px; | |
| 109 | display: flex; | |
| 110 | justify-content: center; | |
| 111 | } | |
| 112 | .auth-extra-ev-resend button { | |
| 113 | width: 100%; | |
| 114 | padding: 11px 16px; | |
| 115 | font-size: 14.5px; | |
| 116 | font-weight: 600; | |
| 117 | } | |
| 118 | .auth-extra-ev-resend button[aria-busy="true"] { | |
| 119 | opacity: 0.78; | |
| 120 | cursor: progress; | |
| 121 | pointer-events: none; | |
| 122 | } | |
| 123 | .auth-extra-ev-resend button[aria-busy="true"]::after { | |
| 124 | content: ''; | |
| 125 | display: inline-block; | |
| 126 | width: 12px; | |
| 127 | height: 12px; | |
| 128 | margin-left: 8px; | |
| 129 | vertical-align: -2px; | |
| 130 | border: 2px solid currentColor; | |
| 131 | border-right-color: transparent; | |
| 132 | border-radius: 50%; | |
| 133 | animation: auth-extra-ev-spin 0.7s linear infinite; | |
| 134 | } | |
| 135 | @keyframes auth-extra-ev-spin { | |
| 136 | to { transform: rotate(360deg); } | |
| 137 | } | |
| 138 | `, | |
| 139 | }} | |
| 140 | /> | |
| 141 | ); | |
| 142 | } | |
| 143 | ||
| 144 | function VerifySubmitBusyScript() { | |
| 145 | return ( | |
| 146 | <script | |
| 147 | dangerouslySetInnerHTML={{ | |
| 148 | __html: /* js */ ` | |
| 149 | (function () { | |
| 150 | try { | |
| 151 | var forms = document.querySelectorAll('form[data-auth-extra-ev]'); | |
| 152 | forms.forEach(function (f) { | |
| 153 | f.addEventListener('submit', function () { | |
| 154 | var btn = f.querySelector('button[type=submit]'); | |
| 155 | if (btn) btn.setAttribute('aria-busy', 'true'); | |
| 156 | }); | |
| 157 | }); | |
| 158 | } catch (e) { /* no-op */ } | |
| 159 | })(); | |
| 160 | `, | |
| 161 | }} | |
| 162 | /> | |
| 163 | ); | |
| 164 | } | |
| 165 | ||
| c63b860 | 166 | verify.get("/verify-email", softAuth, async (c) => { |
| 167 | const token = c.req.query("token") || ""; | |
| 168 | const user = c.get("user") || null; | |
| 169 | const result = await consumeVerificationToken(token); | |
| 170 | ||
| 171 | if (result.ok && result.userId) { | |
| 172 | void sendWelcomeEmail(result.userId); | |
| 173 | return c.redirect("/dashboard?verified=1"); | |
| 174 | } | |
| 175 | ||
| 48c9efc | 176 | const csrf = c.get("csrfToken") as string | undefined; |
| 177 | const userEmail = (user as any)?.email as string | undefined; | |
| 178 | ||
| c63b860 | 179 | return c.html( |
| 180 | <Layout title="Verification link expired" user={user}> | |
| 181 | <div class="auth-container"> | |
| 48c9efc | 182 | <VerifyExtraStyles /> |
| 183 | <h2 class="auth-extra-ev-headline">Link expired</h2> | |
| 184 | <p class="auth-extra-ev-sub"> | |
| 185 | That email-verification link is no longer valid. Verification | |
| 186 | links live for <strong>24 hours</strong> and can only be used once. | |
| c63b860 | 187 | </p> |
| 48c9efc | 188 | <Alert variant="error"> |
| 189 | Reset your verification by requesting a fresh link — the one you | |
| 190 | clicked is expired, already used, or unknown. | |
| 191 | </Alert> | |
| 192 | <div class="auth-extra-ev-next"> | |
| 193 | <strong>What to do next:</strong>{" "} | |
| 194 | {user | |
| 195 | ? "tap Resend below and we'll dispatch a brand-new link to your account email — usually arrives in under a minute." | |
| 196 | : "sign in to your account first, then request a fresh verification link from your dashboard."} | |
| 197 | </div> | |
| 198 | {user && userEmail && ( | |
| 199 | <p class="auth-extra-ev-meta"> | |
| 200 | We'll send the new link to <code>{userEmail}</code>. | |
| 201 | </p> | |
| 202 | )} | |
| 203 | {user ? ( | |
| 204 | <form | |
| 205 | method="post" | |
| 206 | action="/verify-email/resend" | |
| 207 | class="auth-extra-ev-resend" | |
| 208 | data-auth-extra-ev="1" | |
| 209 | > | |
| 210 | {csrf && <input type="hidden" name="_csrf" value={csrf} />} | |
| 211 | <button type="submit" class="btn btn-primary"> | |
| 212 | Resend verification email | |
| 213 | </button> | |
| 214 | </form> | |
| 215 | ) : ( | |
| 216 | <p class="auth-switch" style="margin-top:18px"> | |
| 217 | <a href="/login">Sign in</a> | |
| 218 | </p> | |
| 219 | )} | |
| 220 | <p class="auth-switch"> | |
| 221 | <Text> | |
| 222 | Didn't expect this email?{" "} | |
| 223 | <a href="/login">Back to sign in</a>. | |
| 224 | </Text> | |
| c63b860 | 225 | </p> |
| 48c9efc | 226 | <VerifySubmitBusyScript /> |
| c63b860 | 227 | </div> |
| 228 | </Layout> | |
| 229 | ); | |
| 230 | }); | |
| 231 | ||
| 232 | verify.post("/verify-email/resend", requireAuth, async (c) => { | |
| 233 | const user = c.get("user"); | |
| 234 | if (!user) return c.redirect("/login"); | |
| 235 | ||
| 236 | let email = user.email; | |
| 237 | let verifiedAt: Date | null = (user as any).emailVerifiedAt | |
| 238 | ? new Date((user as any).emailVerifiedAt as string | Date) | |
| 239 | : null; | |
| 240 | try { | |
| 241 | const [fresh] = await db | |
| 242 | .select({ | |
| 243 | email: users.email, | |
| 244 | emailVerifiedAt: users.emailVerifiedAt, | |
| 245 | }) | |
| 246 | .from(users) | |
| 247 | .where(eq(users.id, user.id)) | |
| 248 | .limit(1); | |
| 249 | if (fresh) { | |
| 250 | email = fresh.email; | |
| 251 | verifiedAt = fresh.emailVerifiedAt | |
| 252 | ? new Date(fresh.emailVerifiedAt as unknown as string | Date) | |
| 253 | : null; | |
| 254 | } | |
| 255 | } catch { | |
| 256 | // best effort | |
| 257 | } | |
| 258 | ||
| 259 | if (verifiedAt) { | |
| 260 | return c.redirect("/dashboard?verified=1"); | |
| 261 | } | |
| 262 | ||
| 263 | const rate = checkResendRate(user.id); | |
| 264 | if (!rate.allowed) { | |
| 265 | return c.redirect("/dashboard?verify=rate_limited"); | |
| 266 | } | |
| 267 | ||
| 826eccf | 268 | // If the operator hasn't wired a real email provider, fire the verification |
| 269 | // anyway (it still writes a row + logs to stderr so the admin can grab the | |
| 270 | // token), but redirect to a state the banner can describe honestly. Lying | |
| 271 | // "Sent! Check your inbox" when the inbox will never receive it is the | |
| 272 | // exact frustration the user flagged. | |
| c63b860 | 273 | void startEmailVerification(user.id, email); |
| 826eccf | 274 | if (config.emailProvider !== "resend" || !config.resendApiKey) { |
| 275 | return c.redirect("/dashboard?verify=not_configured"); | |
| 276 | } | |
| c63b860 | 277 | return c.redirect("/dashboard?verify=sent"); |
| 278 | }); | |
| 279 | ||
| 280 | export default verify; |