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). | |
| 10 | */ | |
| 11 | ||
| 12 | import { Hono } from "hono"; | |
| 13 | import { eq } from "drizzle-orm"; | |
| 14 | import { db } from "../db"; | |
| 15 | import { users } from "../db/schema"; | |
| 16 | import { Layout } from "../views/layout"; | |
| 17 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 18 | import type { AuthEnv } from "../middleware/auth"; | |
| 826eccf | 19 | import { config } from "../lib/config"; |
| c63b860 | 20 | import { |
| 21 | consumeVerificationToken, | |
| 22 | startEmailVerification, | |
| 23 | sendWelcomeEmail, | |
| 24 | } from "../lib/email-verification"; | |
| 25 | ||
| 26 | const verify = new Hono<AuthEnv>(); | |
| 27 | ||
| 28 | const RESEND_LIMIT = 3; | |
| 29 | const RESEND_WINDOW_MS = 60 * 60 * 1000; | |
| 30 | const _resendLog: Map<string, number[]> = new Map(); | |
| 31 | ||
| 32 | function checkResendRate(userId: string): { allowed: boolean; remaining: number } { | |
| 33 | const now = Date.now(); | |
| 34 | const cutoff = now - RESEND_WINDOW_MS; | |
| 35 | const recent = (_resendLog.get(userId) || []).filter((t) => t > cutoff); | |
| 36 | if (recent.length >= RESEND_LIMIT) { | |
| 37 | _resendLog.set(userId, recent); | |
| 38 | return { allowed: false, remaining: 0 }; | |
| 39 | } | |
| 40 | recent.push(now); | |
| 41 | _resendLog.set(userId, recent); | |
| 42 | return { allowed: true, remaining: RESEND_LIMIT - recent.length }; | |
| 43 | } | |
| 44 | ||
| 45 | /** Test-only: wipe the in-memory rate-limit counters. */ | |
| 46 | export function __resetResendRateLimitForTests(): void { | |
| 47 | _resendLog.clear(); | |
| 48 | } | |
| 49 | ||
| 50 | verify.get("/verify-email", softAuth, async (c) => { | |
| 51 | const token = c.req.query("token") || ""; | |
| 52 | const user = c.get("user") || null; | |
| 53 | const result = await consumeVerificationToken(token); | |
| 54 | ||
| 55 | if (result.ok && result.userId) { | |
| 56 | void sendWelcomeEmail(result.userId); | |
| 57 | return c.redirect("/dashboard?verified=1"); | |
| 58 | } | |
| 59 | ||
| 60 | return c.html( | |
| 61 | <Layout title="Verification link expired" user={user}> | |
| 62 | <div class="auth-container"> | |
| 63 | <h2>Link expired</h2> | |
| 64 | <p style="color:var(--text-muted);font-size:14px;line-height:1.55"> | |
| 65 | That verification link is no longer valid. Links expire after 24 | |
| 66 | hours and can only be used once. Sign in and request a fresh link | |
| 67 | from your dashboard. | |
| 68 | </p> | |
| 69 | <p class="auth-switch" style="margin-top:24px"> | |
| 70 | <a href="/login">Sign in</a> | |
| 71 | </p> | |
| 72 | </div> | |
| 73 | </Layout> | |
| 74 | ); | |
| 75 | }); | |
| 76 | ||
| 77 | verify.post("/verify-email/resend", requireAuth, async (c) => { | |
| 78 | const user = c.get("user"); | |
| 79 | if (!user) return c.redirect("/login"); | |
| 80 | ||
| 81 | let email = user.email; | |
| 82 | let verifiedAt: Date | null = (user as any).emailVerifiedAt | |
| 83 | ? new Date((user as any).emailVerifiedAt as string | Date) | |
| 84 | : null; | |
| 85 | try { | |
| 86 | const [fresh] = await db | |
| 87 | .select({ | |
| 88 | email: users.email, | |
| 89 | emailVerifiedAt: users.emailVerifiedAt, | |
| 90 | }) | |
| 91 | .from(users) | |
| 92 | .where(eq(users.id, user.id)) | |
| 93 | .limit(1); | |
| 94 | if (fresh) { | |
| 95 | email = fresh.email; | |
| 96 | verifiedAt = fresh.emailVerifiedAt | |
| 97 | ? new Date(fresh.emailVerifiedAt as unknown as string | Date) | |
| 98 | : null; | |
| 99 | } | |
| 100 | } catch { | |
| 101 | // best effort | |
| 102 | } | |
| 103 | ||
| 104 | if (verifiedAt) { | |
| 105 | return c.redirect("/dashboard?verified=1"); | |
| 106 | } | |
| 107 | ||
| 108 | const rate = checkResendRate(user.id); | |
| 109 | if (!rate.allowed) { | |
| 110 | return c.redirect("/dashboard?verify=rate_limited"); | |
| 111 | } | |
| 112 | ||
| 826eccf | 113 | // If the operator hasn't wired a real email provider, fire the verification |
| 114 | // anyway (it still writes a row + logs to stderr so the admin can grab the | |
| 115 | // token), but redirect to a state the banner can describe honestly. Lying | |
| 116 | // "Sent! Check your inbox" when the inbox will never receive it is the | |
| 117 | // exact frustration the user flagged. | |
| c63b860 | 118 | void startEmailVerification(user.id, email); |
| 826eccf | 119 | if (config.emailProvider !== "resend" || !config.resendApiKey) { |
| 120 | return c.redirect("/dashboard?verify=not_configured"); | |
| 121 | } | |
| c63b860 | 122 | return c.redirect("/dashboard?verify=sent"); |
| 123 | }); | |
| 124 | ||
| 125 | export default verify; |