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.tsxBlame759 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
7581253Claude236 // If the caller posted from /settings/email-verification, send them back
237 // there instead of the dashboard so the banner shows next to the page they
238 // were on. Pure cosmetic — the underlying flow is unchanged.
239 const referrer = c.req.header("referer") || "";
240 const fromSettings = referrer.includes("/settings/email-verification");
241
c63b860Claude242 let email = user.email;
243 let verifiedAt: Date | null = (user as any).emailVerifiedAt
244 ? new Date((user as any).emailVerifiedAt as string | Date)
245 : null;
246 try {
247 const [fresh] = await db
248 .select({
249 email: users.email,
250 emailVerifiedAt: users.emailVerifiedAt,
251 })
252 .from(users)
253 .where(eq(users.id, user.id))
254 .limit(1);
255 if (fresh) {
256 email = fresh.email;
257 verifiedAt = fresh.emailVerifiedAt
258 ? new Date(fresh.emailVerifiedAt as unknown as string | Date)
259 : null;
260 }
261 } catch {
262 // best effort
263 }
264
265 if (verifiedAt) {
7581253Claude266 return c.redirect(
267 fromSettings ? "/settings/email-verification?verified=1" : "/dashboard?verified=1"
268 );
c63b860Claude269 }
270
271 const rate = checkResendRate(user.id);
272 if (!rate.allowed) {
7581253Claude273 return c.redirect(
274 fromSettings
275 ? "/settings/email-verification?verify=rate_limited"
276 : "/dashboard?verify=rate_limited"
277 );
c63b860Claude278 }
279
826eccfTest User280 // If the operator hasn't wired a real email provider, fire the verification
281 // anyway (it still writes a row + logs to stderr so the admin can grab the
282 // token), but redirect to a state the banner can describe honestly. Lying
283 // "Sent! Check your inbox" when the inbox will never receive it is the
284 // exact frustration the user flagged.
c63b860Claude285 void startEmailVerification(user.id, email);
826eccfTest User286 if (config.emailProvider !== "resend" || !config.resendApiKey) {
7581253Claude287 return c.redirect(
288 fromSettings
289 ? "/settings/email-verification?verify=not_configured"
290 : "/dashboard?verify=not_configured"
291 );
292 }
293 return c.redirect(
294 fromSettings ? "/settings/email-verification?verify=sent" : "/dashboard?verify=sent"
295 );
296});
297
298// ─── Scoped CSS (.ev-*) — settings page polish ─────────────────────────────
299// Every selector prefixed `.ev-*` so this surface can't bleed into the
300// auth-extra-ev-* styles used by the dead-link page above, or any other
301// page. Mirrors the gradient-hairline hero + card pattern from
302// settings-2fa.tsx and admin-integrations.tsx.
303const evStyles = `
eed4684Claude304 .ev-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
7581253Claude305
306 .ev-hero {
307 position: relative;
308 margin-bottom: var(--space-5);
309 padding: var(--space-5) var(--space-6);
310 background: var(--bg-elevated);
311 border: 1px solid var(--border);
312 border-radius: 16px;
313 overflow: hidden;
314 }
315 .ev-hero::before {
316 content: '';
317 position: absolute;
318 top: 0; left: 0; right: 0;
319 height: 2px;
320 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
321 opacity: 0.7;
322 pointer-events: none;
323 }
324 .ev-hero-orb {
325 position: absolute;
326 inset: -20% -10% auto auto;
327 width: 380px; height: 380px;
328 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
329 filter: blur(80px);
330 opacity: 0.7;
331 pointer-events: none;
332 z-index: 0;
333 }
334 .ev-hero-inner { position: relative; z-index: 1; max-width: 720px; }
335 .ev-eyebrow {
336 font-size: 12px;
337 color: var(--text-muted);
338 margin-bottom: var(--space-2);
339 letter-spacing: 0.02em;
340 display: inline-flex;
341 align-items: center;
342 gap: 8px;
343 text-transform: uppercase;
344 font-family: var(--font-mono);
345 font-weight: 600;
346 }
347 .ev-eyebrow-pill {
348 display: inline-flex;
349 align-items: center;
350 justify-content: center;
351 width: 18px; height: 18px;
352 border-radius: 6px;
353 background: rgba(140,109,255,0.14);
354 color: #b69dff;
355 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
356 }
357 .ev-crumb { color: var(--text-muted); text-decoration: none; }
358 .ev-crumb:hover { color: var(--text); }
359 .ev-title {
360 font-size: clamp(28px, 4vw, 40px);
361 font-family: var(--font-display);
362 font-weight: 800;
363 letter-spacing: -0.028em;
364 line-height: 1.05;
365 margin: 0 0 var(--space-2);
366 color: var(--text-strong);
367 }
368 .ev-title-grad {
369 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
370 -webkit-background-clip: text;
371 background-clip: text;
372 -webkit-text-fill-color: transparent;
373 color: transparent;
374 }
375 .ev-sub {
376 font-size: 15px;
377 color: var(--text-muted);
378 margin: 0;
379 line-height: 1.55;
380 max-width: 620px;
381 }
382
383 /* ─── Banner ─── */
384 .ev-banner {
385 margin-bottom: var(--space-4);
386 padding: 10px 14px;
387 border-radius: 10px;
388 font-size: 13.5px;
389 border: 1px solid var(--border);
390 background: rgba(255,255,255,0.025);
391 color: var(--text);
392 display: flex;
393 align-items: center;
394 gap: 10px;
395 }
396 .ev-banner.is-ok {
397 border-color: rgba(52,211,153,0.40);
398 background: rgba(52,211,153,0.08);
399 color: #bbf7d0;
400 }
401 .ev-banner.is-warn {
402 border-color: rgba(251,191,36,0.40);
403 background: rgba(251,191,36,0.08);
404 color: #fde68a;
405 }
406 .ev-banner.is-error {
407 border-color: rgba(248,113,113,0.40);
408 background: rgba(248,113,113,0.08);
409 color: #fecaca;
410 }
411 .ev-banner-dot {
412 width: 8px; height: 8px;
413 border-radius: 9999px;
414 background: currentColor;
415 flex-shrink: 0;
416 }
417
418 /* ─── Status card ─── */
419 .ev-status {
420 position: relative;
421 margin-bottom: var(--space-5);
422 padding: var(--space-5);
423 background: var(--bg-elevated);
424 border: 1px solid var(--border);
425 border-radius: 14px;
426 display: flex;
427 align-items: center;
428 gap: var(--space-4);
429 flex-wrap: wrap;
430 overflow: hidden;
826eccfTest User431 }
7581253Claude432 .ev-status.is-on {
433 border-color: rgba(52,211,153,0.32);
434 background: linear-gradient(135deg, rgba(52,211,153,0.08) 0%, rgba(15,17,26,0) 60%), var(--bg-elevated);
435 }
436 .ev-status.is-off {
437 border-color: rgba(251,191,36,0.32);
438 background: linear-gradient(135deg, rgba(251,191,36,0.06) 0%, rgba(15,17,26,0) 60%), var(--bg-elevated);
439 }
440 .ev-status-mark {
441 flex-shrink: 0;
442 width: 56px; height: 56px;
443 border-radius: 14px;
444 display: flex;
445 align-items: center;
446 justify-content: center;
447 color: #fff;
448 }
449 .ev-status-mark.is-on {
450 background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
451 box-shadow: 0 8px 20px -8px rgba(16,185,129,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
452 }
453 .ev-status-mark.is-off {
454 background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
455 color: #1a1206;
456 box-shadow: 0 8px 20px -8px rgba(251,191,36,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
457 }
458 .ev-status-text { flex: 1; min-width: 220px; }
459 .ev-status-headline {
460 margin: 0 0 4px;
461 font-family: var(--font-display);
462 font-size: 19px;
463 font-weight: 700;
464 letter-spacing: -0.018em;
465 color: var(--text-strong);
466 }
467 .ev-status-desc {
468 margin: 0;
469 font-size: 13.5px;
470 color: var(--text-muted);
471 line-height: 1.5;
472 }
473 .ev-status-desc code {
474 font-family: var(--font-mono);
475 font-size: 12px;
476 background: rgba(255,255,255,0.04);
477 border: 1px solid var(--border);
478 padding: 2px 7px;
479 border-radius: 5px;
480 color: var(--text);
481 }
482
483 /* ─── Section card ─── */
484 .ev-section {
485 margin-bottom: var(--space-5);
486 background: var(--bg-elevated);
487 border: 1px solid var(--border);
488 border-radius: 14px;
489 overflow: hidden;
490 }
491 .ev-section-head {
492 padding: var(--space-4) var(--space-5);
493 border-bottom: 1px solid var(--border);
494 }
495 .ev-section-title {
496 margin: 0;
497 font-family: var(--font-display);
498 font-size: 17px;
499 font-weight: 700;
500 letter-spacing: -0.018em;
501 color: var(--text-strong);
502 display: flex;
503 align-items: center;
504 gap: 10px;
505 }
506 .ev-section-title-icon {
507 display: inline-flex;
508 align-items: center;
509 justify-content: center;
510 width: 26px; height: 26px;
511 border-radius: 8px;
512 background: rgba(140,109,255,0.12);
513 color: #b69dff;
514 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
515 flex-shrink: 0;
516 }
517 .ev-section-sub {
518 margin: 6px 0 0 36px;
519 font-size: 12.5px;
520 color: var(--text-muted);
521 line-height: 1.5;
522 }
523 .ev-section-body { padding: var(--space-4) var(--space-5); }
524
525 /* ─── Buttons ─── */
526 .ev-btn {
527 display: inline-flex;
528 align-items: center;
529 justify-content: center;
530 gap: 6px;
531 padding: 10px 18px;
532 border-radius: 10px;
533 font-size: 13.5px;
534 font-weight: 600;
535 text-decoration: none;
536 border: 1px solid transparent;
537 cursor: pointer;
538 font-family: inherit;
539 line-height: 1;
540 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
541 }
542 .ev-btn-primary {
543 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
544 color: #fff;
545 box-shadow: 0 6px 18px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
546 }
547 .ev-btn-primary:hover {
548 transform: translateY(-1px);
549 box-shadow: 0 10px 24px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
550 color: #fff;
551 text-decoration: none;
552 }
553 .ev-btn-ghost {
554 background: rgba(255,255,255,0.025);
555 color: var(--text);
556 border-color: var(--border-strong);
557 }
558 .ev-btn-ghost:hover {
559 background: rgba(140,109,255,0.06);
560 border-color: rgba(140,109,255,0.45);
561 color: var(--text-strong);
562 text-decoration: none;
563 }
564
565 .ev-meta {
566 margin-top: var(--space-3);
567 padding: 12px 14px;
568 background: var(--bg);
569 border: 1px solid var(--border-strong);
570 border-radius: 10px;
571 font-size: 13px;
572 color: var(--text-muted);
573 line-height: 1.55;
574 }
575 .ev-meta strong { color: var(--text-strong); }
576 .ev-meta code {
577 font-family: var(--font-mono);
578 font-size: 12.5px;
579 color: var(--text);
580 }
581`;
582
583const EvIconShield = () => (
584 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
585 <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
586 <polyline points="22 4 12 14.01 9 11.01" />
587 </svg>
588);
589const EvCheckIcon = () => (
590 <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
591 <polyline points="20 6 9 17 4 12" />
592 </svg>
593);
594const EvWarnIcon = () => (
595 <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
596 <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
597 <line x1="12" y1="9" x2="12" y2="13" />
598 <line x1="12" y1="17" x2="12.01" y2="17" />
599 </svg>
600);
601
602// ─── /settings/email-verification — verification status + resend ───────────
603// Visual sibling to /settings/2fa: status hero, single action card. The
604// underlying resend flow points at the existing POST /verify-email/resend
605// handler so all rate-limit + email-dispatch logic is unchanged.
606verify.get("/settings/email-verification", requireAuth, async (c) => {
607 const user = c.get("user")!;
608 const csrf = c.get("csrfToken") as string | undefined;
609
610 // Always read fresh from DB so a stale session doesn't show an outdated
611 // unverified state right after the user clicks the verification link.
612 let email = user.email;
613 let verifiedAt: Date | null = (user as any).emailVerifiedAt
614 ? new Date((user as any).emailVerifiedAt as string | Date)
615 : null;
616 try {
617 const [fresh] = await db
618 .select({
619 email: users.email,
620 emailVerifiedAt: users.emailVerifiedAt,
621 })
622 .from(users)
623 .where(eq(users.id, user.id))
624 .limit(1);
625 if (fresh) {
626 email = fresh.email;
627 verifiedAt = fresh.emailVerifiedAt
628 ? new Date(fresh.emailVerifiedAt as unknown as string | Date)
629 : null;
630 }
631 } catch {
632 /* best effort */
633 }
634
635 const verified = !!verifiedAt;
636 const flag = c.req.query("verify") || (c.req.query("verified") ? "ok" : "");
637 let banner: { kind: "ok" | "warn" | "error"; text: string } | null = null;
638 if (flag === "sent") {
639 banner = { kind: "ok", text: "Verification email dispatched. Check your inbox — it usually lands in under a minute." };
640 } else if (flag === "not_configured") {
641 banner = { kind: "warn", text: "Verification token issued but no email provider is configured. The site admin can fetch the link from server logs." };
642 } else if (flag === "rate_limited") {
643 banner = { kind: "error", text: "Hold up — you've already requested a few links in the last hour. Try again later." };
644 } else if (flag === "ok" || flag === "1") {
645 banner = { kind: "ok", text: "Your email is verified." };
646 }
647
648 const providerWired = config.emailProvider === "resend" && !!config.resendApiKey;
649
650 return c.html(
651 <Layout title="Email verification" user={user}>
652 <div class="ev-wrap">
653 <section class="ev-hero">
654 <div class="ev-hero-orb" aria-hidden="true" />
655 <div class="ev-hero-inner">
656 <div class="ev-eyebrow">
657 <span class="ev-eyebrow-pill" aria-hidden="true">
658 <EvIconShield />
659 </span>
660 <a href="/settings" class="ev-crumb">Settings</a>
661 <span>/</span>
662 <span>Email verification</span>
663 </div>
664 <h2 class="ev-title">
665 <span class="ev-title-grad">Email verification.</span>
666 </h2>
667 <p class="ev-sub">
668 Confirms you own the email on file. Required for password resets,
669 security alerts, and (soon) repo transfers. Links live for
670 24 hours and can only be used once.
671 </p>
672 </div>
673 </section>
674
675 {banner && (
676 <div class={`ev-banner is-${banner.kind}`} role={banner.kind === "error" ? "alert" : "status"}>
677 <span class="ev-banner-dot" aria-hidden="true" />
678 {banner.text}
679 </div>
680 )}
681
682 {verified ? (
683 <section class="ev-status is-on" aria-label="Email verification status">
684 <div class="ev-status-mark is-on" aria-hidden="true">
685 <EvCheckIcon />
686 </div>
687 <div class="ev-status-text">
688 <h3 class="ev-status-headline">Email verified</h3>
689 <p class="ev-status-desc">
690 <code>{email}</code> is confirmed
691 {verifiedAt ? ` since ${verifiedAt.toLocaleDateString()}` : ""}.
692 You're all set.
693 </p>
694 </div>
695 </section>
696 ) : (
697 <section class="ev-status is-off" aria-label="Email verification status">
698 <div class="ev-status-mark is-off" aria-hidden="true">
699 <EvWarnIcon />
700 </div>
701 <div class="ev-status-text">
702 <h3 class="ev-status-headline">Email not yet verified</h3>
703 <p class="ev-status-desc">
704 Send a fresh verification link to <code>{email}</code>. The
705 link expires after 24 hours; you can request up to three per
706 hour.
707 </p>
708 </div>
709 </section>
710 )}
711
712 {!verified && (
713 <section class="ev-section">
714 <header class="ev-section-head">
715 <h3 class="ev-section-title">
716 <span class="ev-section-title-icon" aria-hidden="true">
717 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
718 <path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
719 <polyline points="22,6 12,13 2,6" />
720 </svg>
721 </span>
722 Send a verification link
723 </h3>
724 <p class="ev-section-sub">
725 Tap the button below to dispatch a brand-new link. We'll send
726 it to your account email on file.
727 </p>
728 </header>
729 <div class="ev-section-body">
730 <form method="post" action="/verify-email/resend" style="margin:0">
731 {csrf && <input type="hidden" name="_csrf" value={csrf} />}
732 <button type="submit" class="ev-btn ev-btn-primary">
733 Send verification email
734 </button>
735 </form>
736 {!providerWired && (
737 <div class="ev-meta">
738 <strong>Heads up:</strong> the site operator hasn't wired a
739 production email provider yet. The verification row will be
740 created, but the link won't actually be emailed — an admin
741 can grab it from <code>server logs</code> for testing.
742 </div>
743 )}
744 {providerWired && (
745 <div class="ev-meta">
746 Need to change the email address itself? Update it on{" "}
747 <a href="/settings">your profile settings</a>.
748 </div>
749 )}
750 </div>
751 </section>
752 )}
753 </div>
754 <style dangerouslySetInnerHTML={{ __html: evStyles }} />
755 </Layout>
756 );
c63b860Claude757});
758
759export default verify;