feat(pitch-deck): self-service magic-link reissue on /auth
All checks were successful
Build pitch-deck / build-push-deploy (push) Successful in 1m5s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 31s
CI / test-python-voice (push) Successful in 31s
CI / test-bqas (push) Successful in 31s
All checks were successful
Build pitch-deck / build-push-deploy (push) Successful in 1m5s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 31s
CI / test-python-voice (push) Successful in 31s
CI / test-bqas (push) Successful in 31s
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 <noreply@anthropic.com>
This commit is contained in:
73
pitch-deck/app/api/auth/request-link/route.ts
Normal file
73
pitch-deck/app/api/auth/request-link/route.ts
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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<Status>('idle')
|
||||
const [message, setMessage] = useState<string | null>(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 (
|
||||
<div className="h-screen flex items-center justify-center bg-[#0a0a1a] relative overflow-hidden">
|
||||
{/* Background gradient */}
|
||||
@@ -35,9 +70,44 @@ export default function AuthPage() {
|
||||
|
||||
<p className="text-white/50 text-sm leading-relaxed mb-6">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{status === 'sent' ? (
|
||||
<div className="text-left bg-indigo-500/10 border border-indigo-500/20 rounded-lg p-4 mb-5">
|
||||
<p className="text-indigo-200/90 text-sm leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="text-left mb-5">
|
||||
<label htmlFor="email" className="block text-white/60 text-xs mb-2 uppercase tracking-wide">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => 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 && (
|
||||
<p className="mt-2 text-rose-300/80 text-xs">{message}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'submitting' || !email.trim()}
|
||||
className="w-full mt-4 bg-gradient-to-br from-indigo-500 to-purple-500 hover:from-indigo-400 hover:to-purple-400 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg px-4 py-3 transition-all"
|
||||
>
|
||||
{status === 'submitting' ? 'Sending…' : 'Send access link'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="border-t border-white/[0.06] pt-5">
|
||||
<p className="text-white/30 text-xs">
|
||||
Questions? Contact us at{' '}
|
||||
|
||||
Reference in New Issue
Block a user