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

- 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:
Sharang Parnerkar
2026-04-30 14:55:29 +02:00
parent adfff6cfe4
commit 23b233bda3
9 changed files with 231 additions and 28 deletions
@@ -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>
<div className="text-sm text-white/60">{inv.company || '—'}</div>
<div className="text-xs text-white/40 mt-1">{inv.email}</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={() => setEditing(true)}
className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg"
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"
>
Edit
{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)}