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:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user