feat(pitch-deck): admin UI for investor + financial-model management
Some checks failed
CI / Deploy (pull_request) Has been skipped
CI / go-lint (pull_request) Failing after 2s
CI / python-lint (pull_request) Failing after 11s
CI / nodejs-lint (pull_request) Failing after 2s
CI / test-go-consent (pull_request) Failing after 2s
CI / test-python-voice (pull_request) Failing after 9s
CI / test-bqas (pull_request) Failing after 8s

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

Backend:
- pitch_admins + pitch_admin_sessions tables (migration 002)
- pitch_audit_logs.admin_id + target_investor_id columns
- lib/admin-auth.ts: bcryptjs hashing, single-session enforcement,
  jose JWT with 'pitch-admin' audience claim, requireAdmin guard
- logAudit extended to accept admin_id and target_investor_id
- middleware.ts: gates /pitch-admin/* and /api/admin/* on the admin
  cookie (with bearer-secret fallback for CLI compatibility)
- 14 API routes under /api/admin-auth and /api/admin (login, logout,
  me, dashboard, investors[id] CRUD + resend, admins CRUD,
  fm scenarios + assumptions PATCH)
- Existing /api/admin/{invite,investors,revoke,audit-logs} migrated
  to requireAdmin and now log with admin_id + target_investor_id
- scripts/create-admin.ts CLI bootstrap (npm run admin:create)

Frontend:
- /pitch-admin/login + /pitch-admin/(authed) route group
- AdminShell with sidebar nav + StatCard + AuditLogTable components
- Dashboard with KPIs, recent logins, recent activity
- Investors list with search/filter + resend/revoke inline actions
- Investor detail with inline edit + per-investor audit timeline
- Audit log viewer with actor/action/date filters + pagination
- Financial model scenario list + per-scenario assumption editor
  (categorized, inline edit, before/after diff in audit)
- Admins management (add, deactivate, reset password)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-07 11:27:18 +02:00
parent 645973141c
commit fc71439011
36 changed files with 3424 additions and 66 deletions

View File

@@ -189,6 +189,10 @@ services:
PITCH_ADMIN_SECRET: ${PITCH_ADMIN_SECRET}
PITCH_BASE_URL: ${PITCH_BASE_URL:-https://pitch.breakpilot.ai}
MAGIC_LINK_TTL_HOURS: ${MAGIC_LINK_TTL_HOURS:-72}
# Optional: bootstrap first admin via `npm run admin:create` inside the container.
PITCH_ADMIN_BOOTSTRAP_EMAIL: ${PITCH_ADMIN_BOOTSTRAP_EMAIL:-}
PITCH_ADMIN_BOOTSTRAP_NAME: ${PITCH_ADMIN_BOOTSTRAP_NAME:-}
PITCH_ADMIN_BOOTSTRAP_PASSWORD: ${PITCH_ADMIN_BOOTSTRAP_PASSWORD:-}
SMTP_HOST: ${SMTP_HOST}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USERNAME: ${SMTP_USERNAME}

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { verifyPassword, createAdminSession, setAdminCookie, logAdminAudit } from '@/lib/admin-auth'
import { getClientIp } from '@/lib/auth'
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
export async function POST(request: NextRequest) {
const ip = getClientIp(request) || 'unknown'
// Reuse the auth-verify rate limit (10/IP/15min)
const rl = checkRateLimit(`admin-login:${ip}`, RATE_LIMITS.authVerify)
if (!rl.allowed) {
return NextResponse.json({ error: 'Too many attempts. Try again later.' }, { status: 429 })
}
const body = await request.json().catch(() => ({}))
const email = (body.email || '').trim().toLowerCase()
const password = body.password || ''
if (!email || !password) {
return NextResponse.json({ error: 'Email and password required' }, { status: 400 })
}
const { rows } = await pool.query(
`SELECT id, email, name, password_hash, is_active FROM pitch_admins WHERE email = $1`,
[email],
)
if (rows.length === 0) {
await logAdminAudit(null, 'admin_login_failed', { email, reason: 'unknown_email' }, request)
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
}
const admin = rows[0]
if (!admin.is_active) {
await logAdminAudit(admin.id, 'admin_login_failed', { reason: 'inactive' }, request)
return NextResponse.json({ error: 'Account disabled' }, { status: 403 })
}
const ok = await verifyPassword(password, admin.password_hash)
if (!ok) {
await logAdminAudit(admin.id, 'admin_login_failed', { reason: 'wrong_password' }, request)
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
}
const ua = request.headers.get('user-agent')
const { jwt } = await createAdminSession(admin.id, ip, ua)
await setAdminCookie(jwt)
await pool.query(
`UPDATE pitch_admins SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1`,
[admin.id],
)
await logAdminAudit(admin.id, 'admin_login_success', { email }, request)
return NextResponse.json({
success: true,
admin: { id: admin.id, email: admin.email, name: admin.name },
})
}

View File

@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'
import {
getAdminPayloadFromCookie,
revokeAdminSession,
clearAdminCookie,
logAdminAudit,
} from '@/lib/admin-auth'
export async function POST(request: NextRequest) {
const payload = await getAdminPayloadFromCookie()
if (payload) {
await revokeAdminSession(payload.sessionId)
await logAdminAudit(payload.sub, 'admin_logout', {}, request)
}
await clearAdminCookie()
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,10 @@
import { NextResponse } from 'next/server'
import { getAdminFromCookie } from '@/lib/admin-auth'
export async function GET() {
const admin = await getAdminFromCookie()
if (!admin) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
}
return NextResponse.json({ admin })
}

View File

@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin, logAdminAudit, hashPassword, revokeAllAdminSessions } from '@/lib/admin-auth'
interface RouteContext {
params: Promise<{ id: string }>
}
export async function PATCH(request: NextRequest, ctx: RouteContext) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const actorAdminId = guard.kind === 'admin' ? guard.admin.id : null
const { id } = await ctx.params
const body = await request.json().catch(() => ({}))
const { name, is_active, password } = body
const before = await pool.query(
`SELECT email, name, is_active FROM pitch_admins WHERE id = $1`,
[id],
)
if (before.rows.length === 0) {
return NextResponse.json({ error: 'Admin not found' }, { status: 404 })
}
const updates: string[] = []
const params: unknown[] = []
let p = 1
if (typeof name === 'string' && name.trim()) {
updates.push(`name = $${p++}`)
params.push(name.trim())
}
if (typeof is_active === 'boolean') {
updates.push(`is_active = $${p++}`)
params.push(is_active)
}
if (typeof password === 'string') {
if (password.length < 12) {
return NextResponse.json({ error: 'password must be at least 12 characters' }, { status: 400 })
}
const hash = await hashPassword(password)
updates.push(`password_hash = $${p++}`)
params.push(hash)
}
if (updates.length === 0) {
return NextResponse.json({ error: 'no fields to update' }, { status: 400 })
}
updates.push(`updated_at = NOW()`)
params.push(id)
const { rows } = await pool.query(
`UPDATE pitch_admins SET ${updates.join(', ')}
WHERE id = $${p}
RETURNING id, email, name, is_active, last_login_at, created_at`,
params,
)
// If deactivated or password changed, revoke their sessions
if (is_active === false || typeof password === 'string') {
await revokeAllAdminSessions(id)
}
const action = is_active === false ? 'admin_deactivated' : 'admin_edited'
await logAdminAudit(
actorAdminId,
action,
{
target_admin_id: id,
target_email: before.rows[0].email,
before: before.rows[0],
after: { name: rows[0].name, is_active: rows[0].is_active },
password_changed: typeof password === 'string',
},
request,
)
return NextResponse.json({ admin: rows[0] })
}

View File

@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin, logAdminAudit, hashPassword } from '@/lib/admin-auth'
export async function GET(request: NextRequest) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const { rows } = await pool.query(
`SELECT id, email, name, is_active, last_login_at, created_at, updated_at
FROM pitch_admins ORDER BY created_at ASC`,
)
return NextResponse.json({ admins: rows })
}
export async function POST(request: NextRequest) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const adminId = guard.kind === 'admin' ? guard.admin.id : null
const body = await request.json().catch(() => ({}))
const email = (body.email || '').trim().toLowerCase()
const name = (body.name || '').trim()
const password = body.password || ''
if (!email || !name || !password) {
return NextResponse.json({ error: 'email, name, password required' }, { status: 400 })
}
if (password.length < 12) {
return NextResponse.json({ error: 'password must be at least 12 characters' }, { status: 400 })
}
const hash = await hashPassword(password)
try {
const { rows } = await pool.query(
`INSERT INTO pitch_admins (email, name, password_hash, is_active)
VALUES ($1, $2, $3, true)
RETURNING id, email, name, is_active, created_at`,
[email, name, hash],
)
const newAdmin = rows[0]
await logAdminAudit(adminId, 'admin_created', { email, name, new_admin_id: newAdmin.id }, request)
return NextResponse.json({ admin: newAdmin })
} catch (err) {
const e = err as { code?: string }
if (e.code === '23505') {
return NextResponse.json({ error: 'Email already exists' }, { status: 409 })
}
throw err
}
}

View File

@@ -1,42 +1,77 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { validateAdminSecret } from '@/lib/auth'
import { requireAdmin } from '@/lib/admin-auth'
export async function GET(request: NextRequest) {
if (!validateAdminSecret(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const { searchParams } = new URL(request.url)
const investorId = searchParams.get('investor_id')
const targetInvestorId = searchParams.get('target_investor_id')
const adminId = searchParams.get('admin_id')
const actorType = searchParams.get('actor_type') // 'admin' | 'investor'
const action = searchParams.get('action')
const since = searchParams.get('since') // ISO date
const until = searchParams.get('until')
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
const offset = parseInt(searchParams.get('offset') || '0')
const conditions: string[] = []
const params: unknown[] = []
let paramIdx = 1
let p = 1
if (investorId) {
conditions.push(`a.investor_id = $${paramIdx++}`)
conditions.push(`a.investor_id = $${p++}`)
params.push(investorId)
}
if (targetInvestorId) {
conditions.push(`a.target_investor_id = $${p++}`)
params.push(targetInvestorId)
}
if (adminId) {
conditions.push(`a.admin_id = $${p++}`)
params.push(adminId)
}
if (actorType === 'admin') {
conditions.push(`a.admin_id IS NOT NULL`)
} else if (actorType === 'investor') {
conditions.push(`a.investor_id IS NOT NULL`)
}
if (action) {
conditions.push(`a.action = $${paramIdx++}`)
conditions.push(`a.action = $${p++}`)
params.push(action)
}
if (since) {
conditions.push(`a.created_at >= $${p++}`)
params.push(since)
}
if (until) {
conditions.push(`a.created_at <= $${p++}`)
params.push(until)
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
const { rows } = await pool.query(
`SELECT a.*, i.email as investor_email, i.name as investor_name
`SELECT a.*,
i.email AS investor_email, i.name AS investor_name,
ti.email AS target_investor_email, ti.name AS target_investor_name,
ad.email AS admin_email, ad.name AS admin_name
FROM pitch_audit_logs a
LEFT JOIN pitch_investors i ON i.id = a.investor_id
LEFT JOIN pitch_investors ti ON ti.id = a.target_investor_id
LEFT JOIN pitch_admins ad ON ad.id = a.admin_id
${where}
ORDER BY a.created_at DESC
LIMIT $${paramIdx++} OFFSET $${paramIdx++}`,
[...params, limit, offset]
LIMIT $${p++} OFFSET $${p++}`,
[...params, limit, offset],
)
return NextResponse.json({ logs: rows })
const totalRes = await pool.query(
`SELECT COUNT(*)::int AS total FROM pitch_audit_logs a ${where}`,
params,
)
return NextResponse.json({ logs: rows, total: totalRes.rows[0].total })
}

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin } from '@/lib/admin-auth'
export async function GET(request: NextRequest) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const [totals, recentLogins, recentActivity] = await Promise.all([
pool.query(`
SELECT
(SELECT COUNT(*)::int FROM pitch_investors) AS total_investors,
(SELECT COUNT(*)::int FROM pitch_investors WHERE status = 'invited') AS pending_invites,
(SELECT COUNT(*)::int FROM pitch_investors WHERE last_login_at >= NOW() - INTERVAL '7 days') AS active_7d,
(SELECT COUNT(*)::int FROM pitch_audit_logs WHERE action = 'slide_viewed') AS slides_viewed_total,
(SELECT COUNT(*)::int FROM pitch_sessions WHERE revoked = false AND expires_at > NOW()) AS active_sessions,
(SELECT COUNT(*)::int FROM pitch_admins WHERE is_active = true) AS active_admins
`),
pool.query(`
SELECT a.created_at, a.ip_address, i.id AS investor_id, i.email, i.name, i.company
FROM pitch_audit_logs a
JOIN pitch_investors i ON i.id = a.investor_id
WHERE a.action = 'login_success'
ORDER BY a.created_at DESC
LIMIT 10
`),
pool.query(`
SELECT a.id, a.action, a.created_at, a.details,
i.email AS investor_email, i.name AS investor_name,
ti.email AS target_investor_email,
ad.email AS admin_email, ad.name AS admin_name
FROM pitch_audit_logs a
LEFT JOIN pitch_investors i ON i.id = a.investor_id
LEFT JOIN pitch_investors ti ON ti.id = a.target_investor_id
LEFT JOIN pitch_admins ad ON ad.id = a.admin_id
ORDER BY a.created_at DESC
LIMIT 15
`),
])
return NextResponse.json({
totals: totals.rows[0],
recent_logins: recentLogins.rows,
recent_activity: recentActivity.rows,
})
}

View File

@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
interface RouteContext {
params: Promise<{ id: string }>
}
export async function PATCH(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 body = await request.json().catch(() => ({}))
const { value, min_value, max_value, step_size, label_de, label_en } = body
const before = await pool.query(
`SELECT scenario_id, key, label_de, label_en, value, min_value, max_value, step_size
FROM pitch_fm_assumptions WHERE id = $1`,
[id],
)
if (before.rows.length === 0) {
return NextResponse.json({ error: 'Assumption not found' }, { status: 404 })
}
const updates: string[] = []
const params: unknown[] = []
let p = 1
if (value !== undefined) {
updates.push(`value = $${p++}`)
params.push(JSON.stringify(value))
}
if (min_value !== undefined) {
updates.push(`min_value = $${p++}`)
params.push(min_value)
}
if (max_value !== undefined) {
updates.push(`max_value = $${p++}`)
params.push(max_value)
}
if (step_size !== undefined) {
updates.push(`step_size = $${p++}`)
params.push(step_size)
}
if (typeof label_de === 'string') {
updates.push(`label_de = $${p++}`)
params.push(label_de)
}
if (typeof label_en === 'string') {
updates.push(`label_en = $${p++}`)
params.push(label_en)
}
if (updates.length === 0) {
return NextResponse.json({ error: 'no fields to update' }, { status: 400 })
}
params.push(id)
const { rows } = await pool.query(
`UPDATE pitch_fm_assumptions SET ${updates.join(', ')} WHERE id = $${p} RETURNING *`,
params,
)
// Invalidate cached results for this scenario so the next compute uses the new value
await pool.query(`DELETE FROM pitch_fm_results WHERE scenario_id = $1`, [before.rows[0].scenario_id])
await logAdminAudit(
adminId,
'assumption_edited',
{
assumption_id: id,
scenario_id: before.rows[0].scenario_id,
key: before.rows[0].key,
before: {
value: typeof before.rows[0].value === 'string' ? JSON.parse(before.rows[0].value) : before.rows[0].value,
min_value: before.rows[0].min_value,
max_value: before.rows[0].max_value,
step_size: before.rows[0].step_size,
},
after: {
value: typeof rows[0].value === 'string' ? JSON.parse(rows[0].value) : rows[0].value,
min_value: rows[0].min_value,
max_value: rows[0].max_value,
step_size: rows[0].step_size,
},
},
request,
)
return NextResponse.json({ assumption: rows[0] })
}

View File

@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
interface RouteContext {
params: Promise<{ id: string }>
}
export async function PATCH(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 body = await request.json().catch(() => ({}))
const { name, description, color } = body
if (name === undefined && description === undefined && color === undefined) {
return NextResponse.json({ error: 'name, description, or color required' }, { status: 400 })
}
const before = await pool.query(
`SELECT name, description, color FROM pitch_fm_scenarios WHERE id = $1`,
[id],
)
if (before.rows.length === 0) {
return NextResponse.json({ error: 'Scenario not found' }, { status: 404 })
}
const { rows } = await pool.query(
`UPDATE pitch_fm_scenarios SET
name = COALESCE($1, name),
description = COALESCE($2, description),
color = COALESCE($3, color)
WHERE id = $4
RETURNING *`,
[name ?? null, description ?? null, color ?? null, id],
)
await logAdminAudit(
adminId,
'scenario_edited',
{
scenario_id: id,
before: before.rows[0],
after: { name: rows[0].name, description: rows[0].description, color: rows[0].color },
},
request,
)
return NextResponse.json({ scenario: rows[0] })
}

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin } from '@/lib/admin-auth'
export async function GET(request: NextRequest) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const scenarios = await pool.query(
`SELECT * FROM pitch_fm_scenarios ORDER BY is_default DESC, name`,
)
const assumptions = await pool.query(
`SELECT * FROM pitch_fm_assumptions ORDER BY scenario_id, sort_order`,
)
const result = scenarios.rows.map(s => ({
...s,
assumptions: assumptions.rows
.filter(a => a.scenario_id === s.id)
.map(a => ({
...a,
value: typeof a.value === 'string' ? JSON.parse(a.value) : a.value,
})),
}))
return NextResponse.json({ scenarios: result })
}

View File

@@ -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'
import { sendMagicLinkEmail } from '@/lib/email'
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
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 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.status === 'revoked') {
return NextResponse.json({ error: 'Investor is revoked. Reactivate first by re-inviting.' }, { status: 400 })
}
// Rate limit by email
const rl = checkRateLimit(`magic-link:${investor.email}`, RATE_LIMITS.magicLink)
if (!rl.allowed) {
return NextResponse.json({ error: 'Too many resends for this email. Try again later.' }, { status: 429 })
}
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 logAdminAudit(
adminId,
'magic_link_resent',
{ email: investor.email, expires_at: expiresAt.toISOString() },
request,
investor.id,
)
return NextResponse.json({ success: true, expires_at: expiresAt.toISOString() })
}

View File

@@ -0,0 +1,99 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
interface RouteContext {
params: Promise<{ id: string }>
}
export async function GET(request: NextRequest, ctx: RouteContext) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const { id } = await ctx.params
const [investor, sessions, snapshots, audit] = await Promise.all([
pool.query(
`SELECT id, email, name, company, status, last_login_at, login_count, created_at, updated_at
FROM pitch_investors WHERE id = $1`,
[id],
),
pool.query(
`SELECT id, ip_address, user_agent, expires_at, revoked, created_at
FROM pitch_sessions WHERE investor_id = $1
ORDER BY created_at DESC LIMIT 50`,
[id],
),
pool.query(
`SELECT id, scenario_id, label, is_latest, created_at
FROM pitch_investor_snapshots WHERE investor_id = $1
ORDER BY created_at DESC LIMIT 50`,
[id],
),
pool.query(
`SELECT a.id, a.action, a.created_at, a.details, a.ip_address, a.slide_id,
ad.email AS admin_email, ad.name AS admin_name
FROM pitch_audit_logs a
LEFT JOIN pitch_admins ad ON ad.id = a.admin_id
WHERE a.investor_id = $1 OR a.target_investor_id = $1
ORDER BY a.created_at DESC LIMIT 100`,
[id],
),
])
if (investor.rows.length === 0) {
return NextResponse.json({ error: 'Investor not found' }, { status: 404 })
}
return NextResponse.json({
investor: investor.rows[0],
sessions: sessions.rows,
snapshots: snapshots.rows,
audit: audit.rows,
})
}
export async function PATCH(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 body = await request.json().catch(() => ({}))
const { name, company } = body
if (name === undefined && company === undefined) {
return NextResponse.json({ error: 'name or company required' }, { status: 400 })
}
const before = await pool.query(
`SELECT name, company FROM pitch_investors WHERE id = $1`,
[id],
)
if (before.rows.length === 0) {
return NextResponse.json({ error: 'Investor not found' }, { status: 404 })
}
const { rows } = await pool.query(
`UPDATE pitch_investors SET
name = COALESCE($1, name),
company = COALESCE($2, company),
updated_at = NOW()
WHERE id = $3
RETURNING id, email, name, company, status`,
[name ?? null, company ?? null, id],
)
await logAdminAudit(
adminId,
'investor_edited',
{
before: before.rows[0],
after: { name: rows[0].name, company: rows[0].company },
},
request,
id,
)
return NextResponse.json({ investor: rows[0] })
}

View File

@@ -1,18 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { validateAdminSecret } from '@/lib/auth'
import { requireAdmin } from '@/lib/admin-auth'
export async function GET(request: NextRequest) {
if (!validateAdminSecret(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
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,
(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
FROM pitch_investors i
ORDER BY i.created_at DESC`
ORDER BY i.created_at DESC`,
)
return NextResponse.json({ investors: rows })

View File

@@ -1,22 +1,23 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { validateAdminSecret, generateToken, logAudit, getClientIp } from '@/lib/auth'
import { generateToken } from '@/lib/auth'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
import { sendMagicLinkEmail } from '@/lib/email'
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
export async function POST(request: NextRequest) {
if (!validateAdminSecret(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const adminId = guard.kind === 'admin' ? guard.admin.id : null
const body = await request.json()
const body = await request.json().catch(() => ({}))
const { email, name, company } = body
if (!email || typeof email !== 'string') {
return NextResponse.json({ error: 'Email required' }, { status: 400 })
}
// Rate limit by email
// Rate limit by email (3/hour)
const rl = checkRateLimit(`magic-link:${email.toLowerCase()}`, RATE_LIMITS.magicLink)
if (!rl.allowed) {
return NextResponse.json({ error: 'Too many invites for this email. Try again later.' }, { status: 429 })
@@ -34,7 +35,7 @@ export async function POST(request: NextRequest) {
status = CASE WHEN pitch_investors.status = 'revoked' THEN 'invited' ELSE pitch_investors.status END,
updated_at = NOW()
RETURNING id, status`,
[normalizedEmail, name || null, company || null]
[normalizedEmail, name || null, company || null],
)
const investor = rows[0]
@@ -47,17 +48,21 @@ export async function POST(request: NextRequest) {
await pool.query(
`INSERT INTO pitch_magic_links (investor_id, token, expires_at)
VALUES ($1, $2, $3)`,
[investor.id, token, expiresAt]
[investor.id, token, expiresAt],
)
// Build magic link URL
const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai'
const magicLinkUrl = `${baseUrl}/auth/verify?token=${token}`
// Send email
await sendMagicLinkEmail(normalizedEmail, name || null, magicLinkUrl)
await logAudit(investor.id, 'magic_link_sent', { email: normalizedEmail }, request)
await logAdminAudit(
adminId,
'investor_invited',
{ email: normalizedEmail, name: name || null, company: company || null, expires_at: expiresAt.toISOString() },
request,
investor.id,
)
return NextResponse.json({
success: true,

View File

@@ -1,26 +1,32 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { validateAdminSecret, revokeAllSessions, logAudit } from '@/lib/auth'
import { revokeAllSessions } from '@/lib/auth'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
export async function POST(request: NextRequest) {
if (!validateAdminSecret(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const adminId = guard.kind === 'admin' ? guard.admin.id : null
const body = await request.json()
const body = await request.json().catch(() => ({}))
const { investor_id } = body
if (!investor_id) {
return NextResponse.json({ error: 'investor_id required' }, { status: 400 })
}
await pool.query(
`UPDATE pitch_investors SET status = 'revoked', updated_at = NOW() WHERE id = $1`,
[investor_id]
const { rows } = await pool.query(
`UPDATE pitch_investors SET status = 'revoked', updated_at = NOW()
WHERE id = $1 RETURNING email`,
[investor_id],
)
if (rows.length === 0) {
return NextResponse.json({ error: 'Investor not found' }, { status: 404 })
}
await revokeAllSessions(investor_id)
await logAudit(investor_id, 'investor_revoked', {}, request)
await logAdminAudit(adminId, 'investor_revoked', { email: rows[0].email }, request, investor_id)
return NextResponse.json({ success: true })
}

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>
)
}

View File

@@ -0,0 +1,129 @@
'use client'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useState } from 'react'
import {
LayoutDashboard,
Users,
FileText,
TrendingUp,
ShieldCheck,
LogOut,
Menu,
X,
} from 'lucide-react'
interface AdminShellProps {
admin: { id: string; email: string; name: string }
children: React.ReactNode
}
const NAV = [
{ href: '/pitch-admin', label: 'Dashboard', icon: LayoutDashboard, exact: true },
{ href: '/pitch-admin/investors', label: 'Investors', icon: Users },
{ href: '/pitch-admin/audit', label: 'Audit Log', icon: FileText },
{ href: '/pitch-admin/financial-model', label: 'Financial Model', icon: TrendingUp },
{ href: '/pitch-admin/admins', label: 'Admins', icon: ShieldCheck },
]
export default function AdminShell({ admin, children }: AdminShellProps) {
const pathname = usePathname()
const router = useRouter()
const [open, setOpen] = useState(false)
async function logout() {
await fetch('/api/admin-auth/logout', { method: 'POST' })
router.push('/pitch-admin/login')
}
function isActive(item: typeof NAV[number]) {
if (item.exact) return pathname === item.href
return pathname === item.href || pathname.startsWith(item.href + '/')
}
return (
<div className="min-h-screen bg-[#0a0a1a] text-white flex">
{/* Sidebar */}
<aside
className={`fixed lg:static inset-y-0 left-0 z-40 w-64 bg-black/40 backdrop-blur-xl border-r border-white/[0.06]
transform transition-transform lg:transform-none ${open ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}`}
>
<div className="h-full flex flex-col">
<div className="px-6 py-5 border-b border-white/[0.06]">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center">
<svg width="20" height="20" viewBox="0 0 40 40" fill="none">
<path d="M8 12L20 6L32 12V28L20 34L8 28V12Z" stroke="white" strokeWidth="2.5" fill="none" />
<circle cx="20" cy="20" r="3" fill="white" />
</svg>
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-white">BreakPilot</div>
<div className="text-[10px] text-white/40 uppercase tracking-wider">Pitch Admin</div>
</div>
</div>
</div>
<nav className="flex-1 px-3 py-4 space-y-1">
{NAV.map((item) => {
const Icon = item.icon
const active = isActive(item)
return (
<Link
key={item.href}
href={item.href}
onClick={() => setOpen(false)}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors
${active
? 'bg-indigo-500/15 text-indigo-300 border border-indigo-500/20'
: 'text-white/60 hover:bg-white/[0.04] hover:text-white/90 border border-transparent'}`}
>
<Icon className="w-4 h-4" />
{item.label}
</Link>
)
})}
</nav>
<div className="px-3 py-4 border-t border-white/[0.06]">
<div className="px-3 py-2 mb-2">
<div className="text-sm font-medium text-white/90 truncate">{admin.name}</div>
<div className="text-xs text-white/40 truncate">{admin.email}</div>
</div>
<button
onClick={logout}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-white/60 hover:bg-red-500/10 hover:text-red-300 transition-colors"
>
<LogOut className="w-4 h-4" />
Logout
</button>
</div>
</div>
</aside>
{/* Mobile overlay */}
{open && (
<div
onClick={() => setOpen(false)}
className="fixed inset-0 bg-black/60 z-30 lg:hidden"
/>
)}
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0">
<header className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-white/[0.06]">
<button
onClick={() => setOpen(true)}
className="w-9 h-9 rounded-lg bg-white/[0.04] flex items-center justify-center"
>
<Menu className="w-5 h-5" />
</button>
<div className="text-sm font-semibold">Pitch Admin</div>
<div className="w-9" />
</header>
<main className="flex-1 p-6 lg:p-8 overflow-y-auto">{children}</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,153 @@
'use client'
export interface AuditLogRow {
id: number | string
action: string
created_at: string
details: Record<string, unknown> | null
ip_address?: string | null
slide_id?: string | null
investor_email?: string | null
investor_name?: string | null
target_investor_email?: string | null
target_investor_name?: string | null
admin_email?: string | null
admin_name?: string | null
}
interface AuditLogTableProps {
rows: AuditLogRow[]
showActor?: boolean
}
const ACTION_COLORS: Record<string, string> = {
login_success: 'text-green-400 bg-green-500/10',
login_failed: 'text-rose-400 bg-rose-500/10',
admin_login_success: 'text-green-400 bg-green-500/10',
admin_login_failed: 'text-rose-400 bg-rose-500/10',
admin_logout: 'text-white/40 bg-white/[0.04]',
logout: 'text-white/40 bg-white/[0.04]',
slide_viewed: 'text-indigo-400 bg-indigo-500/10',
assumption_changed: 'text-amber-400 bg-amber-500/10',
assumption_edited: 'text-amber-400 bg-amber-500/10',
scenario_edited: 'text-amber-400 bg-amber-500/10',
investor_invited: 'text-purple-400 bg-purple-500/10',
magic_link_resent: 'text-purple-400 bg-purple-500/10',
investor_revoked: 'text-rose-400 bg-rose-500/10',
investor_edited: 'text-blue-400 bg-blue-500/10',
admin_created: 'text-green-400 bg-green-500/10',
admin_edited: 'text-blue-400 bg-blue-500/10',
admin_deactivated: 'text-rose-400 bg-rose-500/10',
new_ip_detected: 'text-amber-400 bg-amber-500/10',
}
function actorLabel(row: AuditLogRow): { label: string; sub: string; kind: 'admin' | 'investor' | 'system' } {
if (row.admin_email) {
return { label: row.admin_name || row.admin_email, sub: row.admin_email, kind: 'admin' }
}
if (row.investor_email) {
return { label: row.investor_name || row.investor_email, sub: row.investor_email, kind: 'investor' }
}
return { label: 'system', sub: '', kind: 'system' }
}
function targetLabel(row: AuditLogRow): string | null {
if (row.target_investor_email) {
return row.target_investor_name
? `${row.target_investor_name} <${row.target_investor_email}>`
: row.target_investor_email
}
return null
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString()
}
function summarizeDetails(action: string, details: Record<string, unknown> | null): string {
if (!details) return ''
if (action === 'slide_viewed' && details.slide_id) return String(details.slide_id)
if (action === 'assumption_edited' || action === 'scenario_edited') {
const before = details.before as Record<string, unknown> | undefined
const after = details.after as Record<string, unknown> | undefined
if (before && after) {
const keys = Object.keys(after).filter(k => JSON.stringify(before[k]) !== JSON.stringify(after[k]))
return keys.map(k => `${k}: ${JSON.stringify(before[k])}${JSON.stringify(after[k])}`).join(', ')
}
}
if (action === 'investor_invited' || action === 'magic_link_resent') {
return String(details.email || '')
}
if (action === 'investor_edited') {
const before = details.before as Record<string, unknown> | undefined
const after = details.after as Record<string, unknown> | undefined
if (before && after) {
const keys = Object.keys(after).filter(k => before[k] !== after[k])
return keys.map(k => `${k}: "${before[k] || ''}" → "${after[k] || ''}"`).join(', ')
}
}
return JSON.stringify(details).slice(0, 80)
}
export default function AuditLogTable({ rows, showActor = true }: AuditLogTableProps) {
if (rows.length === 0) {
return <div className="text-white/40 text-sm py-8 text-center">No audit events</div>
}
return (
<div className="overflow-x-auto">
<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-3 font-medium">When</th>
{showActor && <th className="py-3 px-3 font-medium">Actor</th>}
<th className="py-3 px-3 font-medium">Action</th>
<th className="py-3 px-3 font-medium">Target / Details</th>
<th className="py-3 px-3 font-medium">IP</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const actor = actorLabel(row)
const target = targetLabel(row)
const summary = summarizeDetails(row.action, row.details)
const colorClass = ACTION_COLORS[row.action] || 'text-white/60 bg-white/[0.04]'
return (
<tr key={row.id} className="border-b border-white/[0.04] hover:bg-white/[0.02]">
<td className="py-3 px-3 text-white/60 whitespace-nowrap">{formatDate(row.created_at)}</td>
{showActor && (
<td className="py-3 px-3">
<div className="flex items-center gap-2">
<span
className={`text-[9px] px-1.5 py-0.5 rounded uppercase font-semibold ${
actor.kind === 'admin'
? 'bg-purple-500/20 text-purple-300'
: actor.kind === 'investor'
? 'bg-indigo-500/20 text-indigo-300'
: 'bg-white/10 text-white/50'
}`}
>
{actor.kind}
</span>
<div className="min-w-0">
<div className="text-white/80 truncate max-w-[180px]">{actor.label}</div>
</div>
</div>
</td>
)}
<td className="py-3 px-3">
<span className={`text-xs px-2 py-1 rounded font-mono ${colorClass}`}>{row.action}</span>
</td>
<td className="py-3 px-3 text-white/60 max-w-md">
{target && <div className="text-white/80 truncate"> {target}</div>}
{summary && <div className="text-xs text-white/40 truncate">{summary}</div>}
</td>
<td className="py-3 px-3 text-white/40 font-mono text-xs">{row.ip_address || '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { LucideIcon } from 'lucide-react'
interface StatCardProps {
label: string
value: string | number
icon?: LucideIcon
hint?: string
accent?: 'indigo' | 'green' | 'amber' | 'rose'
}
const ACCENTS = {
indigo: 'text-indigo-400 bg-indigo-500/10 border-indigo-500/20',
green: 'text-green-400 bg-green-500/10 border-green-500/20',
amber: 'text-amber-400 bg-amber-500/10 border-amber-500/20',
rose: 'text-rose-400 bg-rose-500/10 border-rose-500/20',
}
export default function StatCard({ label, value, icon: Icon, hint, accent = 'indigo' }: StatCardProps) {
return (
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
<div className="flex items-start justify-between mb-3">
<span className="text-xs font-medium text-white/50 uppercase tracking-wider">{label}</span>
{Icon && (
<div className={`w-9 h-9 rounded-lg flex items-center justify-center border ${ACCENTS[accent]}`}>
<Icon className="w-4 h-4" />
</div>
)}
</div>
<div className="text-3xl font-semibold text-white">{value}</div>
{hint && <div className="text-xs text-white/40 mt-1">{hint}</div>}
</div>
)
}

View File

@@ -0,0 +1,206 @@
import { SignJWT, jwtVerify } from 'jose'
import bcrypt from 'bcryptjs'
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
import pool from './db'
import { hashToken, generateToken, getClientIp, logAudit } from './auth'
const ADMIN_COOKIE_NAME = 'pitch_admin_session'
const ADMIN_JWT_AUDIENCE = 'pitch-admin'
const ADMIN_JWT_EXPIRY = '2h'
const ADMIN_SESSION_EXPIRY_HOURS = 12
function getJwtSecret() {
const secret = process.env.PITCH_JWT_SECRET
if (!secret) throw new Error('PITCH_JWT_SECRET not set')
return new TextEncoder().encode(secret)
}
export interface Admin {
id: string
email: string
name: string
is_active: boolean
last_login_at: string | null
created_at: string
}
export interface AdminJwtPayload {
sub: string // admin id
email: string
sessionId: string
}
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12)
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash)
}
export async function createAdminJwt(payload: AdminJwtPayload): Promise<string> {
return new SignJWT({ ...payload })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(ADMIN_JWT_EXPIRY)
.setAudience(ADMIN_JWT_AUDIENCE)
.sign(getJwtSecret())
}
export async function verifyAdminJwt(token: string): Promise<AdminJwtPayload | null> {
try {
const { payload } = await jwtVerify(token, getJwtSecret(), { audience: ADMIN_JWT_AUDIENCE })
return payload as unknown as AdminJwtPayload
} catch {
return null
}
}
export async function createAdminSession(
adminId: string,
ip: string | null,
userAgent: string | null,
): Promise<{ sessionId: string; jwt: string }> {
// Single session per admin
await pool.query(
`UPDATE pitch_admin_sessions SET revoked = true WHERE admin_id = $1 AND revoked = false`,
[adminId],
)
const sessionToken = generateToken()
const tokenHash = hashToken(sessionToken)
const expiresAt = new Date(Date.now() + ADMIN_SESSION_EXPIRY_HOURS * 60 * 60 * 1000)
const { rows } = await pool.query(
`INSERT INTO pitch_admin_sessions (admin_id, token_hash, ip_address, user_agent, expires_at)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
[adminId, tokenHash, ip, userAgent, expiresAt],
)
const sessionId = rows[0].id
const adminRes = await pool.query(`SELECT email FROM pitch_admins WHERE id = $1`, [adminId])
const jwt = await createAdminJwt({
sub: adminId,
email: adminRes.rows[0].email,
sessionId,
})
return { sessionId, jwt }
}
export async function validateAdminSession(sessionId: string, adminId: string): Promise<boolean> {
const { rows } = await pool.query(
`SELECT s.id FROM pitch_admin_sessions s
JOIN pitch_admins a ON a.id = s.admin_id
WHERE s.id = $1 AND s.admin_id = $2 AND s.revoked = false AND s.expires_at > NOW() AND a.is_active = true`,
[sessionId, adminId],
)
return rows.length > 0
}
export async function revokeAdminSession(sessionId: string): Promise<void> {
await pool.query(`UPDATE pitch_admin_sessions SET revoked = true WHERE id = $1`, [sessionId])
}
export async function revokeAllAdminSessions(adminId: string): Promise<void> {
await pool.query(`UPDATE pitch_admin_sessions SET revoked = true WHERE admin_id = $1`, [adminId])
}
export async function setAdminCookie(jwt: string): Promise<void> {
const cookieStore = await cookies()
cookieStore.set(ADMIN_COOKIE_NAME, jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: ADMIN_SESSION_EXPIRY_HOURS * 60 * 60,
})
}
export async function clearAdminCookie(): Promise<void> {
const cookieStore = await cookies()
cookieStore.delete(ADMIN_COOKIE_NAME)
}
export async function getAdminPayloadFromCookie(): Promise<AdminJwtPayload | null> {
const cookieStore = await cookies()
const token = cookieStore.get(ADMIN_COOKIE_NAME)?.value
if (!token) return null
return verifyAdminJwt(token)
}
/**
* Server-side: read the admin row from the cookie. Returns null if no valid session
* or the admin is inactive. Use in layout.tsx and API routes.
*/
export async function getAdminFromCookie(): Promise<Admin | null> {
const payload = await getAdminPayloadFromCookie()
if (!payload) return null
const valid = await validateAdminSession(payload.sessionId, payload.sub)
if (!valid) return null
const { rows } = await pool.query(
`SELECT id, email, name, is_active, last_login_at, created_at
FROM pitch_admins WHERE id = $1`,
[payload.sub],
)
if (rows.length === 0 || !rows[0].is_active) return null
return rows[0] as Admin
}
/**
* API guard: returns the Admin row, OR a NextResponse 401/403 to return early.
* Also accepts the legacy PITCH_ADMIN_SECRET bearer header for CLI/automation —
* in that case the returned admin id is null but the request is allowed.
*/
export type AdminGuardResult =
| { kind: 'admin'; admin: Admin }
| { kind: 'cli' }
| { kind: 'response'; response: NextResponse }
export async function requireAdmin(request: Request): Promise<AdminGuardResult> {
// CLI fallback via shared secret
const secret = process.env.PITCH_ADMIN_SECRET
if (secret) {
const auth = request.headers.get('authorization')
if (auth === `Bearer ${secret}`) {
return { kind: 'cli' }
}
}
const admin = await getAdminFromCookie()
if (!admin) {
return {
kind: 'response',
response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
}
}
return { kind: 'admin', admin }
}
/**
* Convenience: log an admin-initiated audit event. Falls back to CLI actor when admin is null.
*/
export async function logAdminAudit(
adminId: string | null,
action: string,
details: Record<string, unknown> = {},
request?: Request,
targetInvestorId?: string | null,
): Promise<void> {
await logAudit(
null, // investor_id
action,
details,
request,
undefined, // slide_id
undefined, // session_id
adminId,
targetInvestorId ?? null,
)
}
export { ADMIN_COOKIE_NAME }

View File

@@ -148,13 +148,16 @@ export async function logAudit(
details: Record<string, unknown> = {},
request?: Request,
slideId?: string,
sessionId?: string
sessionId?: string,
adminId?: string | null,
targetInvestorId?: string | null,
): Promise<void> {
const ip = request ? getClientIp(request) : null
const ua = request ? request.headers.get('user-agent') : null
await pool.query(
`INSERT INTO pitch_audit_logs (investor_id, action, details, ip_address, user_agent, slide_id, session_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[investorId, action, JSON.stringify(details), ip, ua, slideId, sessionId]
`INSERT INTO pitch_audit_logs
(investor_id, action, details, ip_address, user_agent, slide_id, session_id, admin_id, target_investor_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[investorId, action, JSON.stringify(details), ip, ua, slideId, sessionId, adminId ?? null, targetInvestorId ?? null]
)
}

View File

@@ -1,11 +1,13 @@
import { NextRequest, NextResponse } from 'next/server'
import { jwtVerify } from 'jose'
// Paths that bypass auth entirely
const PUBLIC_PATHS = [
'/auth',
'/api/auth',
'/auth', // investor login pages
'/api/auth', // investor auth API
'/api/health',
'/api/admin',
'/api/admin-auth', // admin login API
'/pitch-admin/login', // admin login page
'/_next',
'/manifest.json',
'/sw.js',
@@ -13,54 +15,82 @@ const PUBLIC_PATHS = [
'/favicon.ico',
]
// Paths gated on the admin session cookie
const ADMIN_GATED_PREFIXES = ['/pitch-admin', '/api/admin']
function isPublicPath(pathname: string): boolean {
return PUBLIC_PATHS.some(p => pathname === p || pathname.startsWith(p + '/'))
}
function isAdminGatedPath(pathname: string): boolean {
return ADMIN_GATED_PREFIXES.some(p => pathname === p || pathname.startsWith(p + '/'))
}
const ADMIN_AUDIENCE = 'pitch-admin'
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const secret = process.env.PITCH_JWT_SECRET
// Allow public paths
if (isPublicPath(pathname)) {
return NextResponse.next()
}
// Check for session cookie
const token = request.cookies.get('pitch_session')?.value
// ----- Admin-gated routes -----
if (isAdminGatedPath(pathname)) {
// Allow legacy bearer-secret CLI access on /api/admin/* (the API routes themselves
// also check this and log as actor='cli'). The bearer header is opaque to the JWT
// path, so we just let it through here and let the route handler enforce.
if (pathname.startsWith('/api/admin') && request.headers.get('authorization')?.startsWith('Bearer ')) {
return NextResponse.next()
}
if (!token) {
return NextResponse.redirect(new URL('/auth', request.url))
const adminToken = request.cookies.get('pitch_admin_session')?.value
if (!adminToken || !secret) {
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return NextResponse.redirect(new URL('/pitch-admin/login', request.url))
}
try {
await jwtVerify(adminToken, new TextEncoder().encode(secret), { audience: ADMIN_AUDIENCE })
return NextResponse.next()
} catch {
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const response = NextResponse.redirect(new URL('/pitch-admin/login', request.url))
response.cookies.delete('pitch_admin_session')
return response
}
}
// Verify JWT
const secret = process.env.PITCH_JWT_SECRET
if (!secret) {
// ----- Investor-gated routes (everything else) -----
const token = request.cookies.get('pitch_session')?.value
if (!token || !secret) {
return NextResponse.redirect(new URL('/auth', request.url))
}
try {
const { payload } = await jwtVerify(token, new TextEncoder().encode(secret))
// Add investor info to headers for downstream use
const response = NextResponse.next()
response.headers.set('x-investor-id', payload.sub as string)
response.headers.set('x-investor-email', payload.email as string)
response.headers.set('x-session-id', payload.sessionId as string)
// Auto-refresh JWT if within last 15 minutes of expiry
const exp = payload.exp as number
const now = Math.floor(Date.now() / 1000)
const timeLeft = exp - now
if (timeLeft < 900 && timeLeft > 0) {
// Import dynamically to avoid Edge runtime issues with pg
// The actual refresh happens server-side in the API routes
response.headers.set('x-token-refresh-needed', 'true')
}
return response
} catch {
// Invalid or expired JWT
const response = NextResponse.redirect(new URL('/auth', request.url))
response.cookies.delete('pitch_session')
return response
@@ -68,13 +98,5 @@ export async function middleware(request: NextRequest) {
}
export const config = {
matcher: [
/*
* Match all request paths except:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!_next/static|_next/image).*)',
],
matcher: ['/((?!_next/static|_next/image).*)'],
}

View File

@@ -0,0 +1,40 @@
-- =========================================================
-- Pitch Deck: Admin Users + Audit Log Extensions
-- =========================================================
-- Admin users (real accounts with bcrypt passwords)
CREATE TABLE IF NOT EXISTS pitch_admins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pitch_admins_email ON pitch_admins(email);
CREATE INDEX IF NOT EXISTS idx_pitch_admins_active ON pitch_admins(is_active);
-- Admin sessions (mirrors pitch_sessions structure)
CREATE TABLE IF NOT EXISTS pitch_admin_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
admin_id UUID NOT NULL REFERENCES pitch_admins(id) ON DELETE CASCADE,
token_hash VARCHAR(128) NOT NULL,
ip_address INET,
user_agent TEXT,
expires_at TIMESTAMPTZ NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pitch_admin_sessions_admin ON pitch_admin_sessions(admin_id);
CREATE INDEX IF NOT EXISTS idx_pitch_admin_sessions_token ON pitch_admin_sessions(token_hash);
-- Extend audit log: track admin actor + target investor for admin actions
ALTER TABLE pitch_audit_logs
ADD COLUMN IF NOT EXISTS admin_id UUID REFERENCES pitch_admins(id) ON DELETE SET NULL;
ALTER TABLE pitch_audit_logs
ADD COLUMN IF NOT EXISTS target_investor_id UUID REFERENCES pitch_investors(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_pitch_audit_admin ON pitch_audit_logs(admin_id);
CREATE INDEX IF NOT EXISTS idx_pitch_audit_target_investor ON pitch_audit_logs(target_investor_id);

View File

@@ -8,6 +8,7 @@
"name": "breakpilot-pitch-deck",
"version": "1.0.0",
"dependencies": {
"bcryptjs": "^3.0.3",
"framer-motion": "^11.15.0",
"jose": "^6.2.2",
"lucide-react": "^0.468.0",
@@ -19,6 +20,7 @@
"recharts": "^2.15.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.10.2",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.11.10",
@@ -27,6 +29,7 @@
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"tsx": "^4.21.0",
"typescript": "^5.7.2"
}
},
@@ -62,6 +65,448 @@
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@@ -748,6 +1193,13 @@
"tslib": "^2.8.0"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@@ -949,6 +1401,15 @@
"node": ">=6.0.0"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -1289,6 +1750,48 @@
"dev": true,
"license": "ISC"
},
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1433,6 +1936,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2309,6 +2825,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -2629,6 +3155,26 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",

View File

@@ -5,9 +5,11 @@
"scripts": {
"dev": "next dev -p 3012",
"build": "next build",
"start": "next start -p 3012"
"start": "next start -p 3012",
"admin:create": "tsx scripts/create-admin.ts"
},
"dependencies": {
"bcryptjs": "^3.0.3",
"framer-motion": "^11.15.0",
"jose": "^6.2.2",
"lucide-react": "^0.468.0",
@@ -19,6 +21,7 @@
"recharts": "^2.15.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.10.2",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.11.10",
@@ -27,6 +30,7 @@
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"tsx": "^4.21.0",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env tsx
/**
* Bootstrap a new pitch admin user.
*
* Usage:
* tsx scripts/create-admin.ts --email=ben@breakpilot.ai --name="Benjamin" --password='...'
*
* Or via env vars (useful in CI):
* PITCH_ADMIN_BOOTSTRAP_EMAIL=... PITCH_ADMIN_BOOTSTRAP_NAME=... PITCH_ADMIN_BOOTSTRAP_PASSWORD=... \
* tsx scripts/create-admin.ts
*
* Idempotent: if an admin with the email already exists, the password is updated
* (so you can use it to reset). The script always re-activates the account.
*/
import { Pool } from 'pg'
import bcrypt from 'bcryptjs'
function arg(name: string): string | undefined {
const prefix = `--${name}=`
const m = process.argv.find(a => a.startsWith(prefix))
return m ? m.slice(prefix.length) : undefined
}
const email = (arg('email') || process.env.PITCH_ADMIN_BOOTSTRAP_EMAIL || '').trim().toLowerCase()
const name = arg('name') || process.env.PITCH_ADMIN_BOOTSTRAP_NAME || 'Admin'
const password = arg('password') || process.env.PITCH_ADMIN_BOOTSTRAP_PASSWORD || ''
if (!email || !password) {
console.error('ERROR: --email and --password are required (or set env vars).')
console.error(' tsx scripts/create-admin.ts --email=user@example.com --name="Name" --password=secret')
process.exit(1)
}
if (password.length < 12) {
console.error('ERROR: password must be at least 12 characters.')
process.exit(1)
}
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_db',
})
async function main() {
const hash = await bcrypt.hash(password, 12)
const { rows } = await pool.query(
`INSERT INTO pitch_admins (email, name, password_hash, is_active)
VALUES ($1, $2, $3, true)
ON CONFLICT (email) DO UPDATE SET
name = EXCLUDED.name,
password_hash = EXCLUDED.password_hash,
is_active = true,
updated_at = NOW()
RETURNING id, email, name, created_at`,
[email, name, hash],
)
const row = rows[0]
console.log(`✓ Admin ready: ${row.email} (${row.name})`)
console.log(` id: ${row.id}`)
console.log(` created_at: ${row.created_at.toISOString()}`)
await pool.end()
}
main().catch(err => {
console.error('ERROR:', err.message)
pool.end().catch(() => {})
process.exit(1)
})