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

magic-link.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.

magic-link.tsxBlame318 lines · 2 contributors
cd4f63bTest User1/**
2 * Block Q2 — Magic-link sign-in routes.
3 *
4 * GET /login/magic → email-entry form
5 * POST /login/magic → always redirects to ?sent=1
6 * GET /login/magic/callback?token=… → consume token, set session, redirect
7 *
8 * Structurally a sibling of `password-reset.tsx`. Differences:
9 * - The "callback" lands the user directly into a fresh session — there
10 * is no second form to fill in. We mint a session cookie and bounce
11 * to /dashboard (existing user) or /onboarding?welcome=1 (auto-created).
12 * - 15-minute TTL is messaged on the success/dead-link pages.
48c9efcClaude13 *
14 * 2026 polish: display-quality headlines, supporting subtitle, "what
15 * happens next" explainer, and loading-state submit so the surface
16 * matches the polished /login + /register pages. All form actions, POST
17 * handlers, redirects and token semantics are preserved exactly.
cd4f63bTest User18 */
19
20import { Hono } from "hono";
21import { setCookie } from "hono/cookie";
22import { db } from "../db";
23import { sessions } from "../db/schema";
24import {
25 generateSessionToken,
26 sessionCookieOptions,
27 sessionExpiry,
28} from "../lib/auth";
29import { Layout } from "../views/layout";
48c9efcClaude30import { Form, FormGroup, Input, Alert, Text } from "../views/ui";
cd4f63bTest User31import { softAuth } from "../middleware/auth";
32import type { AuthEnv } from "../middleware/auth";
33import {
34 startMagicLinkSignIn,
35 consumeMagicLinkToken,
36} from "../lib/magic-link";
37
38const magicLink = new Hono<AuthEnv>();
39
48c9efcClaude40// ---------------------------------------------------------------------------
41// Per-page CSS — `.auth-extra-ml-*` so it can never collide with the
42// locked .auth-container rules in layout.tsx or with the sibling
43// password-reset extras.
44// ---------------------------------------------------------------------------
45function MagicExtraStyles() {
46 return (
47 <style
48 dangerouslySetInnerHTML={{
49 __html: `
50 .auth-extra-ml-headline {
51 font-family: var(--font-display);
52 font-weight: 700;
53 font-size: clamp(28px, 4.6vw, 40px);
54 line-height: 1.08;
55 letter-spacing: -0.028em;
56 color: var(--text-strong);
57 margin: 0 0 10px;
58 }
59 .auth-extra-ml-sub {
60 color: var(--text-muted);
61 font-size: 14.5px;
62 line-height: 1.55;
63 margin: 0 0 22px;
64 }
65 .auth-extra-ml-next {
66 margin-top: 14px;
67 padding: 12px 14px;
68 background: var(--bg-tertiary, var(--bg-secondary));
69 border: 1px solid var(--border);
70 border-radius: var(--r-sm, 6px);
71 color: var(--text-muted);
72 font-size: 13px;
73 line-height: 1.55;
74 }
75 .auth-extra-ml-next strong { color: var(--text); font-weight: 600; }
76 .auth-extra-ml-steps {
77 margin: 0;
78 padding: 0 0 0 18px;
79 color: var(--text-muted);
80 font-size: 13px;
81 line-height: 1.65;
82 }
83 .auth-extra-ml-steps li { margin: 2px 0; }
84 .auth-extra-ml-submit {
85 width: 100%;
86 padding: 12px 16px;
87 font-size: 15px;
88 font-weight: 600;
89 margin-top: 4px;
90 }
91 .auth-extra-ml-submit[aria-busy="true"] {
92 opacity: 0.78;
93 cursor: progress;
94 pointer-events: none;
95 }
96 .auth-extra-ml-submit[aria-busy="true"]::after {
97 content: '';
98 display: inline-block;
99 width: 12px;
100 height: 12px;
101 margin-left: 8px;
102 vertical-align: -2px;
103 border: 2px solid currentColor;
104 border-right-color: transparent;
105 border-radius: 50%;
106 animation: auth-extra-ml-spin 0.7s linear infinite;
107 }
108 @keyframes auth-extra-ml-spin {
109 to { transform: rotate(360deg); }
110 }
111 `,
112 }}
113 />
114 );
115}
116
117function MagicSubmitBusyScript() {
118 return (
119 <script
120 dangerouslySetInnerHTML={{
121 __html: /* js */ `
122 (function () {
123 try {
124 var forms = document.querySelectorAll('form[data-auth-extra-ml]');
125 forms.forEach(function (f) {
126 f.addEventListener('submit', function () {
127 var btn = f.querySelector('.auth-extra-ml-submit');
128 if (btn) btn.setAttribute('aria-busy', 'true');
129 });
130 });
131 } catch (e) { /* no-op */ }
132 })();
133 `,
134 }}
135 />
136 );
137}
138
cd4f63bTest User139// ---------------------------------------------------------------------------
140// GET /login/magic — entry form + post-submit success.
141// ---------------------------------------------------------------------------
142
143magicLink.get("/login/magic", softAuth, (c) => {
144 const existing = c.get("user");
145 if (existing) return c.redirect("/dashboard");
146
147 const csrf = c.get("csrfToken") as string | undefined;
148 const sent = c.req.query("sent") === "1";
149
150 if (sent) {
151 return c.html(
152 <Layout title="Check your inbox" user={null}>
153 <div class="auth-container">
48c9efcClaude154 <MagicExtraStyles />
155 <h2 class="auth-extra-ml-headline">Check your inbox</h2>
156 <p class="auth-extra-ml-sub">
157 We just sent a one-time sign-in link to the address you entered.
158 It usually lands within a minute.
159 </p>
cd4f63bTest User160 <Alert variant="success">
161 If we can sign you in with that email, we've sent a link. It
162 expires in 15 minutes.
163 </Alert>
48c9efcClaude164 <div class="auth-extra-ml-next">
165 <strong>What happens next:</strong>
166 <ol class="auth-extra-ml-steps" style="margin-top:6px">
167 <li>Open the email titled "Your Gluecron sign-in link".</li>
168 <li>Click the button — you'll be signed in on this device.</li>
169 <li>The link expires in <strong>15 minutes</strong> and works only once.</li>
170 </ol>
171 </div>
cd4f63bTest User172 <p class="auth-switch">
173 <Text>
174 Didn't get it? Check your spam folder, or{" "}
175 <a href="/login/magic">try again</a>.
176 </Text>
177 </p>
178 <p class="auth-switch">
179 <a href="/login">Back to sign in</a>
180 </p>
181 </div>
182 </Layout>
183 );
184 }
185
186 return c.html(
187 <Layout title="Sign in with email link" user={null}>
188 <div class="auth-container">
48c9efcClaude189 <MagicExtraStyles />
190 <h2 class="auth-extra-ml-headline">Sign in with a magic link</h2>
191 <p class="auth-extra-ml-sub">
192 Drop your email below and we'll send you a one-time sign-in link.
193 No password to remember, no extra step.
cd4f63bTest User194 </p>
48c9efcClaude195 <Form
196 method="post"
197 action="/login/magic"
198 csrfToken={csrf}
199 class="auth-extra-ml-form"
200 >
cd4f63bTest User201 <FormGroup label="Email" htmlFor="email">
202 <Input
203 type="email"
204 name="email"
205 required
206 placeholder="you@example.com"
207 autocomplete="email"
208 aria-label="Email"
48c9efcClaude209 autofocus
cd4f63bTest User210 />
211 </FormGroup>
48c9efcClaude212 <button
213 type="submit"
214 class="btn btn-primary auth-extra-ml-submit"
215 data-loading-label="Sending link…"
216 >
cd4f63bTest User217 Send me a sign-in link
48c9efcClaude218 </button>
cd4f63bTest User219 </Form>
48c9efcClaude220 <div class="auth-extra-ml-next" style="margin-top:18px">
221 <strong>How it works:</strong> we email a link that signs you in
222 on this device when clicked. Links expire in 15 minutes and can
223 only be used once. New here? An account is created automatically
224 the first time you sign in.
225 </div>
cd4f63bTest User226 <p class="auth-switch">
227 <Text>
228 Prefer a password?{" "}
229 <a href="/login">Sign in the usual way</a>.
230 </Text>
231 </p>
48c9efcClaude232 <script
233 dangerouslySetInnerHTML={{
234 __html: `document.querySelectorAll('form').forEach(function(f){ f.setAttribute('data-auth-extra-ml', '1'); });`,
235 }}
236 />
237 <MagicSubmitBusyScript />
cd4f63bTest User238 </div>
239 </Layout>
240 );
241});
242
243// ---------------------------------------------------------------------------
244// POST /login/magic — always redirects to ?sent=1 (no enumeration).
245// ---------------------------------------------------------------------------
246
247magicLink.post("/login/magic", async (c) => {
248 const body = await c.req.parseBody();
249 const email = String(body.email || "").trim();
250 const ip =
251 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
252 c.req.header("x-real-ip") ||
253 undefined;
254 await startMagicLinkSignIn(email, { requestIp: ip });
255 return c.redirect("/login/magic?sent=1");
256});
257
258// ---------------------------------------------------------------------------
259// GET /login/magic/callback?token=… — consume the link.
260// ---------------------------------------------------------------------------
261
262function InvalidLinkPage(props: { user: any }) {
263 return (
264 <Layout title="Link no longer valid" user={props.user ?? null}>
265 <div class="auth-container">
48c9efcClaude266 <MagicExtraStyles />
267 <h2 class="auth-extra-ml-headline">This link is no longer valid</h2>
268 <p class="auth-extra-ml-sub">
269 Magic links are single-use and time-limited. The one you followed
270 is either expired, already redeemed, or unknown to us.
271 </p>
cd4f63bTest User272 <Alert variant="error">
273 Magic links expire after 15 minutes and can only be used once.
274 This link is expired, already used, or unknown.
275 </Alert>
276 <p class="auth-switch" style="margin-top:16px">
277 <a href="/login/magic">Send a fresh one</a>
278 </p>
279 <p class="auth-switch">
280 <a href="/login">Back to sign in</a>
281 </p>
282 </div>
283 </Layout>
284 );
285}
286
287magicLink.get("/login/magic/callback", softAuth, async (c) => {
288 const token = String(c.req.query("token") || "").trim();
289 if (!token) return c.html(<InvalidLinkPage user={c.get("user")} />);
290
291 const ip =
292 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
293 c.req.header("x-real-ip") ||
294 undefined;
295
296 const result = await consumeMagicLinkToken(token, { requestIp: ip });
297 if (!result.ok || !result.userId) {
298 return c.html(<InvalidLinkPage user={c.get("user")} />);
299 }
300
301 // Mint a fresh session for this user. We deliberately do NOT honour
302 // existing 2FA on magic-link sign-in here — the magic-link flow is
303 // explicitly for users who don't manage a password and 2FA enrollment
304 // requires a password in our current setup. If/when 2FA is decoupled
305 // from password auth (Q3+), this is the place to gate.
306 const sessionToken = generateSessionToken();
307 await db.insert(sessions).values({
308 userId: result.userId,
309 token: sessionToken,
310 expiresAt: sessionExpiry(),
311 });
312 setCookie(c, "session", sessionToken, sessionCookieOptions());
313
314 if (result.createdAccount) return c.redirect("/onboarding?welcome=1");
315 return c.redirect("/dashboard");
316});
317
318export default magicLink;