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

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.

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