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.tsxBlame280 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).
48c9efcClaude10 *
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).
c63b860Claude17 */
18
19import { Hono } from "hono";
20import { eq } from "drizzle-orm";
21import { db } from "../db";
22import { users } from "../db/schema";
23import { Layout } from "../views/layout";
48c9efcClaude24import { Alert, Text } from "../views/ui";
c63b860Claude25import { softAuth, requireAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
826eccfTest User27import { config } from "../lib/config";
c63b860Claude28import {
29 consumeVerificationToken,
30 startEmailVerification,
31 sendWelcomeEmail,
32} from "../lib/email-verification";
33
34const verify = new Hono<AuthEnv>();
35
36const RESEND_LIMIT = 3;
37const RESEND_WINDOW_MS = 60 * 60 * 1000;
38const _resendLog: Map<string, number[]> = new Map();
39
40function 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. */
54export function __resetResendRateLimitForTests(): void {
55 _resendLog.clear();
56}
57
48c9efcClaude58// ---------------------------------------------------------------------------
59// Per-page CSS — `.auth-extra-ev-*` so it can never collide with the
60// locked .auth-container rules in layout.tsx.
61// ---------------------------------------------------------------------------
62function 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
144function 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
c63b860Claude166verify.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
48c9efcClaude176 const csrf = c.get("csrfToken") as string | undefined;
177 const userEmail = (user as any)?.email as string | undefined;
178
c63b860Claude179 return c.html(
180 <Layout title="Verification link expired" user={user}>
181 <div class="auth-container">
48c9efcClaude182 <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.
c63b860Claude187 </p>
48c9efcClaude188 <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>
c63b860Claude225 </p>
48c9efcClaude226 <VerifySubmitBusyScript />
c63b860Claude227 </div>
228 </Layout>
229 );
230});
231
232verify.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
826eccfTest User268 // 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.
c63b860Claude273 void startEmailVerification(user.id, email);
826eccfTest User274 if (config.emailProvider !== "resend" || !config.resendApiKey) {
275 return c.redirect("/dashboard?verify=not_configured");
276 }
c63b860Claude277 return c.redirect("/dashboard?verify=sent");
278});
279
280export default verify;