feat(pitch-deck): admin UI for investor + financial-model management (#3)
All checks were successful
CI / test-go-consent (push) Successful in 42s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 30s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / Deploy (push) Successful in 2s

Adds /pitch-admin dashboard with real bcrypt admin accounts and full
audit attribution for every state-changing action.

- pitch_admins + pitch_admin_sessions tables (migration 002)
- pitch_audit_logs.admin_id + target_investor_id columns
- lib/admin-auth.ts: bcryptjs, single-session, jose JWT with audience claim
- middleware.ts: two-cookie gating with bearer-secret CLI fallback
- 14 new API routes (admin-auth, dashboard, investor detail/edit/resend,
  admins CRUD, fm scenarios + assumptions PATCH)
- 9 admin pages: login, dashboard, investors list/new/[id], audit,
  financial-model list/[id], admins
- Bootstrap CLI: npm run admin:create
- 36 vitest tests covering auth, admin-auth, rate-limit primitives

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #3.
This commit is contained in:
2026-04-07 10:36:16 +00:00
parent 645973141c
commit c7ab569b2b
41 changed files with 4850 additions and 69 deletions

View File

@@ -0,0 +1,259 @@
'use client'
import { useEffect, useState } from 'react'
import { Plus, Power, Key } from 'lucide-react'
interface Admin {
id: string
email: string
name: string
is_active: boolean
last_login_at: string | null
created_at: string
}
export default function AdminsPage() {
const [admins, setAdmins] = useState<Admin[]>([])
const [loading, setLoading] = useState(true)
const [showAdd, setShowAdd] = useState(false)
const [newEmail, setNewEmail] = useState('')
const [newName, setNewName] = useState('')
const [newPassword, setNewPassword] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [toast, setToast] = useState<string | null>(null)
const [resetId, setResetId] = useState<string | null>(null)
const [resetPassword, setResetPassword] = useState('')
function flashToast(msg: string) {
setToast(msg)
setTimeout(() => setToast(null), 3000)
}
async function load() {
setLoading(true)
const res = await fetch('/api/admin/admins')
if (res.ok) {
const d = await res.json()
setAdmins(d.admins || [])
}
setLoading(false)
}
useEffect(() => { load() }, [])
async function createAdmin(e: React.FormEvent) {
e.preventDefault()
setError('')
setBusy(true)
const res = await fetch('/api/admin/admins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: newEmail, name: newName, password: newPassword }),
})
setBusy(false)
if (res.ok) {
setShowAdd(false)
setNewEmail(''); setNewName(''); setNewPassword('')
flashToast('Admin created')
load()
} else {
const d = await res.json().catch(() => ({}))
setError(d.error || 'Create failed')
}
}
async function toggleActive(a: Admin) {
if (!confirm(`${a.is_active ? 'Deactivate' : 'Activate'} ${a.email}?`)) return
setBusy(true)
const res = await fetch(`/api/admin/admins/${a.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !a.is_active }),
})
setBusy(false)
if (res.ok) {
flashToast(a.is_active ? 'Deactivated' : 'Activated')
load()
} else {
flashToast('Update failed')
}
}
async function submitResetPassword(e: React.FormEvent) {
e.preventDefault()
if (!resetId) return
setBusy(true)
const res = await fetch(`/api/admin/admins/${resetId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: resetPassword }),
})
setBusy(false)
if (res.ok) {
flashToast('Password reset')
setResetId(null)
setResetPassword('')
} else {
const d = await res.json().catch(() => ({}))
flashToast(d.error || 'Reset failed')
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">Admins</h1>
<p className="text-sm text-white/50 mt-1">{admins.length} total</p>
</div>
<button
onClick={() => setShowAdd(s => !s)}
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white text-sm font-medium px-4 py-2 rounded-lg shadow-lg shadow-indigo-500/20 flex items-center gap-2"
>
<Plus className="w-4 h-4" /> Add admin
</button>
</div>
{showAdd && (
<form onSubmit={createAdmin} className="bg-white/[0.04] border border-white/[0.08] rounded-2xl p-5 space-y-3">
<div className="grid md:grid-cols-3 gap-3">
<input
type="email"
value={newEmail}
onChange={e => setNewEmail(e.target.value)}
required
placeholder="email@breakpilot.ai"
className="bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
/>
<input
type="text"
value={newName}
onChange={e => setNewName(e.target.value)}
required
placeholder="Name"
className="bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
/>
<input
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
required
minLength={12}
placeholder="Password (min 12 chars)"
className="bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
/>
</div>
{error && (
<div className="text-sm text-rose-300 bg-rose-500/10 border border-rose-500/20 rounded-lg px-3 py-2">{error}</div>
)}
<div className="flex justify-end gap-2">
<button type="button" onClick={() => { setShowAdd(false); setError('') }} className="text-sm text-white/60 hover:text-white px-4 py-2">Cancel</button>
<button type="submit" disabled={busy} className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-2 rounded-lg disabled:opacity-50">
{busy ? 'Creating…' : 'Create'}
</button>
</div>
</form>
)}
{loading ? (
<div className="flex items-center justify-center h-32"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
) : (
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wider text-white/40 border-b border-white/[0.06]">
<th className="py-3 px-4 font-medium">Admin</th>
<th className="py-3 px-4 font-medium">Status</th>
<th className="py-3 px-4 font-medium">Last login</th>
<th className="py-3 px-4 font-medium">Created</th>
<th className="py-3 px-4 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{admins.map(a => (
<tr key={a.id} className="border-b border-white/[0.04]">
<td className="py-3 px-4">
<div className="text-white/90 font-medium">{a.name}</div>
<div className="text-xs text-white/40">{a.email}</div>
</td>
<td className="py-3 px-4">
<span className={`text-[10px] px-2 py-0.5 rounded-full border uppercase font-semibold ${
a.is_active
? 'bg-green-500/15 text-green-300 border-green-500/30'
: 'bg-rose-500/15 text-rose-300 border-rose-500/30'
}`}>
{a.is_active ? 'Active' : 'Disabled'}
</span>
</td>
<td className="py-3 px-4 text-white/60 text-xs">
{a.last_login_at ? new Date(a.last_login_at).toLocaleString() : '—'}
</td>
<td className="py-3 px-4 text-white/60 text-xs">
{new Date(a.created_at).toLocaleDateString()}
</td>
<td className="py-3 px-4">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => { setResetId(a.id); setResetPassword('') }}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-amber-500/15 hover:text-amber-300"
title="Reset password"
>
<Key className="w-4 h-4" />
</button>
<button
onClick={() => toggleActive(a)}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-rose-500/15 hover:text-rose-300"
title={a.is_active ? 'Deactivate' : 'Activate'}
>
<Power className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Reset password modal */}
{resetId && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4" onClick={() => setResetId(null)}>
<form
onSubmit={submitResetPassword}
onClick={e => e.stopPropagation()}
className="bg-[#0a0a1a] border border-white/[0.1] rounded-2xl p-6 w-full max-w-sm space-y-4"
>
<h3 className="text-lg font-semibold text-white">Reset Password</h3>
<p className="text-sm text-white/60">
The admin's active sessions will be revoked.
</p>
<input
type="password"
value={resetPassword}
onChange={e => setResetPassword(e.target.value)}
required
minLength={12}
autoFocus
placeholder="New password (min 12 chars)"
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
/>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setResetId(null)} className="text-sm text-white/60 hover:text-white px-4 py-2">Cancel</button>
<button type="submit" disabled={busy} className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-2 rounded-lg disabled:opacity-50">
{busy ? 'Saving' : 'Reset'}
</button>
</div>
</form>
</div>
)}
{toast && (
<div className="fixed bottom-6 right-6 bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 text-sm px-4 py-2 rounded-lg backdrop-blur-sm">
{toast}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,130 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import AuditLogTable, { AuditLogRow } from '@/components/pitch-admin/AuditLogTable'
const ACTIONS = [
'', // any
'login_success',
'login_failed',
'logout',
'admin_login_success',
'admin_login_failed',
'admin_logout',
'slide_viewed',
'assumption_changed',
'assumption_edited',
'scenario_edited',
'investor_invited',
'magic_link_resent',
'investor_revoked',
'investor_edited',
'admin_created',
'admin_edited',
'admin_deactivated',
'new_ip_detected',
]
const PAGE_SIZE = 50
export default function AuditPage() {
const [logs, setLogs] = useState<AuditLogRow[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [actorType, setActorType] = useState('')
const [action, setAction] = useState('')
const [page, setPage] = useState(0)
const load = useCallback(async () => {
setLoading(true)
const params = new URLSearchParams()
if (actorType) params.set('actor_type', actorType)
if (action) params.set('action', action)
params.set('limit', String(PAGE_SIZE))
params.set('offset', String(page * PAGE_SIZE))
const res = await fetch(`/api/admin/audit-logs?${params.toString()}`)
if (res.ok) {
const data = await res.json()
setLogs(data.logs)
setTotal(data.total)
}
setLoading(false)
}, [actorType, action, page])
useEffect(() => { load() }, [load])
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-white">Audit Log</h1>
<p className="text-sm text-white/50 mt-1">{total} total events</p>
</div>
<div className="flex items-center gap-3 flex-wrap">
<select
value={actorType}
onChange={(e) => { setActorType(e.target.value); setPage(0) }}
className="bg-white/[0.04] border border-white/[0.08] rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
>
<option value="">All actors</option>
<option value="admin">Admins only</option>
<option value="investor">Investors only</option>
</select>
<select
value={action}
onChange={(e) => { setAction(e.target.value); setPage(0) }}
className="bg-white/[0.04] border border-white/[0.08] rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 max-w-[260px]"
>
{ACTIONS.map(a => (
<option key={a} value={a}>{a || 'All actions'}</option>
))}
</select>
{(actorType || action) && (
<button
onClick={() => { setActorType(''); setAction(''); setPage(0) }}
className="text-sm text-white/50 hover:text-white px-3 py-2"
>
Clear filters
</button>
)}
</div>
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
{loading ? (
<div className="flex items-center justify-center h-32">
<div className="w-6 h-6 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
</div>
) : (
<AuditLogTable rows={logs} showActor />
)}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between text-sm">
<div className="text-white/50">
Page {page + 1} of {totalPages}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
className="bg-white/[0.06] hover:bg-white/[0.1] text-white px-3 py-1.5 rounded-lg disabled:opacity-30"
>
Previous
</button>
<button
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="bg-white/[0.06] hover:bg-white/[0.1] text-white px-3 py-1.5 rounded-lg disabled:opacity-30"
>
Next
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,184 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, Save } from 'lucide-react'
interface Assumption {
id: string
scenario_id: string
key: string
label_de: string
label_en: string
value: number | number[]
value_type: 'scalar' | 'step' | 'timeseries'
unit: string
min_value: number | null
max_value: number | null
step_size: number | null
category: string
sort_order: number
}
interface Scenario {
id: string
name: string
description: string
is_default: boolean
color: string
assumptions: Assumption[]
}
export default function EditScenarioPage() {
const { scenarioId } = useParams<{ scenarioId: string }>()
const [scenario, setScenario] = useState<Scenario | null>(null)
const [loading, setLoading] = useState(true)
const [edits, setEdits] = useState<Record<string, string>>({})
const [savingId, setSavingId] = useState<string | null>(null)
const [toast, setToast] = useState<string | null>(null)
function flashToast(msg: string) {
setToast(msg)
setTimeout(() => setToast(null), 3000)
}
async function load() {
setLoading(true)
const res = await fetch('/api/admin/fm/scenarios')
if (res.ok) {
const d = await res.json()
const found = (d.scenarios as Scenario[]).find(s => s.id === scenarioId)
setScenario(found || null)
}
setLoading(false)
}
useEffect(() => { if (scenarioId) load() }, [scenarioId])
function setEdit(id: string, val: string) {
setEdits(prev => ({ ...prev, [id]: val }))
}
async function saveAssumption(a: Assumption) {
const raw = edits[a.id]
if (raw === undefined) return
let parsed: number | number[]
try {
parsed = a.value_type === 'timeseries' ? JSON.parse(raw) : Number(raw)
if (a.value_type !== 'timeseries' && !Number.isFinite(parsed)) throw new Error('not a number')
} catch {
flashToast('Invalid value')
return
}
setSavingId(a.id)
const res = await fetch(`/api/admin/fm/assumptions/${a.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: parsed }),
})
setSavingId(null)
if (res.ok) {
flashToast('Saved')
setEdits(prev => {
const next = { ...prev }
delete next[a.id]
return next
})
load()
} else {
flashToast('Save failed')
}
}
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
if (!scenario) return <div className="text-rose-400">Scenario not found</div>
// Group by category
const byCategory: Record<string, Assumption[]> = {}
scenario.assumptions.forEach(a => {
if (!byCategory[a.category]) byCategory[a.category] = []
byCategory[a.category].push(a)
})
return (
<div className="space-y-6">
<Link href="/pitch-admin/financial-model" className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80">
<ArrowLeft className="w-4 h-4" /> Back to scenarios
</Link>
<div>
<div className="flex items-center gap-3 mb-1">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: scenario.color }} />
<h1 className="text-2xl font-semibold text-white">{scenario.name}</h1>
{scenario.is_default && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-indigo-500/20 text-indigo-300 uppercase font-semibold">
Default
</span>
)}
</div>
{scenario.description && <p className="text-sm text-white/50">{scenario.description}</p>}
</div>
<div className="space-y-6">
{Object.entries(byCategory).map(([cat, items]) => (
<section key={cat} className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
<h2 className="text-xs font-semibold uppercase tracking-wider text-white/50 mb-4">{cat}</h2>
<div className="space-y-3">
{items.map(a => {
const isEdited = edits[a.id] !== undefined
const currentValue = isEdited
? edits[a.id]
: a.value_type === 'timeseries'
? JSON.stringify(a.value)
: String(a.value)
return (
<div key={a.id} className="grid grid-cols-12 gap-3 items-center">
<div className="col-span-5 min-w-0">
<div className="text-sm text-white/90 truncate">{a.label_en || a.label_de}</div>
<div className="text-xs text-white/40 font-mono truncate">{a.key}</div>
</div>
<div className="col-span-4 flex items-center gap-2">
<input
type="text"
value={currentValue}
onChange={e => setEdit(a.id, e.target.value)}
className={`flex-1 bg-black/30 border rounded-lg px-3 py-1.5 text-sm text-white font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500/40 ${
isEdited ? 'border-amber-500/50' : 'border-white/10'
}`}
/>
{a.unit && <span className="text-xs text-white/40">{a.unit}</span>}
</div>
<div className="col-span-2 text-xs text-white/30 font-mono">
{a.min_value !== null && a.max_value !== null ? `${a.min_value}${a.max_value}` : ''}
</div>
<div className="col-span-1 flex justify-end">
{isEdited && (
<button
onClick={() => saveAssumption(a)}
disabled={savingId === a.id}
className="bg-indigo-500 hover:bg-indigo-600 text-white p-1.5 rounded-lg disabled:opacity-50"
title="Save"
>
<Save className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
)
})}
</div>
</section>
))}
</div>
{toast && (
<div className="fixed bottom-6 right-6 bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 text-sm px-4 py-2 rounded-lg backdrop-blur-sm">
{toast}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,73 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { ArrowRight } from 'lucide-react'
interface Scenario {
id: string
name: string
description: string
is_default: boolean
color: string
assumptions: Array<{ id: string; key: string }>
}
export default function FinancialModelPage() {
const [scenarios, setScenarios] = useState<Scenario[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/admin/fm/scenarios')
.then(r => r.json())
.then(d => setScenarios(d.scenarios || []))
.finally(() => setLoading(false))
}, [])
if (loading) {
return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-white">Financial Model</h1>
<p className="text-sm text-white/50 mt-1">
Edit default scenarios and assumptions. Investor snapshots are not affected.
</p>
</div>
<div className="grid md:grid-cols-2 gap-4">
{scenarios.map(s => (
<Link
key={s.id}
href={`/pitch-admin/financial-model/${s.id}`}
className="bg-white/[0.04] border border-white/[0.06] hover:border-white/[0.15] rounded-2xl p-5 transition-colors block"
>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: s.color }}
/>
<h3 className="text-base font-semibold text-white">{s.name}</h3>
{s.is_default && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-indigo-500/20 text-indigo-300 uppercase font-semibold">
Default
</span>
)}
</div>
{s.description && <p className="text-sm text-white/50">{s.description}</p>}
</div>
<ArrowRight className="w-4 h-4 text-white/30 mt-1 shrink-0" />
</div>
<div className="text-xs text-white/40">
{s.assumptions.length} assumption{s.assumptions.length === 1 ? '' : 's'}
</div>
</Link>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,252 @@
'use client'
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 AuditLogTable from '@/components/pitch-admin/AuditLogTable'
interface InvestorDetail {
investor: {
id: string
email: string
name: string | null
company: string | null
status: string
last_login_at: string | null
login_count: number
created_at: string
}
sessions: Array<{
id: string
ip_address: string | null
user_agent: string | null
expires_at: string
revoked: boolean
created_at: string
}>
snapshots: Array<{
id: string
scenario_id: string
label: string | null
is_latest: boolean
created_at: string
}>
audit: Array<{
id: number
action: string
created_at: string
details: Record<string, unknown> | null
ip_address: string | null
slide_id: string | null
admin_email: string | null
admin_name: 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',
}
export default function InvestorDetailPage() {
const { id } = useParams<{ id: string }>()
const router = useRouter()
const [data, setData] = useState<InvestorDetail | null>(null)
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState(false)
const [name, setName] = useState('')
const [company, setCompany] = useState('')
const [busy, setBusy] = useState(false)
const [toast, setToast] = useState<string | null>(null)
function flashToast(msg: string) {
setToast(msg)
setTimeout(() => setToast(null), 3000)
}
async function load() {
setLoading(true)
const res = await fetch(`/api/admin/investors/${id}`)
if (res.ok) {
const d = await res.json()
setData(d)
setName(d.investor.name || '')
setCompany(d.investor.company || '')
}
setLoading(false)
}
useEffect(() => { if (id) load() }, [id])
async function save() {
setBusy(true)
const res = await fetch(`/api/admin/investors/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, company }),
})
setBusy(false)
if (res.ok) {
setEditing(false)
flashToast('Saved')
load()
} else {
flashToast('Save failed')
}
}
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')
load()
} else {
const err = await res.json().catch(() => ({}))
flashToast(err.error || 'Resend failed')
}
}
async function revoke() {
if (!confirm('Revoke this investor\'s access? This signs them out and prevents future logins.')) return
setBusy(true)
const res = await fetch('/api/admin/revoke', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ investor_id: id }),
})
setBusy(false)
if (res.ok) {
flashToast('Revoked')
load()
} else {
flashToast('Revoke failed')
}
}
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
if (!data) return <div className="text-rose-400">Investor not found</div>
const inv = data.investor
return (
<div className="space-y-6">
<Link href="/pitch-admin/investors" className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80">
<ArrowLeft className="w-4 h-4" /> Back to investors
</Link>
{/* Header */}
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-6">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
{editing ? (
<div className="space-y-3">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-white text-lg font-semibold focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
/>
<input
value={company}
onChange={(e) => setCompany(e.target.value)}
placeholder="Company"
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
/>
</div>
) : (
<>
<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}
</span>
</div>
<div className="text-sm text-white/60">{inv.company || '—'}</div>
<div className="text-xs text-white/40 mt-1">{inv.email}</div>
</>
)}
</div>
<div className="flex items-center gap-2">
{editing ? (
<>
<button
onClick={() => { setEditing(false); setName(inv.name || ''); setCompany(inv.company || '') }}
className="text-sm text-white/60 hover:text-white px-3 py-2"
>
Cancel
</button>
<button
onClick={save}
disabled={busy}
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-2 rounded-lg flex items-center gap-2 disabled:opacity-50"
>
<Save className="w-4 h-4" /> Save
</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"
>
Edit
</button>
<button
onClick={resend}
disabled={busy || 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
</button>
<button
onClick={revoke}
disabled={busy || inv.status === 'revoked'}
className="bg-rose-500/15 hover:bg-rose-500/25 text-rose-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2 disabled:opacity-30"
>
<Ban className="w-4 h-4" /> Revoke
</button>
</>
)}
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mt-6 pt-6 border-t border-white/[0.06]">
<div>
<div className="text-xs text-white/40 uppercase tracking-wider">Logins</div>
<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-sm text-white/80 mt-1">
{inv.last_login_at ? new Date(inv.last_login_at).toLocaleString() : '—'}
</div>
</div>
<div>
<div className="text-xs text-white/40 uppercase tracking-wider">Sessions</div>
<div className="text-xl text-white font-semibold mt-1">{data.sessions.length}</div>
</div>
<div>
<div className="text-xs text-white/40 uppercase tracking-wider">Snapshots</div>
<div className="text-xl text-white font-semibold mt-1">{data.snapshots.length}</div>
</div>
</div>
</div>
{/* Audit log for this investor */}
<section className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
<h2 className="text-sm font-semibold text-white mb-4">Activity</h2>
<AuditLogTable rows={data.audit} showActor />
</section>
{toast && (
<div className="fixed bottom-6 right-6 bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 text-sm px-4 py-2 rounded-lg backdrop-blur-sm">
{toast}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,125 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
export default function NewInvestorPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [name, setName] = useState('')
const [company, setCompany] = useState('')
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setSubmitting(true)
try {
const res = await fetch('/api/admin/invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, name, company }),
})
if (res.ok) {
router.push('/pitch-admin/investors')
router.refresh()
} else {
const data = await res.json().catch(() => ({}))
setError(data.error || 'Invite failed')
}
} catch {
setError('Network error')
} finally {
setSubmitting(false)
}
}
return (
<div className="max-w-xl">
<Link
href="/pitch-admin/investors"
className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80 mb-6"
>
<ArrowLeft className="w-4 h-4" /> Back to investors
</Link>
<h1 className="text-2xl font-semibold text-white mb-2">Invite Investor</h1>
<p className="text-sm text-white/50 mb-6">
A magic link will be emailed. Single-use, expires in {process.env.NEXT_PUBLIC_MAGIC_LINK_TTL_HOURS || '72'}h.
</p>
<form
onSubmit={handleSubmit}
className="bg-white/[0.04] border border-white/[0.08] rounded-2xl p-6 space-y-4"
>
<div>
<label htmlFor="email" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
Email <span className="text-rose-400">*</span>
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
placeholder="jane@vc.com"
/>
</div>
<div>
<label htmlFor="name" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
Name
</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
placeholder="Jane Doe"
/>
</div>
<div>
<label htmlFor="company" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
Company
</label>
<input
id="company"
type="text"
value={company}
onChange={(e) => setCompany(e.target.value)}
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
placeholder="Acme Ventures"
/>
</div>
{error && (
<div className="text-sm text-rose-300 bg-rose-500/10 border border-rose-500/20 rounded-lg px-3 py-2">
{error}
</div>
)}
<div className="flex items-center justify-end gap-3 pt-2">
<Link
href="/pitch-admin/investors"
className="text-sm text-white/60 hover:text-white px-4 py-2"
>
Cancel
</Link>
<button
type="submit"
disabled={submitting}
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white text-sm font-medium px-5 py-2.5 rounded-lg disabled:opacity-50 shadow-lg shadow-indigo-500/20"
>
{submitting ? 'Sending…' : 'Send invite'}
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,213 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { Search, Mail, Ban, Eye, RefreshCw } from 'lucide-react'
interface Investor {
id: string
email: string
name: string | null
company: string | null
status: string
last_login_at: string | null
login_count: number
created_at: string
slides_viewed: number
last_activity: 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',
}
export default function InvestorsPage() {
const [investors, setInvestors] = useState<Investor[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
const [busy, setBusy] = useState<string | null>(null)
const [toast, setToast] = useState<string | null>(null)
async function load() {
setLoading(true)
const res = await fetch('/api/admin/investors')
if (res.ok) {
const data = await res.json()
setInvestors(data.investors)
}
setLoading(false)
}
useEffect(() => { load() }, [])
function flashToast(msg: string) {
setToast(msg)
setTimeout(() => setToast(null), 3000)
}
async function resend(id: string) {
setBusy(id)
const res = await fetch(`/api/admin/investors/${id}/resend`, { method: 'POST' })
setBusy(null)
if (res.ok) flashToast('Magic link resent')
else {
const err = await res.json().catch(() => ({}))
flashToast(err.error || 'Resend failed')
}
}
async function revoke(id: string, email: string) {
if (!confirm(`Revoke access for ${email}? This signs them out and prevents future logins.`)) return
setBusy(id)
const res = await fetch('/api/admin/revoke', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ investor_id: id }),
})
setBusy(null)
if (res.ok) {
flashToast('Access revoked')
load()
} else {
flashToast('Revoke failed')
}
}
const filtered = investors.filter((i) => {
if (statusFilter !== 'all' && i.status !== statusFilter) return false
if (search) {
const q = search.toLowerCase()
return (
i.email.toLowerCase().includes(q) ||
(i.name || '').toLowerCase().includes(q) ||
(i.company || '').toLowerCase().includes(q)
)
}
return true
})
return (
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h1 className="text-2xl font-semibold text-white">Investors</h1>
<p className="text-sm text-white/50 mt-1">{investors.length} total · {filtered.length} shown</p>
</div>
<Link
href="/pitch-admin/investors/new"
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white text-sm font-medium px-4 py-2 rounded-lg shadow-lg shadow-indigo-500/20"
>
+ Invite Investor
</Link>
</div>
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 min-w-[240px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search name, email, or company"
className="w-full bg-white/[0.04] border border-white/[0.08] rounded-lg pl-10 pr-3 py-2 text-sm text-white placeholder:text-white/30 focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="bg-white/[0.04] border border-white/[0.08] rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
>
<option value="all">All statuses</option>
<option value="invited">Invited</option>
<option value="active">Active</option>
<option value="revoked">Revoked</option>
</select>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
</div>
) : (
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wider text-white/40 border-b border-white/[0.06]">
<th className="py-3 px-4 font-medium">Investor</th>
<th className="py-3 px-4 font-medium">Status</th>
<th className="py-3 px-4 font-medium text-right">Logins</th>
<th className="py-3 px-4 font-medium text-right">Slides</th>
<th className="py-3 px-4 font-medium">Last login</th>
<th className="py-3 px-4 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr>
<td colSpan={6} className="py-12 text-center text-white/40">No investors</td>
</tr>
)}
{filtered.map((inv) => (
<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-xs text-white/40 truncate">
{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>
</td>
<td className="py-3 px-4 text-right text-white/70 font-mono">{inv.login_count}</td>
<td className="py-3 px-4 text-right text-white/70 font-mono">{inv.slides_viewed}</td>
<td className="py-3 px-4 text-white/50 text-xs whitespace-nowrap">
{inv.last_login_at ? new Date(inv.last_login_at).toLocaleDateString() : '—'}
</td>
<td className="py-3 px-4">
<div className="flex items-center justify-end gap-1">
<Link
href={`/pitch-admin/investors/${inv.id}`}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-white/[0.06] hover:text-white"
title="View"
>
<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"
>
{busy === inv.id ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Mail className="w-4 h-4" />}
</button>
<button
onClick={() => revoke(inv.id, inv.email)}
disabled={busy === inv.id || inv.status === 'revoked'}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-rose-500/15 hover:text-rose-300 disabled:opacity-30 disabled:cursor-not-allowed"
title="Revoke"
>
<Ban className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{toast && (
<div className="fixed bottom-6 right-6 bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 text-sm px-4 py-2 rounded-lg backdrop-blur-sm">
{toast}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,18 @@
import { redirect } from 'next/navigation'
import { getAdminFromCookie } from '@/lib/admin-auth'
import AdminShell from '@/components/pitch-admin/AdminShell'
export const dynamic = 'force-dynamic'
export default async function AuthedAdminLayout({ children }: { children: React.ReactNode }) {
const admin = await getAdminFromCookie()
if (!admin) {
redirect('/pitch-admin/login')
}
return (
<AdminShell admin={{ id: admin.id, email: admin.email, name: admin.name }}>
{children}
</AdminShell>
)
}

View File

@@ -0,0 +1,142 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { Users, UserCheck, Mail, Eye, ArrowRight } from 'lucide-react'
import StatCard from '@/components/pitch-admin/StatCard'
import AuditLogTable from '@/components/pitch-admin/AuditLogTable'
interface DashboardData {
totals: {
total_investors: number
pending_invites: number
active_7d: number
slides_viewed_total: number
active_sessions: number
active_admins: number
}
recent_logins: Array<{
investor_id: string
email: string
name: string | null
company: string | null
created_at: string
ip_address: string | null
}>
recent_activity: Array<{
id: number
action: string
created_at: string
details: Record<string, unknown> | null
investor_email: string | null
investor_name: string | null
target_investor_email: string | null
admin_email: string | null
admin_name: string | null
}>
}
export default function DashboardPage() {
const [data, setData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/admin/dashboard')
.then((r) => r.json())
.then(setData)
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (!data) return <div className="text-rose-400">Failed to load dashboard</div>
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">Dashboard</h1>
<p className="text-sm text-white/50 mt-1">Investor activity overview</p>
</div>
<Link
href="/pitch-admin/investors/new"
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white text-sm font-medium px-4 py-2 rounded-lg shadow-lg shadow-indigo-500/20 transition-all"
>
+ Invite Investor
</Link>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard label="Total Investors" value={data.totals.total_investors} icon={Users} accent="indigo" />
<StatCard
label="Active (7d)"
value={data.totals.active_7d}
icon={UserCheck}
accent="green"
hint={`${data.totals.active_sessions} live sessions`}
/>
<StatCard
label="Pending Invites"
value={data.totals.pending_invites}
icon={Mail}
accent="amber"
/>
<StatCard
label="Slides Viewed"
value={data.totals.slides_viewed_total}
icon={Eye}
accent="indigo"
hint="all-time"
/>
</div>
<div className="grid lg:grid-cols-2 gap-6">
<section className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-white">Recent Logins</h2>
<Link href="/pitch-admin/investors" className="text-xs text-indigo-400 hover:text-indigo-300 flex items-center gap-1">
All investors <ArrowRight className="w-3 h-3" />
</Link>
</div>
{data.recent_logins.length === 0 ? (
<div className="text-white/40 text-sm py-8 text-center">No logins yet</div>
) : (
<ul className="space-y-2">
{data.recent_logins.map((row, i) => (
<li key={i} className="flex items-center justify-between text-sm py-2 border-b border-white/[0.04] last:border-0">
<div className="min-w-0">
<Link href={`/pitch-admin/investors/${row.investor_id}`} className="text-white/90 hover:text-indigo-300 truncate block">
{row.name || row.email}
</Link>
<div className="text-xs text-white/40 truncate">{row.company || row.email}</div>
</div>
<div className="text-xs text-white/40 whitespace-nowrap ml-3">
{new Date(row.created_at).toLocaleString()}
</div>
</li>
))}
</ul>
)}
</section>
<section className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-white">Recent Activity</h2>
<Link href="/pitch-admin/audit" className="text-xs text-indigo-400 hover:text-indigo-300 flex items-center gap-1">
Full log <ArrowRight className="w-3 h-3" />
</Link>
</div>
<div className="-mx-5">
<AuditLogTable rows={data.recent_activity.slice(0, 8)} />
</div>
</section>
</div>
</div>
)
}

View File

@@ -0,0 +1,110 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export default function AdminLoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setSubmitting(true)
try {
const res = await fetch('/api/admin-auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (res.ok) {
router.push('/pitch-admin')
router.refresh()
} else {
const data = await res.json().catch(() => ({}))
setError(data.error || 'Login failed')
}
} catch {
setError('Network error')
} finally {
setSubmitting(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-[#0a0a1a] relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-indigo-950/30 via-transparent to-purple-950/20" />
<div className="relative z-10 w-full max-w-sm px-6">
<div className="text-center mb-8">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center shadow-lg shadow-indigo-500/30">
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
<path d="M8 12L20 6L32 12V28L20 34L8 28V12Z" stroke="white" strokeWidth="2" fill="none" />
<circle cx="20" cy="20" r="4" fill="white" opacity="0.8" />
</svg>
</div>
<h1 className="text-2xl font-semibold text-white mb-1">Pitch Admin</h1>
<p className="text-sm text-white/40">BreakPilot ComplAI</p>
</div>
<form
onSubmit={handleSubmit}
className="bg-white/[0.04] border border-white/[0.08] rounded-2xl p-6 backdrop-blur-sm space-y-4"
>
<div>
<label htmlFor="email" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username"
required
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white placeholder:text-white/30 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500/60"
placeholder="you@breakpilot.ai"
/>
</div>
<div>
<label htmlFor="password" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white placeholder:text-white/30 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500/60"
placeholder="••••••••"
/>
</div>
{error && (
<div className="text-sm text-rose-300 bg-rose-500/10 border border-rose-500/20 rounded-lg px-3 py-2">
{error}
</div>
)}
<button
type="submit"
disabled={submitting}
className="w-full bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white font-medium py-2.5 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-indigo-500/20"
>
{submitting ? 'Signing in…' : 'Sign in'}
</button>
</form>
<p className="text-center text-xs text-white/30 mt-6">
Admin access only. All actions are logged.
</p>
</div>
</div>
)
}