From 03d420c9846001b313dbd44c7408f94b423b19c8 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:05:41 +0200 Subject: [PATCH] feat(pitch-deck): self-service magic-link reissue on /auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investors who lost their session or whose invite token was already used can now enter their email on /auth to receive a fresh access link, without needing a manual re-invite from an admin. - New /api/auth/request-link endpoint looks up the investor by email, issues a new pitch_magic_links row, and emails the link via the existing sendMagicLinkEmail path. Response is generic regardless of whether the email exists (enumeration resistance) and silently no-ops for revoked investors. - Rate-limited both per-IP (authVerify preset) and per-email (magicLink preset, 3/hour — same ceiling as admin-invite/resend). - /auth page now renders an email form; submits to the new endpoint and shows a generic "if invited, link sent" confirmation. - Route-level tests cover validation, normalization, unknown email, revoked investor, and both rate-limit paths. - End-to-end regression test wires request-link + verify against an in-memory fake DB and asserts the full flow: original invite used → replay rejected → email submission → fresh link → verify succeeds. Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/api/reissue-regression.test.ts | 294 ++++++++++++++++++ pitch-deck/__tests__/api/request-link.test.ts | 213 +++++++++++++ pitch-deck/app/api/auth/request-link/route.ts | 73 +++++ pitch-deck/app/auth/page.tsx | 72 ++++- 4 files changed, 651 insertions(+), 1 deletion(-) create mode 100644 pitch-deck/__tests__/api/reissue-regression.test.ts create mode 100644 pitch-deck/__tests__/api/request-link.test.ts create mode 100644 pitch-deck/app/api/auth/request-link/route.ts diff --git a/pitch-deck/__tests__/api/reissue-regression.test.ts b/pitch-deck/__tests__/api/reissue-regression.test.ts new file mode 100644 index 0000000..dec81a1 --- /dev/null +++ b/pitch-deck/__tests__/api/reissue-regression.test.ts @@ -0,0 +1,294 @@ +/** + * Regression test for the "lost access" scenario: + * + * 1. Admin invites investor A → token T1 is created and emailed. + * 2. Investor A opens the link successfully → T1 is marked used_at. + * 3. Investor A clears their session (or a redeploy drops cookies). + * 4. Investor A returns to / — redirected to /auth. + * 5. Without this feature, A is stuck: T1 is already used, expired, or the + * session is gone, and there is no self-service way to get back in. + * 6. With this feature, A enters their email on /auth and the endpoint + * issues a brand new, unused magic link T2 for the same investor row. + * + * This test wires together the request-link handler with the real verify + * handler against an in-memory fake of the two tables the flow touches + * (pitch_investors, pitch_magic_links) so we can assert end-to-end that a + * second link works after the first one was used. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { NextRequest } from 'next/server' + +// ---- In-memory fake of the two tables touched by this flow ---- + +interface InvestorRow { + id: string + email: string + name: string | null + company: string | null + status: 'invited' | 'active' | 'revoked' + last_login_at: Date | null + login_count: number +} +interface MagicLinkRow { + id: string + investor_id: string + token: string + expires_at: Date + used_at: Date | null + ip_address: string | null + user_agent: string | null +} + +const db = { + investors: [] as InvestorRow[], + magicLinks: [] as MagicLinkRow[], + sessions: [] as { id: string; investor_id: string; ip_address: string | null }[], +} + +let idCounter = 0 +const nextId = () => `row-${++idCounter}` + +// A tiny query router: match the SQL fragment we care about, ignore the rest. +const queryMock = vi.fn(async (sql: string, params: unknown[] = []) => { + const s = sql.replace(/\s+/g, ' ').trim() + + // Investor lookup by email (used by request-link) + if (/SELECT id, email, name, status FROM pitch_investors WHERE email = \$1/i.test(s)) { + const row = db.investors.find(i => i.email === params[0]) + return { rows: row ? [row] : [] } + } + + // Insert magic link + if (/INSERT INTO pitch_magic_links \(investor_id, token, expires_at\)/i.test(s)) { + db.magicLinks.push({ + id: nextId(), + investor_id: params[0] as string, + token: params[1] as string, + expires_at: params[2] as Date, + used_at: null, + ip_address: null, + user_agent: null, + }) + return { rows: [] } + } + + // Verify: magic link + investor JOIN lookup + if (/FROM pitch_magic_links ml JOIN pitch_investors i/i.test(s)) { + const link = db.magicLinks.find(ml => ml.token === params[0]) + if (!link) return { rows: [] } + const inv = db.investors.find(i => i.id === link.investor_id)! + return { + rows: [{ + id: link.id, + investor_id: link.investor_id, + expires_at: link.expires_at, + used_at: link.used_at, + email: inv.email, + investor_status: inv.status, + }], + } + } + + // Mark magic link used + if (/UPDATE pitch_magic_links SET used_at = NOW/i.test(s)) { + const link = db.magicLinks.find(ml => ml.id === params[2]) + if (link) { + link.used_at = new Date() + link.ip_address = params[0] as string | null + link.user_agent = params[1] as string | null + } + return { rows: [] } + } + + // Activate investor + if (/UPDATE pitch_investors SET status = 'active'/i.test(s)) { + const inv = db.investors.find(i => i.id === params[0]) + if (inv) { + inv.status = 'active' + inv.last_login_at = new Date() + inv.login_count += 1 + } + return { rows: [] } + } + + // createSession: revoke prior sessions (no-op in fake) + if (/UPDATE pitch_sessions SET revoked = true WHERE investor_id/i.test(s)) { + return { rows: [] } + } + + // createSession: insert + if (/INSERT INTO pitch_sessions/i.test(s)) { + const id = nextId() + db.sessions.push({ id, investor_id: params[0] as string, ip_address: params[2] as string | null }) + return { rows: [{ id }] } + } + + // createSession: fetch investor email for JWT + if (/SELECT email FROM pitch_investors WHERE id = \$1/i.test(s)) { + const inv = db.investors.find(i => i.id === params[0]) + return { rows: inv ? [{ email: inv.email }] : [] } + } + + // new-ip detection query (verify route) + if (/SELECT DISTINCT ip_address FROM pitch_sessions/i.test(s)) { + return { rows: [] } + } + + // Audit log insert — accept everything + if (/INSERT INTO pitch_audit_logs/i.test(s)) { + return { rows: [] } + } + + throw new Error(`Unmocked query: ${s.slice(0, 120)}…`) +}) + +vi.mock('@/lib/db', () => ({ + default: { query: (...args: unknown[]) => queryMock(args[0] as string, args[1] as unknown[]) }, +})) + +// Capture emails instead of sending them +const sentEmails: Array<{ to: string; url: string }> = [] +vi.mock('@/lib/email', () => ({ + sendMagicLinkEmail: vi.fn(async (to: string, _name: string | null, url: string) => { + sentEmails.push({ to, url }) + }), +})) + +// next/headers cookies() needs to be stubbed — setSessionCookie calls it. +vi.mock('next/headers', () => ({ + cookies: async () => ({ + set: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + }), +})) + +// Import the handlers AFTER mocks are set up +import { POST as requestLink } from '@/app/api/auth/request-link/route' +import { POST as verifyLink } from '@/app/api/auth/verify/route' + +function makeJsonRequest(url: string, body: unknown, ip = '203.0.113.1'): NextRequest { + return new NextRequest(url, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-forwarded-for': ip }, + body: JSON.stringify(body), + }) +} + +function extractToken(url: string): string { + const m = url.match(/token=([0-9a-f]+)/) + if (!m) throw new Error(`No token in url: ${url}`) + return m[1] +} + +beforeEach(() => { + db.investors = [] + db.magicLinks = [] + db.sessions = [] + sentEmails.length = 0 + idCounter = 0 + queryMock.mockClear() +}) + +describe('Regression: investor can re-request a working magic link after the first is consumed', () => { + it('full flow — invite → use → request-link → new link works', async () => { + // --- Setup: admin has already invited the investor (simulate the outcome) --- + const investorId = 'investor-42' + db.investors.push({ + id: investorId, + email: 'vc@example.com', + name: 'VC Partner', + company: 'Acme Capital', + status: 'invited', + last_login_at: null, + login_count: 0, + }) + db.magicLinks.push({ + id: 'ml-original', + investor_id: investorId, + token: 'a'.repeat(96), // original invite token + expires_at: new Date(Date.now() + 72 * 60 * 60 * 1000), + used_at: null, + ip_address: null, + user_agent: null, + }) + + // --- Step 1: investor uses the original invite link --- + const firstVerify = await verifyLink(makeJsonRequest('http://localhost/api/auth/verify', { token: 'a'.repeat(96) })) + expect(firstVerify.status).toBe(200) + const first = db.magicLinks.find(ml => ml.id === 'ml-original')! + expect(first.used_at).not.toBeNull() + + // --- Step 2: investor comes back later; clicks the same link → rejected --- + const replay = await verifyLink(makeJsonRequest('http://localhost/api/auth/verify', { token: 'a'.repeat(96) })) + expect(replay.status).toBe(401) + const replayBody = await replay.json() + expect(replayBody.error).toMatch(/already been used/i) + + // --- Step 3: investor visits /auth and submits their email --- + const reissue = await requestLink( + makeJsonRequest('http://localhost/api/auth/request-link', { email: 'vc@example.com' }, '203.0.113.99'), + ) + expect(reissue.status).toBe(200) + const reissueBody = await reissue.json() + expect(reissueBody.success).toBe(true) + + // --- Step 4: a fresh email was dispatched to the investor --- + expect(sentEmails).toHaveLength(1) + expect(sentEmails[0].to).toBe('vc@example.com') + const newToken = extractToken(sentEmails[0].url) + expect(newToken).not.toBe('a'.repeat(96)) + expect(newToken).toMatch(/^[0-9a-f]{96}$/) + + // A second unused magic link row exists for the same investor + const links = db.magicLinks.filter(ml => ml.investor_id === investorId) + expect(links).toHaveLength(2) + const newLink = links.find(ml => ml.token === newToken)! + expect(newLink.used_at).toBeNull() + + // --- Step 5: the new token validates successfully --- + const secondVerify = await verifyLink(makeJsonRequest('http://localhost/api/auth/verify', { token: newToken })) + expect(secondVerify.status).toBe(200) + const secondBody = await secondVerify.json() + expect(secondBody.success).toBe(true) + expect(secondBody.redirect).toBe('/') + + // And the new link is now used, mirroring the one-time-use contract + expect(newLink.used_at).not.toBeNull() + }) + + it('unknown emails do not create magic links or send email (prevents enumeration & abuse)', async () => { + // No investors in the DB + const res = await requestLink( + makeJsonRequest('http://localhost/api/auth/request-link', { email: 'stranger@example.com' }), + ) + expect(res.status).toBe(200) + const body = await res.json() + // Same generic message as the happy path + expect(body.success).toBe(true) + expect(body.message).toMatch(/if this email was invited/i) + + expect(sentEmails).toHaveLength(0) + expect(db.magicLinks).toHaveLength(0) + }) + + it('revoked investors cannot self-serve a new link', async () => { + db.investors.push({ + id: 'revoked-1', + email: 'gone@example.com', + name: null, + company: null, + status: 'revoked', + last_login_at: null, + login_count: 0, + }) + + const res = await requestLink( + makeJsonRequest('http://localhost/api/auth/request-link', { email: 'gone@example.com' }), + ) + expect(res.status).toBe(200) // generic success (no info leak) + expect(sentEmails).toHaveLength(0) + expect(db.magicLinks).toHaveLength(0) + }) +}) diff --git a/pitch-deck/__tests__/api/request-link.test.ts b/pitch-deck/__tests__/api/request-link.test.ts new file mode 100644 index 0000000..f1536f7 --- /dev/null +++ b/pitch-deck/__tests__/api/request-link.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { NextRequest } from 'next/server' + +// Mock the DB pool before the route is imported +const queryMock = vi.fn() +vi.mock('@/lib/db', () => ({ + default: { query: (...args: unknown[]) => queryMock(...args) }, +})) + +// Mock the email sender so no SMTP is attempted +const sendMagicLinkEmailMock = vi.fn().mockResolvedValue(undefined) +vi.mock('@/lib/email', () => ({ + sendMagicLinkEmail: (...args: unknown[]) => sendMagicLinkEmailMock(...args), +})) + +// Import after mocks are registered +import { POST } from '@/app/api/auth/request-link/route' + +// Unique suffix per test so the rate-limit store (keyed by IP / email) doesn't +// bleed across cases — the rate-limiter holds state at module scope. +let testId = 0 +function uniqueIp() { + testId++ + return `10.0.${Math.floor(testId / 250)}.${testId % 250}` +} + +function makeRequest(body: unknown, ip = uniqueIp()): NextRequest { + return new NextRequest('http://localhost/api/auth/request-link', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-forwarded-for': ip, + }, + body: JSON.stringify(body), + }) +} + +function investorRow(overrides: Partial<{ id: string; email: string; name: string | null; status: string }> = {}) { + return { + id: overrides.id ?? 'investor-1', + email: overrides.email ?? 'invited@example.com', + name: overrides.name ?? 'Alice', + status: overrides.status ?? 'invited', + } +} + +beforeEach(() => { + queryMock.mockReset() + sendMagicLinkEmailMock.mockReset() + sendMagicLinkEmailMock.mockResolvedValue(undefined) +}) + +describe('POST /api/auth/request-link — input validation', () => { + it('returns 400 when email is missing', async () => { + const res = await POST(makeRequest({})) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toBe('Email required') + expect(queryMock).not.toHaveBeenCalled() + expect(sendMagicLinkEmailMock).not.toHaveBeenCalled() + }) + + it('returns 400 when email is not a string', async () => { + const res = await POST(makeRequest({ email: 12345 })) + expect(res.status).toBe(400) + expect(sendMagicLinkEmailMock).not.toHaveBeenCalled() + }) + + it('handles malformed JSON body as missing email (400)', async () => { + const req = new NextRequest('http://localhost/api/auth/request-link', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-forwarded-for': uniqueIp() }, + body: 'not-json', + }) + const res = await POST(req) + expect(res.status).toBe(400) + }) +}) + +describe('POST /api/auth/request-link — unknown email (enumeration resistance)', () => { + it('returns the generic success response without sending email', async () => { + // First query: investor lookup → empty rows + queryMock.mockResolvedValueOnce({ rows: [] }) + // Second query: the audit log insert + queryMock.mockResolvedValueOnce({ rows: [] }) + + const res = await POST(makeRequest({ email: 'unknown@example.com' })) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.success).toBe(true) + expect(body.message).toMatch(/if this email was invited/i) + expect(sendMagicLinkEmailMock).not.toHaveBeenCalled() + + // Verify the investor-lookup SQL was issued with the normalized email + const [sql, params] = queryMock.mock.calls[0] + expect(sql).toMatch(/FROM pitch_investors WHERE email/i) + expect(params).toEqual(['unknown@example.com']) + }) + + it('normalizes email (trim + lowercase) before lookup', async () => { + queryMock.mockResolvedValueOnce({ rows: [] }) + queryMock.mockResolvedValueOnce({ rows: [] }) + + await POST(makeRequest({ email: ' Mixed@Example.COM ' })) + + const [, params] = queryMock.mock.calls[0] + expect(params).toEqual(['mixed@example.com']) + }) +}) + +describe('POST /api/auth/request-link — known investor', () => { + it('creates a new magic link and sends the email with generic response', async () => { + // 1st: investor lookup → found + queryMock.mockResolvedValueOnce({ rows: [investorRow()] }) + // 2nd: magic link insert + queryMock.mockResolvedValueOnce({ rows: [] }) + // 3rd: audit log insert + queryMock.mockResolvedValueOnce({ rows: [] }) + + const res = await POST(makeRequest({ email: 'invited@example.com' })) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.success).toBe(true) + // Response is identical to the unknown-email case (no information leak) + expect(body.message).toMatch(/if this email was invited/i) + + // Verify magic link insert + const [insertSql, insertParams] = queryMock.mock.calls[1] + expect(insertSql).toMatch(/INSERT INTO pitch_magic_links/i) + expect(insertParams[0]).toBe('investor-1') + expect(insertParams[1]).toMatch(/^[0-9a-f]{96}$/) // 96-char hex token + expect(insertParams[2]).toBeInstanceOf(Date) + + // Verify email was sent with the fresh token URL + expect(sendMagicLinkEmailMock).toHaveBeenCalledTimes(1) + const [emailTo, emailName, magicLinkUrl] = sendMagicLinkEmailMock.mock.calls[0] + expect(emailTo).toBe('invited@example.com') + expect(emailName).toBe('Alice') + expect(magicLinkUrl).toMatch(/\/auth\/verify\?token=[0-9a-f]{96}$/) + }) + + it('generates a different token on each call (re-invite is always fresh)', async () => { + // Call 1 + queryMock.mockResolvedValueOnce({ rows: [investorRow({ email: 'a@x.com' })] }) + queryMock.mockResolvedValueOnce({ rows: [] }) + queryMock.mockResolvedValueOnce({ rows: [] }) + await POST(makeRequest({ email: 'a@x.com' })) + + // Call 2 — different email to avoid the per-email rate limit + queryMock.mockResolvedValueOnce({ rows: [investorRow({ email: 'b@x.com' })] }) + queryMock.mockResolvedValueOnce({ rows: [] }) + queryMock.mockResolvedValueOnce({ rows: [] }) + await POST(makeRequest({ email: 'b@x.com' })) + + const token1 = queryMock.mock.calls[1][1][1] + const token2 = queryMock.mock.calls[4][1][1] + expect(token1).not.toBe(token2) + }) + + it('skips email send for a revoked investor (returns generic response)', async () => { + queryMock.mockResolvedValueOnce({ rows: [investorRow({ status: 'revoked' })] }) + queryMock.mockResolvedValueOnce({ rows: [] }) // audit log + + const res = await POST(makeRequest({ email: 'invited@example.com' })) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.success).toBe(true) + expect(sendMagicLinkEmailMock).not.toHaveBeenCalled() + + // Ensure no magic link was inserted + const inserts = queryMock.mock.calls.filter(c => /INSERT INTO pitch_magic_links/i.test(c[0])) + expect(inserts.length).toBe(0) + }) +}) + +describe('POST /api/auth/request-link — rate limiting', () => { + it('throttles after N requests per email and returns generic success (silent throttle)', async () => { + const email = `throttle-${Date.now()}@example.com` + + // First 3 requests succeed (RATE_LIMITS.magicLink.limit = 3) + for (let i = 0; i < 3; i++) { + queryMock.mockResolvedValueOnce({ rows: [investorRow({ email })] }) + queryMock.mockResolvedValueOnce({ rows: [] }) // magic link insert + queryMock.mockResolvedValueOnce({ rows: [] }) // audit log + const res = await POST(makeRequest({ email })) + expect(res.status).toBe(200) + } + expect(sendMagicLinkEmailMock).toHaveBeenCalledTimes(3) + + // 4th request is silently throttled — same generic response, no email sent + queryMock.mockResolvedValueOnce({ rows: [] }) // audit log only + const res4 = await POST(makeRequest({ email })) + expect(res4.status).toBe(200) + const body4 = await res4.json() + expect(body4.success).toBe(true) + // Still exactly 3 emails sent — nothing new + expect(sendMagicLinkEmailMock).toHaveBeenCalledTimes(3) + }) + + it('throttles with 429 after too many attempts from the same IP', async () => { + const ip = '172.31.99.99' + // RATE_LIMITS.authVerify.limit = 10 for IP-scoped checks + for (let i = 0; i < 10; i++) { + queryMock.mockResolvedValueOnce({ rows: [] }) // investor lookup returns empty + queryMock.mockResolvedValueOnce({ rows: [] }) // audit + const res = await POST(makeRequest({ email: `ip-test-${i}@example.com` }, ip)) + expect(res.status).toBe(200) + } + + const res = await POST(makeRequest({ email: 'final@example.com' }, ip)) + expect(res.status).toBe(429) + }) +}) diff --git a/pitch-deck/app/api/auth/request-link/route.ts b/pitch-deck/app/api/auth/request-link/route.ts new file mode 100644 index 0000000..6258916 --- /dev/null +++ b/pitch-deck/app/api/auth/request-link/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { generateToken, getClientIp, logAudit } from '@/lib/auth' +import { sendMagicLinkEmail } from '@/lib/email' +import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit' + +// Generic response returned regardless of whether an investor exists, to +// prevent email enumeration. The client always sees the same success message. +const GENERIC_RESPONSE = { + success: true, + message: 'If this email was invited, a fresh access link has been sent.', +} + +export async function POST(request: NextRequest) { + const ip = getClientIp(request) || 'unknown' + + // IP-based rate limit to prevent enumeration / abuse + const ipRl = checkRateLimit(`request-link-ip:${ip}`, RATE_LIMITS.authVerify) + if (!ipRl.allowed) { + return NextResponse.json({ error: 'Too many attempts. Try again later.' }, { status: 429 }) + } + + const body = await request.json().catch(() => ({})) + const { email } = body + + if (!email || typeof email !== 'string') { + return NextResponse.json({ error: 'Email required' }, { status: 400 }) + } + + const normalizedEmail = email.toLowerCase().trim() + + // Per-email rate limit (silent — same generic response on throttle so callers + // can't distinguish a throttled-but-valid email from an unknown one) + const emailRl = checkRateLimit(`magic-link:${normalizedEmail}`, RATE_LIMITS.magicLink) + if (!emailRl.allowed) { + await logAudit(null, 'request_link_throttled', { email: normalizedEmail }, request) + return NextResponse.json(GENERIC_RESPONSE) + } + + const { rows } = await pool.query( + `SELECT id, email, name, status FROM pitch_investors WHERE email = $1`, + [normalizedEmail], + ) + + if (rows.length === 0) { + await logAudit(null, 'request_link_unknown_email', { email: normalizedEmail }, request) + return NextResponse.json(GENERIC_RESPONSE) + } + + const investor = rows[0] + + if (investor.status === 'revoked') { + await logAudit(investor.id, 'request_link_revoked', { email: normalizedEmail }, request) + return NextResponse.json(GENERIC_RESPONSE) + } + + const token = generateToken() + const ttlHours = parseInt(process.env.MAGIC_LINK_TTL_HOURS || '72') + const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000) + + await pool.query( + `INSERT INTO pitch_magic_links (investor_id, token, expires_at) VALUES ($1, $2, $3)`, + [investor.id, token, expiresAt], + ) + + const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai' + const magicLinkUrl = `${baseUrl}/auth/verify?token=${token}` + await sendMagicLinkEmail(investor.email, investor.name, magicLinkUrl) + + await logAudit(investor.id, 'request_link_sent', { email: normalizedEmail, expires_at: expiresAt.toISOString() }, request) + + return NextResponse.json(GENERIC_RESPONSE) +} diff --git a/pitch-deck/app/auth/page.tsx b/pitch-deck/app/auth/page.tsx index a9d4053..e72e705 100644 --- a/pitch-deck/app/auth/page.tsx +++ b/pitch-deck/app/auth/page.tsx @@ -1,8 +1,43 @@ 'use client' import { motion } from 'framer-motion' +import { useState, FormEvent } from 'react' + +type Status = 'idle' | 'submitting' | 'sent' | 'error' export default function AuthPage() { + const [email, setEmail] = useState('') + const [status, setStatus] = useState('idle') + const [message, setMessage] = useState(null) + + async function handleSubmit(e: FormEvent) { + e.preventDefault() + if (!email.trim()) return + + setStatus('submitting') + setMessage(null) + + try { + const res = await fetch('/api/auth/request-link', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email.trim() }), + }) + const data = await res.json().catch(() => ({})) + + if (res.ok) { + setStatus('sent') + setMessage(data.message || 'If this email was invited, a fresh access link has been sent.') + } else { + setStatus('error') + setMessage(data.error || 'Something went wrong. Please try again.') + } + } catch { + setStatus('error') + setMessage('Network error. Please try again.') + } + } + return (
{/* Background gradient */} @@ -35,9 +70,44 @@ export default function AuthPage() {

This interactive pitch deck is available by invitation only. - Please check your email for an access link. + If you were invited, enter your email below and we'll send you a fresh access link.

+ {status === 'sent' ? ( +
+

+ {message} +

+
+ ) : ( +
+ + setEmail(e.target.value)} + disabled={status === 'submitting'} + placeholder="you@example.com" + className="w-full bg-white/[0.04] border border-white/[0.08] rounded-lg px-4 py-3 text-white/90 text-sm placeholder:text-white/20 focus:outline-none focus:border-indigo-400/50 focus:bg-white/[0.06] transition-colors disabled:opacity-50" + /> + {status === 'error' && message && ( +

{message}

+ )} + +
+ )} +

Questions? Contact us at{' '}