feat(pitch-admin): generate magic link + 72h investor data masking
Build pitch-deck / build-push-deploy (push) Successful in 1m30s
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 29s
CI / test-python-voice (push) Successful in 29s
CI / test-bqas (push) Successful in 30s
Build pitch-deck / build-push-deploy (push) Successful in 1m30s
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 29s
CI / test-python-voice (push) Successful in 29s
CI / test-bqas (push) Successful in 30s
- New POST /api/admin/investors/[id]/generate-link endpoint: creates a magic link without sending email, returns the URL for the admin to copy and share manually (for when email is filtered) - Adds 'Copy Link' button (emerald) to investor list and detail pages; link is copied to clipboard on click - New lib/masking.ts: maskOverdueInvestors() UPDATE that anonymizes email/name/company → revokes sessions 72h after first investor login - first_activity_at recorded on first verify (COALESCE, set once only) - migration 004 adds first_activity_at + data_masked_at columns with partial index; also wired into /api/admin/migrate for one-shot apply - Admin UI shows 'anonymized' badge, expiry countdown, and masked state; Copy Link + Resend are disabled for anonymized investors - verify route returns 410 if data_masked_at is set (belt-and-suspenders alongside the revoked status check) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { generateToken } from '@/lib/auth'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, ctx: RouteContext) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const { id } = await ctx.params
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, email, name, status, data_masked_at FROM pitch_investors WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Investor not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const investor = rows[0]
|
||||
if (investor.data_masked_at) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Investor data has been anonymized after the 72h window. Cannot generate a new link.' },
|
||||
{ status: 410 },
|
||||
)
|
||||
}
|
||||
if (investor.status === 'revoked') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Investor is revoked. Re-invite to reactivate.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
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 url = `${baseUrl}/auth/verify?token=${token}`
|
||||
|
||||
await logAdminAudit(
|
||||
adminId,
|
||||
'magic_link_generated',
|
||||
{ email: investor.email, expires_at: expiresAt.toISOString(), channel: 'manual_copy' },
|
||||
request,
|
||||
investor.id,
|
||||
)
|
||||
|
||||
return NextResponse.json({ url, expires_at: expiresAt.toISOString() })
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
import { maskOverdueInvestors } from '@/lib/masking'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
@@ -12,10 +13,13 @@ export async function GET(request: NextRequest, ctx: RouteContext) {
|
||||
|
||||
const { id } = await ctx.params
|
||||
|
||||
await maskOverdueInvestors()
|
||||
|
||||
const [investor, sessions, snapshots, audit] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count,
|
||||
i.created_at, i.updated_at, i.assigned_version_id,
|
||||
i.created_at, i.updated_at, i.first_activity_at, i.data_masked_at,
|
||||
i.assigned_version_id,
|
||||
v.name AS version_name, v.status AS version_status
|
||||
FROM pitch_investors i
|
||||
LEFT JOIN pitch_versions v ON v.id = i.assigned_version_id
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin } from '@/lib/admin-auth'
|
||||
import { maskOverdueInvestors } from '@/lib/masking'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
|
||||
await maskOverdueInvestors()
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count, i.created_at,
|
||||
i.first_activity_at, i.data_masked_at,
|
||||
i.assigned_version_id, v.name AS version_name,
|
||||
(SELECT COUNT(*) FROM pitch_audit_logs a WHERE a.investor_id = i.id AND a.action = 'slide_viewed') as slides_viewed,
|
||||
(SELECT MAX(a.created_at) FROM pitch_audit_logs a WHERE a.investor_id = i.id) as last_activity
|
||||
|
||||
@@ -10,6 +10,12 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Finanzplan tables — the ones missing on production
|
||||
const statements = [
|
||||
// 004 — investor data masking columns
|
||||
`ALTER TABLE pitch_investors ADD COLUMN IF NOT EXISTS first_activity_at TIMESTAMPTZ`,
|
||||
`ALTER TABLE pitch_investors ADD COLUMN IF NOT EXISTS data_masked_at TIMESTAMPTZ`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_pitch_investors_mask_check
|
||||
ON pitch_investors (first_activity_at)
|
||||
WHERE first_activity_at IS NOT NULL AND data_masked_at IS NULL`,
|
||||
`CREATE TABLE IF NOT EXISTS fp_scenarios (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL DEFAULT 'Base Case',
|
||||
|
||||
@@ -21,7 +21,9 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Find the magic link
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ml.id, ml.investor_id, ml.expires_at, ml.used_at, i.email, i.status as investor_status
|
||||
`SELECT ml.id, ml.investor_id, ml.expires_at, ml.used_at,
|
||||
i.email, i.status as investor_status,
|
||||
i.first_activity_at, i.data_masked_at
|
||||
FROM pitch_magic_links ml
|
||||
JOIN pitch_investors i ON i.id = ml.investor_id
|
||||
WHERE ml.token = $1`,
|
||||
@@ -45,6 +47,10 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'This link has expired. Please request a new one.' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (link.data_masked_at) {
|
||||
return NextResponse.json({ error: 'This access period has ended and data has been anonymized.' }, { status: 410 })
|
||||
}
|
||||
|
||||
if (link.investor_status === 'revoked') {
|
||||
await logAudit(link.investor_id, 'login_failed', { reason: 'investor_revoked' }, request)
|
||||
return NextResponse.json({ error: 'Access has been revoked.' }, { status: 403 })
|
||||
@@ -58,9 +64,14 @@ export async function POST(request: NextRequest) {
|
||||
[ip, ua, link.id]
|
||||
)
|
||||
|
||||
// Activate investor if first login
|
||||
// Activate investor if first login; record first_activity_at once
|
||||
await pool.query(
|
||||
`UPDATE pitch_investors SET status = 'active', last_login_at = NOW(), login_count = login_count + 1, updated_at = NOW()
|
||||
`UPDATE pitch_investors
|
||||
SET status = 'active',
|
||||
last_login_at = NOW(),
|
||||
login_count = login_count + 1,
|
||||
first_activity_at = COALESCE(first_activity_at, NOW()),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[link.investor_id]
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, Mail, Ban, Save } from 'lucide-react'
|
||||
import { ArrowLeft, Mail, Ban, Save, Link2, RefreshCw } from 'lucide-react'
|
||||
import AuditLogTable from '@/components/pitch-admin/AuditLogTable'
|
||||
|
||||
interface InvestorDetail {
|
||||
@@ -16,6 +16,8 @@ interface InvestorDetail {
|
||||
last_login_at: string | null
|
||||
login_count: number
|
||||
created_at: string
|
||||
first_activity_at: string | null
|
||||
data_masked_at: string | null
|
||||
assigned_version_id: string | null
|
||||
version_name: string | null
|
||||
version_status: string | null
|
||||
@@ -51,6 +53,7 @@ const STATUS_STYLES: Record<string, string> = {
|
||||
invited: 'bg-amber-500/15 text-amber-300 border-amber-500/30',
|
||||
active: 'bg-green-500/15 text-green-300 border-green-500/30',
|
||||
revoked: 'bg-rose-500/15 text-rose-300 border-rose-500/30',
|
||||
anonymized: 'bg-zinc-500/15 text-zinc-400 border-zinc-500/30',
|
||||
}
|
||||
|
||||
export default function InvestorDetailPage() {
|
||||
@@ -105,12 +108,30 @@ export default function InvestorDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function generateLink() {
|
||||
setBusy(true)
|
||||
const res = await fetch(`/api/admin/investors/${id}/generate-link`, { method: 'POST' })
|
||||
setBusy(false)
|
||||
if (res.ok) {
|
||||
const d = await res.json()
|
||||
try {
|
||||
await navigator.clipboard.writeText(d.url)
|
||||
flashToast('Magic link copied to clipboard')
|
||||
} catch {
|
||||
flashToast(`Link (copy manually): ${d.url}`)
|
||||
}
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
flashToast(err.error || 'Failed to generate link')
|
||||
}
|
||||
}
|
||||
|
||||
async function resend() {
|
||||
setBusy(true)
|
||||
const res = await fetch(`/api/admin/investors/${id}/resend`, { method: 'POST' })
|
||||
setBusy(false)
|
||||
if (res.ok) {
|
||||
flashToast('Magic link resent')
|
||||
flashToast('Magic link resent via email')
|
||||
load()
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
@@ -168,13 +189,28 @@ export default function InvestorDetailPage() {
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-1 flex-wrap">
|
||||
<h1 className="text-2xl font-semibold text-white">{inv.name || inv.email}</h1>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border uppercase font-semibold ${STATUS_STYLES[inv.status]}`}>
|
||||
{inv.status}
|
||||
<h1 className="text-2xl font-semibold text-white">
|
||||
{inv.data_masked_at ? <span className="text-zinc-500 italic">[data protected]</span> : (inv.name || inv.email)}
|
||||
</h1>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border uppercase font-semibold ${inv.data_masked_at ? STATUS_STYLES.anonymized : STATUS_STYLES[inv.status]}`}>
|
||||
{inv.data_masked_at ? 'anonymized' : inv.status}
|
||||
</span>
|
||||
</div>
|
||||
{inv.data_masked_at ? (
|
||||
<div className="text-xs text-zinc-500 mt-1">
|
||||
Data anonymized on {new Date(inv.data_masked_at).toLocaleString()} · 72h window elapsed after first activity
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm text-white/60">{inv.company || '—'}</div>
|
||||
<div className="text-xs text-white/40 mt-1">{inv.email}</div>
|
||||
{inv.first_activity_at && (
|
||||
<div className="text-xs text-amber-400/70 mt-1">
|
||||
⏱ Data window: 72h from first login · expires {new Date(new Date(inv.first_activity_at).getTime() + 72 * 60 * 60 * 1000).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -197,18 +233,28 @@ export default function InvestorDetailPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!inv.data_masked_at && (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={generateLink}
|
||||
disabled={busy || !!inv.data_masked_at || inv.status === 'revoked'}
|
||||
className="bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2 disabled:opacity-30"
|
||||
title="Generate link without sending email — copies to clipboard"
|
||||
>
|
||||
{busy ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Link2 className="w-4 h-4" />} Copy Link
|
||||
</button>
|
||||
<button
|
||||
onClick={resend}
|
||||
disabled={busy || inv.status === 'revoked'}
|
||||
disabled={busy || !!inv.data_masked_at || inv.status === 'revoked'}
|
||||
className="bg-indigo-500/15 hover:bg-indigo-500/25 text-indigo-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2 disabled:opacity-30"
|
||||
>
|
||||
<Mail className="w-4 h-4" /> Resend Link
|
||||
<Mail className="w-4 h-4" /> Resend Email
|
||||
</button>
|
||||
<button
|
||||
onClick={revoke}
|
||||
@@ -228,9 +274,9 @@ export default function InvestorDetailPage() {
|
||||
<div className="text-xl text-white font-semibold mt-1">{inv.login_count}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-white/40 uppercase tracking-wider">Last login</div>
|
||||
<div className="text-xs text-white/40 uppercase tracking-wider">First activity</div>
|
||||
<div className="text-sm text-white/80 mt-1">
|
||||
{inv.last_login_at ? new Date(inv.last_login_at).toLocaleString() : '—'}
|
||||
{inv.first_activity_at ? new Date(inv.first_activity_at).toLocaleString() : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Search, Mail, Ban, Eye, RefreshCw } from 'lucide-react'
|
||||
import { Search, Mail, Ban, Eye, RefreshCw, Link2 } from 'lucide-react'
|
||||
|
||||
interface Investor {
|
||||
id: string
|
||||
@@ -17,12 +17,15 @@ interface Investor {
|
||||
last_activity: string | null
|
||||
assigned_version_id: string | null
|
||||
version_name: string | null
|
||||
first_activity_at: string | null
|
||||
data_masked_at: string | null
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
invited: 'bg-amber-500/15 text-amber-300 border-amber-500/30',
|
||||
active: 'bg-green-500/15 text-green-300 border-green-500/30',
|
||||
revoked: 'bg-rose-500/15 text-rose-300 border-rose-500/30',
|
||||
anonymized: 'bg-zinc-500/15 text-zinc-400 border-zinc-500/30',
|
||||
}
|
||||
|
||||
export default function InvestorsPage() {
|
||||
@@ -50,6 +53,24 @@ export default function InvestorsPage() {
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
async function generateLink(id: string) {
|
||||
setBusy(id)
|
||||
const res = await fetch(`/api/admin/investors/${id}/generate-link`, { method: 'POST' })
|
||||
setBusy(null)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.url)
|
||||
flashToast('Magic link copied to clipboard')
|
||||
} catch {
|
||||
flashToast(`Link (copy manually): ${data.url}`)
|
||||
}
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
flashToast(err.error || 'Failed to generate link')
|
||||
}
|
||||
}
|
||||
|
||||
async function resend(id: string) {
|
||||
setBusy(id)
|
||||
const res = await fetch(`/api/admin/investors/${id}/resend`, { method: 'POST' })
|
||||
@@ -156,15 +177,19 @@ export default function InvestorsPage() {
|
||||
<tr key={inv.id} className="border-b border-white/[0.04] hover:bg-white/[0.02]">
|
||||
<td className="py-3 px-4">
|
||||
<Link href={`/pitch-admin/investors/${inv.id}`} className="block min-w-0 hover:text-indigo-300">
|
||||
<div className="text-white/90 font-medium truncate">{inv.name || inv.email}</div>
|
||||
<div className="text-white/90 font-medium truncate">
|
||||
{inv.data_masked_at ? <span className="text-zinc-500 italic">[data protected]</span> : (inv.name || inv.email)}
|
||||
</div>
|
||||
<div className="text-xs text-white/40 truncate">
|
||||
{inv.company ? `${inv.company} · ` : ''}{inv.email}
|
||||
{inv.data_masked_at
|
||||
? `Anonymized ${new Date(inv.data_masked_at).toLocaleDateString()}`
|
||||
: `${inv.company ? `${inv.company} · ` : ''}${inv.email}`}
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border uppercase font-semibold ${STATUS_STYLES[inv.status] || ''}`}>
|
||||
{inv.status}
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border uppercase font-semibold ${inv.data_masked_at ? STATUS_STYLES.anonymized : (STATUS_STYLES[inv.status] || '')}`}>
|
||||
{inv.data_masked_at ? 'anonymized' : inv.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-white/70 font-mono">{inv.login_count}</td>
|
||||
@@ -189,12 +214,20 @@ export default function InvestorsPage() {
|
||||
<Eye className="w-4 h-4" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => resend(inv.id)}
|
||||
disabled={busy === inv.id || inv.status === 'revoked'}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-indigo-500/15 hover:text-indigo-300 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Resend magic link"
|
||||
onClick={() => generateLink(inv.id)}
|
||||
disabled={busy === inv.id || inv.status === 'revoked' || !!inv.data_masked_at}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-emerald-500/15 hover:text-emerald-300 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Generate & copy magic link (no email)"
|
||||
>
|
||||
{busy === inv.id ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Mail className="w-4 h-4" />}
|
||||
{busy === inv.id ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Link2 className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => resend(inv.id)}
|
||||
disabled={busy === inv.id || inv.status === 'revoked' || !!inv.data_masked_at}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-indigo-500/15 hover:text-indigo-300 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Resend magic link via email"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => revoke(inv.id, inv.email)}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import pool from '@/lib/db'
|
||||
|
||||
const MASKING_HOURS = parseInt(process.env.DATA_MASKING_HOURS || '72')
|
||||
|
||||
export async function maskOverdueInvestors(): Promise<void> {
|
||||
const cutoff = new Date(Date.now() - MASKING_HOURS * 60 * 60 * 1000)
|
||||
|
||||
const { rows } = await pool.query<{ id: string }>(
|
||||
`UPDATE pitch_investors
|
||||
SET email = 'anon.' || id::text,
|
||||
name = NULL,
|
||||
company = NULL,
|
||||
status = 'revoked',
|
||||
data_masked_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE first_activity_at IS NOT NULL
|
||||
AND first_activity_at < $1
|
||||
AND data_masked_at IS NULL
|
||||
RETURNING id`,
|
||||
[cutoff],
|
||||
)
|
||||
|
||||
if (rows.length > 0) {
|
||||
await pool.query(
|
||||
`UPDATE pitch_sessions SET revoked = true
|
||||
WHERE investor_id = ANY($1::uuid[]) AND NOT revoked`,
|
||||
[rows.map((r) => r.id)],
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Investor data masking: track first activity and enforce 72h anonymization window
|
||||
ALTER TABLE pitch_investors
|
||||
ADD COLUMN IF NOT EXISTS first_activity_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS data_masked_at TIMESTAMPTZ;
|
||||
|
||||
-- Partial index for fast masking-eligibility scans
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_investors_mask_check
|
||||
ON pitch_investors (first_activity_at)
|
||||
WHERE first_activity_at IS NOT NULL AND data_masked_at IS NULL;
|
||||
Reference in New Issue
Block a user