CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.
| cd4f63b | 1 | /** |
| 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. | |
| 13 | */ | |
| 14 | ||
| 15 | import { Hono } from "hono"; | |
| 16 | import { setCookie } from "hono/cookie"; | |
| 17 | import { db } from "../db"; | |
| 18 | import { sessions } from "../db/schema"; | |
| 19 | import { | |
| 20 | generateSessionToken, | |
| 21 | sessionCookieOptions, | |
| 22 | sessionExpiry, | |
| 23 | } from "../lib/auth"; | |
| 24 | import { Layout } from "../views/layout"; | |
| 25 | import { Form, FormGroup, Input, Button, Alert, Text } from "../views/ui"; | |
| 26 | import { softAuth } from "../middleware/auth"; | |
| 27 | import type { AuthEnv } from "../middleware/auth"; | |
| 28 | import { | |
| 29 | startMagicLinkSignIn, | |
| 30 | consumeMagicLinkToken, | |
| 31 | } from "../lib/magic-link"; | |
| 32 | ||
| 33 | const magicLink = new Hono<AuthEnv>(); | |
| 34 | ||
| 35 | // --------------------------------------------------------------------------- | |
| 36 | // GET /login/magic — entry form + post-submit success. | |
| 37 | // --------------------------------------------------------------------------- | |
| 38 | ||
| 39 | magicLink.get("/login/magic", softAuth, (c) => { | |
| 40 | const existing = c.get("user"); | |
| 41 | if (existing) return c.redirect("/dashboard"); | |
| 42 | ||
| 43 | const csrf = c.get("csrfToken") as string | undefined; | |
| 44 | const sent = c.req.query("sent") === "1"; | |
| 45 | ||
| 46 | if (sent) { | |
| 47 | return c.html( | |
| 48 | <Layout title="Check your inbox" user={null}> | |
| 49 | <div class="auth-container"> | |
| 50 | <h2>Check your inbox</h2> | |
| 51 | <Alert variant="success"> | |
| 52 | If we can sign you in with that email, we've sent a link. It | |
| 53 | expires in 15 minutes. | |
| 54 | </Alert> | |
| 55 | <p class="auth-switch"> | |
| 56 | <Text> | |
| 57 | Didn't get it? Check your spam folder, or{" "} | |
| 58 | <a href="/login/magic">try again</a>. | |
| 59 | </Text> | |
| 60 | </p> | |
| 61 | <p class="auth-switch"> | |
| 62 | <a href="/login">Back to sign in</a> | |
| 63 | </p> | |
| 64 | </div> | |
| 65 | </Layout> | |
| 66 | ); | |
| 67 | } | |
| 68 | ||
| 69 | return c.html( | |
| 70 | <Layout title="Sign in with email link" user={null}> | |
| 71 | <div class="auth-container"> | |
| 72 | <h2>Sign in with a magic link</h2> | |
| 73 | <p class="auth-switch" style="margin-bottom:16px;margin-top:0"> | |
| 74 | <Text> | |
| 75 | Enter your email and we'll send you a one-time sign-in link. No | |
| 76 | password needed. | |
| 77 | </Text> | |
| 78 | </p> | |
| 79 | <Form method="post" action="/login/magic" csrfToken={csrf}> | |
| 80 | <FormGroup label="Email" htmlFor="email"> | |
| 81 | <Input | |
| 82 | type="email" | |
| 83 | name="email" | |
| 84 | required | |
| 85 | placeholder="you@example.com" | |
| 86 | autocomplete="email" | |
| 87 | aria-label="Email" | |
| 88 | /> | |
| 89 | </FormGroup> | |
| 90 | <Button type="submit" variant="primary"> | |
| 91 | Send me a sign-in link | |
| 92 | </Button> | |
| 93 | </Form> | |
| 94 | <p class="auth-switch"> | |
| 95 | <Text> | |
| 96 | Prefer a password?{" "} | |
| 97 | <a href="/login">Sign in the usual way</a>. | |
| 98 | </Text> | |
| 99 | </p> | |
| 100 | </div> | |
| 101 | </Layout> | |
| 102 | ); | |
| 103 | }); | |
| 104 | ||
| 105 | // --------------------------------------------------------------------------- | |
| 106 | // POST /login/magic — always redirects to ?sent=1 (no enumeration). | |
| 107 | // --------------------------------------------------------------------------- | |
| 108 | ||
| 109 | magicLink.post("/login/magic", async (c) => { | |
| 110 | const body = await c.req.parseBody(); | |
| 111 | const email = String(body.email || "").trim(); | |
| 112 | const ip = | |
| 113 | c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || | |
| 114 | c.req.header("x-real-ip") || | |
| 115 | undefined; | |
| 116 | await startMagicLinkSignIn(email, { requestIp: ip }); | |
| 117 | return c.redirect("/login/magic?sent=1"); | |
| 118 | }); | |
| 119 | ||
| 120 | // --------------------------------------------------------------------------- | |
| 121 | // GET /login/magic/callback?token=… — consume the link. | |
| 122 | // --------------------------------------------------------------------------- | |
| 123 | ||
| 124 | function InvalidLinkPage(props: { user: any }) { | |
| 125 | return ( | |
| 126 | <Layout title="Link no longer valid" user={props.user ?? null}> | |
| 127 | <div class="auth-container"> | |
| 128 | <h2>This link is no longer valid</h2> | |
| 129 | <Alert variant="error"> | |
| 130 | Magic links expire after 15 minutes and can only be used once. | |
| 131 | This link is expired, already used, or unknown. | |
| 132 | </Alert> | |
| 133 | <p class="auth-switch" style="margin-top:16px"> | |
| 134 | <a href="/login/magic">Send a fresh one</a> | |
| 135 | </p> | |
| 136 | <p class="auth-switch"> | |
| 137 | <a href="/login">Back to sign in</a> | |
| 138 | </p> | |
| 139 | </div> | |
| 140 | </Layout> | |
| 141 | ); | |
| 142 | } | |
| 143 | ||
| 144 | magicLink.get("/login/magic/callback", softAuth, async (c) => { | |
| 145 | const token = String(c.req.query("token") || "").trim(); | |
| 146 | if (!token) return c.html(<InvalidLinkPage user={c.get("user")} />); | |
| 147 | ||
| 148 | const ip = | |
| 149 | c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || | |
| 150 | c.req.header("x-real-ip") || | |
| 151 | undefined; | |
| 152 | ||
| 153 | const result = await consumeMagicLinkToken(token, { requestIp: ip }); | |
| 154 | if (!result.ok || !result.userId) { | |
| 155 | return c.html(<InvalidLinkPage user={c.get("user")} />); | |
| 156 | } | |
| 157 | ||
| 158 | // Mint a fresh session for this user. We deliberately do NOT honour | |
| 159 | // existing 2FA on magic-link sign-in here — the magic-link flow is | |
| 160 | // explicitly for users who don't manage a password and 2FA enrollment | |
| 161 | // requires a password in our current setup. If/when 2FA is decoupled | |
| 162 | // from password auth (Q3+), this is the place to gate. | |
| 163 | const sessionToken = generateSessionToken(); | |
| 164 | await db.insert(sessions).values({ | |
| 165 | userId: result.userId, | |
| 166 | token: sessionToken, | |
| 167 | expiresAt: sessionExpiry(), | |
| 168 | }); | |
| 169 | setCookie(c, "session", sessionToken, sessionCookieOptions()); | |
| 170 | ||
| 171 | if (result.createdAccount) return c.redirect("/onboarding?welcome=1"); | |
| 172 | return c.redirect("/dashboard"); | |
| 173 | }); | |
| 174 | ||
| 175 | export default magicLink; |