merge: gitea/main — resolve pitch-deck conflicts (accept theirs)
Some checks failed
CI / test-go-consent (push) Successful in 45s
CI / test-python-voice (push) Successful in 37s
CI / test-bqas (push) Successful in 34s
CI / Deploy (push) Failing after 5s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-09 14:43:32 +02:00
67 changed files with 6533 additions and 273 deletions

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

@@ -0,0 +1,77 @@
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 { 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 p = 1
if (investorId) {
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 = $${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,
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 $${p++} OFFSET $${p++}`,
[...params, limit, offset],
)
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

@@ -0,0 +1,18 @@
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 { 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`,
)
return NextResponse.json({ investors: rows })
}

View File

@@ -0,0 +1,73 @@
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'
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, name, company } = body
if (!email || typeof email !== 'string') {
return NextResponse.json({ error: 'Email required' }, { status: 400 })
}
// 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 })
}
const normalizedEmail = email.toLowerCase().trim()
// Upsert investor
const { rows } = await pool.query(
`INSERT INTO pitch_investors (email, name, company)
VALUES ($1, $2, $3)
ON CONFLICT (email) DO UPDATE SET
name = COALESCE(EXCLUDED.name, pitch_investors.name),
company = COALESCE(EXCLUDED.company, pitch_investors.company),
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],
)
const investor = rows[0]
// Generate magic link
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(normalizedEmail, name || null, magicLinkUrl)
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,
investor_id: investor.id,
email: normalizedEmail,
expires_at: expiresAt.toISOString(),
})
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { revokeAllSessions } from '@/lib/auth'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
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 { investor_id } = body
if (!investor_id) {
return NextResponse.json({ error: 'investor_id required' }, { status: 400 })
}
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 logAdminAudit(adminId, 'investor_revoked', { email: rows[0].email }, request, investor_id)
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSessionFromCookie, logAudit } from '@/lib/auth'
export async function POST(request: NextRequest) {
const session = await getSessionFromCookie()
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
}
const body = await request.json()
const { action, details, slide_id } = body
if (!action || typeof action !== 'string') {
return NextResponse.json({ error: 'action required' }, { status: 400 })
}
// Only allow known client-side actions
const allowedActions = ['slide_viewed', 'assumption_changed', 'chat_message_sent', 'snapshot_saved', 'snapshot_restored']
if (!allowedActions.includes(action)) {
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
}
await logAudit(session.sub, action, details || {}, request, slide_id, session.sessionId)
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSessionFromCookie, revokeSession, clearSessionCookie, logAudit } from '@/lib/auth'
export async function POST(request: NextRequest) {
const session = await getSessionFromCookie()
if (session) {
await revokeSession(session.sessionId)
await logAudit(session.sub, 'logout', {}, request, undefined, session.sessionId)
}
await clearSessionCookie()
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server'
import pool from '@/lib/db'
import { getSessionFromCookie, validateSession } from '@/lib/auth'
export async function GET() {
const session = await getSessionFromCookie()
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
}
const valid = await validateSession(session.sessionId, session.sub)
if (!valid) {
return NextResponse.json({ error: 'Session expired' }, { status: 401 })
}
const { rows } = await pool.query(
`SELECT id, email, name, company, status, last_login_at, login_count, created_at
FROM pitch_investors WHERE id = $1`,
[session.sub]
)
if (rows.length === 0 || rows[0].status === 'revoked') {
return NextResponse.json({ error: 'Access revoked' }, { status: 403 })
}
return NextResponse.json({ investor: rows[0] })
}

View File

@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { createSession, setSessionCookie, getClientIp, logAudit, hashToken } from '@/lib/auth'
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
export async function POST(request: NextRequest) {
const ip = getClientIp(request) || 'unknown'
// Rate limit by IP
const rl = checkRateLimit(`auth-verify:${ip}`, RATE_LIMITS.authVerify)
if (!rl.allowed) {
return NextResponse.json({ error: 'Too many attempts. Try again later.' }, { status: 429 })
}
const body = await request.json()
const { token } = body
if (!token || typeof token !== 'string') {
return NextResponse.json({ error: 'Token required' }, { status: 400 })
}
// Find the magic link
const { rows } = await pool.query(
`SELECT ml.id, ml.investor_id, ml.expires_at, ml.used_at, i.email, i.status as investor_status
FROM pitch_magic_links ml
JOIN pitch_investors i ON i.id = ml.investor_id
WHERE ml.token = $1`,
[token]
)
if (rows.length === 0) {
await logAudit(null, 'login_failed', { reason: 'invalid_token' }, request)
return NextResponse.json({ error: 'Invalid link' }, { status: 401 })
}
const link = rows[0]
if (link.used_at) {
await logAudit(link.investor_id, 'login_failed', { reason: 'token_already_used' }, request)
return NextResponse.json({ error: 'This link has already been used. Please request a new one.' }, { status: 401 })
}
if (new Date(link.expires_at) < new Date()) {
await logAudit(link.investor_id, 'login_failed', { reason: 'token_expired' }, request)
return NextResponse.json({ error: 'This link has expired. Please request a new one.' }, { status: 401 })
}
if (link.investor_status === 'revoked') {
await logAudit(link.investor_id, 'login_failed', { reason: 'investor_revoked' }, request)
return NextResponse.json({ error: 'Access has been revoked.' }, { status: 403 })
}
const ua = request.headers.get('user-agent')
// Mark token as used
await pool.query(
`UPDATE pitch_magic_links SET used_at = NOW(), ip_address = $1, user_agent = $2 WHERE id = $3`,
[ip, ua, link.id]
)
// Activate investor if first login
await pool.query(
`UPDATE pitch_investors SET status = 'active', last_login_at = NOW(), login_count = login_count + 1, updated_at = NOW()
WHERE id = $1`,
[link.investor_id]
)
// Create session and set cookie
const { sessionId, jwt } = await createSession(link.investor_id, ip, ua)
await setSessionCookie(jwt)
// Check for new IP
const { rows: prevSessions } = await pool.query(
`SELECT DISTINCT ip_address FROM pitch_sessions WHERE investor_id = $1 AND id != $2 AND ip_address IS NOT NULL`,
[link.investor_id, sessionId]
)
const knownIps = prevSessions.map((r: { ip_address: string }) => r.ip_address)
if (knownIps.length > 0 && !knownIps.includes(ip)) {
await logAudit(link.investor_id, 'new_ip_detected', { ip, known_ips: knownIps }, request, undefined, sessionId)
}
await logAudit(link.investor_id, 'login_success', { email: link.email }, request, undefined, sessionId)
return NextResponse.json({ success: true, redirect: '/' })
}

View File

@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() })
}

View File

@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { getSessionFromCookie } from '@/lib/auth'
export async function GET() {
const session = await getSessionFromCookie()
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
}
const { rows } = await pool.query(
`SELECT id, scenario_id, assumptions, label, is_latest, created_at
FROM pitch_investor_snapshots
WHERE investor_id = $1 AND is_latest = true
ORDER BY created_at DESC`,
[session.sub]
)
return NextResponse.json({ snapshots: rows })
}
export async function POST(request: NextRequest) {
const session = await getSessionFromCookie()
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
}
const body = await request.json()
const { scenario_id, assumptions, label } = body
if (!scenario_id || !assumptions) {
return NextResponse.json({ error: 'scenario_id and assumptions required' }, { status: 400 })
}
// Mark previous latest as not latest
await pool.query(
`UPDATE pitch_investor_snapshots SET is_latest = false
WHERE investor_id = $1 AND scenario_id = $2 AND is_latest = true`,
[session.sub, scenario_id]
)
// Insert new snapshot
const { rows } = await pool.query(
`INSERT INTO pitch_investor_snapshots (investor_id, scenario_id, assumptions, label, is_latest)
VALUES ($1, $2, $3, $4, true)
RETURNING id, created_at`,
[session.sub, scenario_id, JSON.stringify(assumptions), label || null]
)
return NextResponse.json({ snapshot: rows[0] })
}
export async function DELETE(request: NextRequest) {
const session = await getSessionFromCookie()
if (!session) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')
if (!id) {
return NextResponse.json({ error: 'Snapshot id required' }, { status: 400 })
}
await pool.query(
`DELETE FROM pitch_investor_snapshots WHERE id = $1 AND investor_id = $2`,
[id, session.sub]
)
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,57 @@
'use client'
import { motion } from 'framer-motion'
export default function AuthPage() {
return (
<div className="h-screen flex items-center justify-center bg-[#0a0a1a] relative overflow-hidden">
{/* Background gradient */}
<div className="absolute inset-0 bg-gradient-to-br from-indigo-950/30 via-transparent to-purple-950/20" />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="relative z-10 text-center max-w-md mx-auto px-6"
>
<div className="mb-8">
<h1 className="text-3xl font-bold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent mb-2">
BreakPilot ComplAI
</h1>
<p className="text-white/30 text-sm">Investor Pitch Deck</p>
</div>
<div className="bg-white/[0.03] border border-white/[0.06] rounded-2xl p-8 backdrop-blur-sm">
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-indigo-500/10 flex items-center justify-center">
<svg className="w-8 h-8 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M21.75 9v.906a2.25 2.25 0 01-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 001.183 1.981l6.478 3.488m8.839 2.51l-4.66-2.51m0 0l-1.023-.55a2.25 2.25 0 00-2.134 0l-1.022.55m0 0l-4.661 2.51m16.5-1.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75A2.25 2.25 0 014.5 4.5h15a2.25 2.25 0 012.25 2.25v11.25z" />
</svg>
</div>
<h2 className="text-xl font-semibold text-white/90 mb-3">
Invitation Required
</h2>
<p className="text-white/50 text-sm leading-relaxed mb-6">
This interactive pitch deck is available by invitation only.
Please check your email for an access link.
</p>
<div className="border-t border-white/[0.06] pt-5">
<p className="text-white/30 text-xs">
Questions? Contact us at{' '}
<a href="mailto:pitch@breakpilot.ai" className="text-indigo-400/80 hover:text-indigo-400 transition-colors">
pitch@breakpilot.ai
</a>
</p>
</div>
</div>
<p className="mt-6 text-white/20 text-xs">
We are an AI-first company. No PDFs. No slide decks. Just code.
</p>
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,112 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { motion } from 'framer-motion'
function VerifyContent() {
const searchParams = useSearchParams()
const router = useRouter()
const token = searchParams.get('token')
const [status, setStatus] = useState<'verifying' | 'success' | 'error'>('verifying')
const [errorMsg, setErrorMsg] = useState('')
useEffect(() => {
if (!token) {
setStatus('error')
setErrorMsg('No access token provided.')
return
}
async function verify() {
try {
const res = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
if (res.ok) {
setStatus('success')
setTimeout(() => router.push('/'), 1000)
} else {
const data = await res.json()
setStatus('error')
setErrorMsg(data.error || 'Verification failed.')
}
} catch {
setStatus('error')
setErrorMsg('Network error. Please try again.')
}
}
verify()
}, [token, router])
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4 }}
className="relative z-10 text-center max-w-md mx-auto px-6"
>
<div className="bg-white/[0.03] border border-white/[0.06] rounded-2xl p-8 backdrop-blur-sm">
{status === 'verifying' && (
<>
<div className="w-12 h-12 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-white/60">Verifying your access link...</p>
</>
)}
{status === 'success' && (
<>
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-green-500/10 flex items-center justify-center">
<svg className="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-white/80 font-medium">Access verified!</p>
<p className="text-white/40 text-sm mt-2">Redirecting to pitch deck...</p>
</>
)}
{status === 'error' && (
<>
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-500/10 flex items-center justify-center">
<svg className="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p className="text-white/80 font-medium mb-2">Access Denied</p>
<p className="text-white/50 text-sm">{errorMsg}</p>
<a
href="/auth"
className="inline-block mt-6 text-indigo-400 text-sm hover:text-indigo-300 transition-colors"
>
Back to login
</a>
</>
)}
</div>
</motion.div>
)
}
export default function VerifyPage() {
return (
<div className="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" />
<Suspense
fallback={
<div className="relative z-10 text-center">
<div className="w-12 h-12 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-white/60">Loading...</p>
</div>
}
>
<VerifyContent />
</Suspense>
</div>
)
}

View File

@@ -1,9 +1,23 @@
import type { Metadata } from 'next'
import type { Metadata, Viewport } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'BreakPilot ComplAI — Investor Pitch Deck',
description: 'Datensouveraenitaet meets KI-Compliance. Pre-Seed Q4 2026.',
manifest: '/manifest.json',
robots: { index: false, follow: false },
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: 'BreakPilot Pitch',
},
}
export const viewport: Viewport = {
themeColor: '#6366f1',
width: 'device-width',
initialScale: 1,
maximumScale: 1,
}
export default function RootLayout({
@@ -13,8 +27,22 @@ export default function RootLayout({
}) {
return (
<html lang="de" className="dark">
<head>
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
</head>
<body className="bg-[#0a0a1a] text-white antialiased overflow-hidden h-screen">
{children}
<script
dangerouslySetInnerHTML={{
__html: `
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
`,
}}
/>
</body>
</html>
)

View File

@@ -2,14 +2,24 @@
import { useState, useCallback } from 'react'
import { Language } from '@/lib/types'
import { useAuth } from '@/lib/hooks/useAuth'
import PitchDeck from '@/components/PitchDeck'
export default function Home() {
const [lang, setLang] = useState<Language>('de')
const { investor, loading, logout } = useAuth()
const toggleLanguage = useCallback(() => {
setLang(prev => prev === 'de' ? 'en' : 'de')
}, [])
return <PitchDeck lang={lang} onToggleLanguage={toggleLanguage} />
if (loading) {
return (
<div className="h-screen flex items-center justify-center">
<div className="w-12 h-12 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
return <PitchDeck lang={lang} onToggleLanguage={toggleLanguage} investor={investor} onLogout={logout} />
}

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