Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsxBlame125 lines · 2 contributors
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";
826eccfTest User19import { config } from "../lib/config";
c63b860Claude20import {
21 consumeVerificationToken,
22 startEmailVerification,
23 sendWelcomeEmail,
24} from "../lib/email-verification";
25
26const verify = new Hono<AuthEnv>();
27
28const RESEND_LIMIT = 3;
29const RESEND_WINDOW_MS = 60 * 60 * 1000;
30const _resendLog: Map<string, number[]> = new Map();
31
32function 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. */
46export function __resetResendRateLimitForTests(): void {
47 _resendLog.clear();
48}
49
50verify.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
77verify.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
826eccfTest User113 // 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.
c63b860Claude118 void startEmailVerification(user.id, email);
826eccfTest User119 if (config.emailProvider !== "resend" || !config.resendApiKey) {
120 return c.redirect("/dashboard?verify=not_configured");
121 }
c63b860Claude122 return c.redirect("/dashboard?verify=sent");
123});
124
125export default verify;