diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index 197b366..18ac205 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -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} diff --git a/pitch-deck/app/api/admin-auth/login/route.ts b/pitch-deck/app/api/admin-auth/login/route.ts new file mode 100644 index 0000000..5fcbccd --- /dev/null +++ b/pitch-deck/app/api/admin-auth/login/route.ts @@ -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 }, + }) +} diff --git a/pitch-deck/app/api/admin-auth/logout/route.ts b/pitch-deck/app/api/admin-auth/logout/route.ts new file mode 100644 index 0000000..d990fc7 --- /dev/null +++ b/pitch-deck/app/api/admin-auth/logout/route.ts @@ -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 }) +} diff --git a/pitch-deck/app/api/admin-auth/me/route.ts b/pitch-deck/app/api/admin-auth/me/route.ts new file mode 100644 index 0000000..a202fdf --- /dev/null +++ b/pitch-deck/app/api/admin-auth/me/route.ts @@ -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 }) +} diff --git a/pitch-deck/app/api/admin/admins/[id]/route.ts b/pitch-deck/app/api/admin/admins/[id]/route.ts new file mode 100644 index 0000000..ca7daef --- /dev/null +++ b/pitch-deck/app/api/admin/admins/[id]/route.ts @@ -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] }) +} diff --git a/pitch-deck/app/api/admin/admins/route.ts b/pitch-deck/app/api/admin/admins/route.ts new file mode 100644 index 0000000..dc01044 --- /dev/null +++ b/pitch-deck/app/api/admin/admins/route.ts @@ -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 + } +} diff --git a/pitch-deck/app/api/admin/audit-logs/route.ts b/pitch-deck/app/api/admin/audit-logs/route.ts index 67204ef..74509d1 100644 --- a/pitch-deck/app/api/admin/audit-logs/route.ts +++ b/pitch-deck/app/api/admin/audit-logs/route.ts @@ -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 }) } diff --git a/pitch-deck/app/api/admin/dashboard/route.ts b/pitch-deck/app/api/admin/dashboard/route.ts new file mode 100644 index 0000000..8104ab7 --- /dev/null +++ b/pitch-deck/app/api/admin/dashboard/route.ts @@ -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, + }) +} diff --git a/pitch-deck/app/api/admin/fm/assumptions/[id]/route.ts b/pitch-deck/app/api/admin/fm/assumptions/[id]/route.ts new file mode 100644 index 0000000..bb531c2 --- /dev/null +++ b/pitch-deck/app/api/admin/fm/assumptions/[id]/route.ts @@ -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] }) +} diff --git a/pitch-deck/app/api/admin/fm/scenarios/[id]/route.ts b/pitch-deck/app/api/admin/fm/scenarios/[id]/route.ts new file mode 100644 index 0000000..efa4af2 --- /dev/null +++ b/pitch-deck/app/api/admin/fm/scenarios/[id]/route.ts @@ -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] }) +} diff --git a/pitch-deck/app/api/admin/fm/scenarios/route.ts b/pitch-deck/app/api/admin/fm/scenarios/route.ts new file mode 100644 index 0000000..ec9b65a --- /dev/null +++ b/pitch-deck/app/api/admin/fm/scenarios/route.ts @@ -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 }) +} diff --git a/pitch-deck/app/api/admin/investors/[id]/resend/route.ts b/pitch-deck/app/api/admin/investors/[id]/resend/route.ts new file mode 100644 index 0000000..6156ab7 --- /dev/null +++ b/pitch-deck/app/api/admin/investors/[id]/resend/route.ts @@ -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() }) +} diff --git a/pitch-deck/app/api/admin/investors/[id]/route.ts b/pitch-deck/app/api/admin/investors/[id]/route.ts new file mode 100644 index 0000000..ad447cd --- /dev/null +++ b/pitch-deck/app/api/admin/investors/[id]/route.ts @@ -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] }) +} diff --git a/pitch-deck/app/api/admin/investors/route.ts b/pitch-deck/app/api/admin/investors/route.ts index ad9fe69..712eff0 100644 --- a/pitch-deck/app/api/admin/investors/route.ts +++ b/pitch-deck/app/api/admin/investors/route.ts @@ -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 }) diff --git a/pitch-deck/app/api/admin/invite/route.ts b/pitch-deck/app/api/admin/invite/route.ts index 4607efb..602026e 100644 --- a/pitch-deck/app/api/admin/invite/route.ts +++ b/pitch-deck/app/api/admin/invite/route.ts @@ -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, diff --git a/pitch-deck/app/api/admin/revoke/route.ts b/pitch-deck/app/api/admin/revoke/route.ts index 366ee0c..cf0e6b5 100644 --- a/pitch-deck/app/api/admin/revoke/route.ts +++ b/pitch-deck/app/api/admin/revoke/route.ts @@ -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 }) } diff --git a/pitch-deck/app/pitch-admin/(authed)/admins/page.tsx b/pitch-deck/app/pitch-admin/(authed)/admins/page.tsx new file mode 100644 index 0000000..0adfc69 --- /dev/null +++ b/pitch-deck/app/pitch-admin/(authed)/admins/page.tsx @@ -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([]) + 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(null) + const [resetId, setResetId] = useState(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 ( +
+
+
+

Admins

+

{admins.length} total

+
+ +
+ + {showAdd && ( +
+
+ 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" + /> + 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" + /> + 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" + /> +
+ {error && ( +
{error}
+ )} +
+ + +
+
+ )} + + {loading ? ( +
+ ) : ( +
+ + + + + + + + + + + + {admins.map(a => ( + + + + + + + + ))} + +
AdminStatusLast loginCreatedActions
+
{a.name}
+
{a.email}
+
+ + {a.is_active ? 'Active' : 'Disabled'} + + + {a.last_login_at ? new Date(a.last_login_at).toLocaleString() : '—'} + + {new Date(a.created_at).toLocaleDateString()} + +
+ + +
+
+
+ )} + + {/* Reset password modal */} + {resetId && ( +
setResetId(null)}> +
e.stopPropagation()} + className="bg-[#0a0a1a] border border-white/[0.1] rounded-2xl p-6 w-full max-w-sm space-y-4" + > +

Reset Password

+

+ The admin's active sessions will be revoked. +

+ 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" + /> +
+ + +
+
+
+ )} + + {toast && ( +
+ {toast} +
+ )} +
+ ) +} diff --git a/pitch-deck/app/pitch-admin/(authed)/audit/page.tsx b/pitch-deck/app/pitch-admin/(authed)/audit/page.tsx new file mode 100644 index 0000000..a4192b9 --- /dev/null +++ b/pitch-deck/app/pitch-admin/(authed)/audit/page.tsx @@ -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([]) + 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 ( +
+
+

Audit Log

+

{total} total events

+
+ +
+ + + {(actorType || action) && ( + + )} +
+ +
+ {loading ? ( +
+
+
+ ) : ( + + )} +
+ + {totalPages > 1 && ( +
+
+ Page {page + 1} of {totalPages} +
+
+ + +
+
+ )} +
+ ) +} diff --git a/pitch-deck/app/pitch-admin/(authed)/financial-model/[scenarioId]/page.tsx b/pitch-deck/app/pitch-admin/(authed)/financial-model/[scenarioId]/page.tsx new file mode 100644 index 0000000..02b1038 --- /dev/null +++ b/pitch-deck/app/pitch-admin/(authed)/financial-model/[scenarioId]/page.tsx @@ -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(null) + const [loading, setLoading] = useState(true) + const [edits, setEdits] = useState>({}) + const [savingId, setSavingId] = useState(null) + const [toast, setToast] = useState(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
+ if (!scenario) return
Scenario not found
+ + // Group by category + const byCategory: Record = {} + scenario.assumptions.forEach(a => { + if (!byCategory[a.category]) byCategory[a.category] = [] + byCategory[a.category].push(a) + }) + + return ( +
+ + Back to scenarios + + +
+
+
+

{scenario.name}

+ {scenario.is_default && ( + + Default + + )} +
+ {scenario.description &&

{scenario.description}

} +
+ +
+ {Object.entries(byCategory).map(([cat, items]) => ( +
+

{cat}

+
+ {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 ( +
+
+
{a.label_en || a.label_de}
+
{a.key}
+
+
+ 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 && {a.unit}} +
+
+ {a.min_value !== null && a.max_value !== null ? `${a.min_value}–${a.max_value}` : ''} +
+
+ {isEdited && ( + + )} +
+
+ ) + })} +
+
+ ))} +
+ + {toast && ( +
+ {toast} +
+ )} +
+ ) +} diff --git a/pitch-deck/app/pitch-admin/(authed)/financial-model/page.tsx b/pitch-deck/app/pitch-admin/(authed)/financial-model/page.tsx new file mode 100644 index 0000000..a0825bd --- /dev/null +++ b/pitch-deck/app/pitch-admin/(authed)/financial-model/page.tsx @@ -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([]) + 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
+ } + + return ( +
+
+

Financial Model

+

+ Edit default scenarios and assumptions. Investor snapshots are not affected. +

+
+ +
+ {scenarios.map(s => ( + +
+
+
+
+

{s.name}

+ {s.is_default && ( + + Default + + )} +
+ {s.description &&

{s.description}

} +
+ +
+
+ {s.assumptions.length} assumption{s.assumptions.length === 1 ? '' : 's'} +
+ + ))} +
+
+ ) +} diff --git a/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx b/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx new file mode 100644 index 0000000..f3c7b61 --- /dev/null +++ b/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx @@ -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 | null + ip_address: string | null + slide_id: string | null + admin_email: string | null + admin_name: string | null + }> +} + +const STATUS_STYLES: Record = { + 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(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(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
+ if (!data) return
Investor not found
+ + const inv = data.investor + + return ( +
+ + Back to investors + + + {/* Header */} +
+
+
+ {editing ? ( +
+ 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" + /> + 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" + /> +
+ ) : ( + <> +
+

{inv.name || inv.email}

+ + {inv.status} + +
+
{inv.company || '—'}
+
{inv.email}
+ + )} +
+
+ {editing ? ( + <> + + + + ) : ( + <> + + + + + )} +
+
+ +
+
+
Logins
+
{inv.login_count}
+
+
+
Last login
+
+ {inv.last_login_at ? new Date(inv.last_login_at).toLocaleString() : '—'} +
+
+
+
Sessions
+
{data.sessions.length}
+
+
+
Snapshots
+
{data.snapshots.length}
+
+
+
+ + {/* Audit log for this investor */} +
+

Activity

+ +
+ + {toast && ( +
+ {toast} +
+ )} +
+ ) +} diff --git a/pitch-deck/app/pitch-admin/(authed)/investors/new/page.tsx b/pitch-deck/app/pitch-admin/(authed)/investors/new/page.tsx new file mode 100644 index 0000000..f7cd285 --- /dev/null +++ b/pitch-deck/app/pitch-admin/(authed)/investors/new/page.tsx @@ -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 ( +
+ + Back to investors + + +

Invite Investor

+

+ A magic link will be emailed. Single-use, expires in {process.env.NEXT_PUBLIC_MAGIC_LINK_TTL_HOURS || '72'}h. +

+ +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + {error && ( +
+ {error} +
+ )} + +
+ + Cancel + + +
+
+
+ ) +} diff --git a/pitch-deck/app/pitch-admin/(authed)/investors/page.tsx b/pitch-deck/app/pitch-admin/(authed)/investors/page.tsx new file mode 100644 index 0000000..16724d1 --- /dev/null +++ b/pitch-deck/app/pitch-admin/(authed)/investors/page.tsx @@ -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 = { + 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([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState('') + const [statusFilter, setStatusFilter] = useState('all') + const [busy, setBusy] = useState(null) + const [toast, setToast] = useState(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 ( +
+
+
+

Investors

+

{investors.length} total · {filtered.length} shown

+
+ + + Invite Investor + +
+ +
+
+ + 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" + /> +
+ +
+ + {loading ? ( +
+
+
+ ) : ( +
+ + + + + + + + + + + + + {filtered.length === 0 && ( + + + + )} + {filtered.map((inv) => ( + + + + + + + + + ))} + +
InvestorStatusLoginsSlidesLast loginActions
No investors
+ +
{inv.name || inv.email}
+
+ {inv.company ? `${inv.company} · ` : ''}{inv.email} +
+ +
+ + {inv.status} + + {inv.login_count}{inv.slides_viewed} + {inv.last_login_at ? new Date(inv.last_login_at).toLocaleDateString() : '—'} + +
+ + + + + +
+
+
+ )} + + {toast && ( +
+ {toast} +
+ )} +
+ ) +} diff --git a/pitch-deck/app/pitch-admin/(authed)/layout.tsx b/pitch-deck/app/pitch-admin/(authed)/layout.tsx new file mode 100644 index 0000000..4a2f0dd --- /dev/null +++ b/pitch-deck/app/pitch-admin/(authed)/layout.tsx @@ -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 ( + + {children} + + ) +} diff --git a/pitch-deck/app/pitch-admin/(authed)/page.tsx b/pitch-deck/app/pitch-admin/(authed)/page.tsx new file mode 100644 index 0000000..4aff287 --- /dev/null +++ b/pitch-deck/app/pitch-admin/(authed)/page.tsx @@ -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 | 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(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetch('/api/admin/dashboard') + .then((r) => r.json()) + .then(setData) + .finally(() => setLoading(false)) + }, []) + + if (loading) { + return ( +
+
+
+ ) + } + + if (!data) return
Failed to load dashboard
+ + return ( +
+
+
+

Dashboard

+

Investor activity overview

+
+ + + Invite Investor + +
+ +
+ + + + +
+ +
+
+
+

Recent Logins

+ + All investors + +
+ {data.recent_logins.length === 0 ? ( +
No logins yet
+ ) : ( +
    + {data.recent_logins.map((row, i) => ( +
  • +
    + + {row.name || row.email} + +
    {row.company || row.email}
    +
    +
    + {new Date(row.created_at).toLocaleString()} +
    +
  • + ))} +
+ )} +
+ +
+
+

Recent Activity

+ + Full log + +
+
+ +
+
+
+
+ ) +} diff --git a/pitch-deck/app/pitch-admin/login/page.tsx b/pitch-deck/app/pitch-admin/login/page.tsx new file mode 100644 index 0000000..7e4cfb1 --- /dev/null +++ b/pitch-deck/app/pitch-admin/login/page.tsx @@ -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 ( +
+
+ +
+
+
+ + + + +
+

Pitch Admin

+

BreakPilot ComplAI

+
+ +
+
+ + 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" + /> +
+ +
+ + 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="••••••••" + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ +

+ Admin access only. All actions are logged. +

+
+
+ ) +} diff --git a/pitch-deck/components/pitch-admin/AdminShell.tsx b/pitch-deck/components/pitch-admin/AdminShell.tsx new file mode 100644 index 0000000..32c9e86 --- /dev/null +++ b/pitch-deck/components/pitch-admin/AdminShell.tsx @@ -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 ( +
+ {/* Sidebar */} + + + {/* Mobile overlay */} + {open && ( +
setOpen(false)} + className="fixed inset-0 bg-black/60 z-30 lg:hidden" + /> + )} + + {/* Main content */} +
+
+ +
Pitch Admin
+
+
+
{children}
+
+
+ ) +} diff --git a/pitch-deck/components/pitch-admin/AuditLogTable.tsx b/pitch-deck/components/pitch-admin/AuditLogTable.tsx new file mode 100644 index 0000000..da613cf --- /dev/null +++ b/pitch-deck/components/pitch-admin/AuditLogTable.tsx @@ -0,0 +1,153 @@ +'use client' + +export interface AuditLogRow { + id: number | string + action: string + created_at: string + details: Record | 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 = { + 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 | 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 | undefined + const after = details.after as Record | 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 | undefined + const after = details.after as Record | 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
No audit events
+ } + + return ( +
+ + + + + {showActor && } + + + + + + + {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 ( + + + {showActor && ( + + )} + + + + + ) + })} + +
WhenActorActionTarget / DetailsIP
{formatDate(row.created_at)} +
+ + {actor.kind} + +
+
{actor.label}
+
+
+
+ {row.action} + + {target &&
→ {target}
} + {summary &&
{summary}
} +
{row.ip_address || '—'}
+
+ ) +} diff --git a/pitch-deck/components/pitch-admin/StatCard.tsx b/pitch-deck/components/pitch-admin/StatCard.tsx new file mode 100644 index 0000000..a0043ef --- /dev/null +++ b/pitch-deck/components/pitch-admin/StatCard.tsx @@ -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 ( +
+
+ {label} + {Icon && ( +
+ +
+ )} +
+
{value}
+ {hint &&
{hint}
} +
+ ) +} diff --git a/pitch-deck/lib/admin-auth.ts b/pitch-deck/lib/admin-auth.ts new file mode 100644 index 0000000..ad0d405 --- /dev/null +++ b/pitch-deck/lib/admin-auth.ts @@ -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 { + return bcrypt.hash(password, 12) +} + +export async function verifyPassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash) +} + +export async function createAdminJwt(payload: AdminJwtPayload): Promise { + 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 { + 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 { + 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 { + await pool.query(`UPDATE pitch_admin_sessions SET revoked = true WHERE id = $1`, [sessionId]) +} + +export async function revokeAllAdminSessions(adminId: string): Promise { + await pool.query(`UPDATE pitch_admin_sessions SET revoked = true WHERE admin_id = $1`, [adminId]) +} + +export async function setAdminCookie(jwt: string): Promise { + 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 { + const cookieStore = await cookies() + cookieStore.delete(ADMIN_COOKIE_NAME) +} + +export async function getAdminPayloadFromCookie(): Promise { + 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 { + 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 { + // 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 = {}, + request?: Request, + targetInvestorId?: string | null, +): Promise { + await logAudit( + null, // investor_id + action, + details, + request, + undefined, // slide_id + undefined, // session_id + adminId, + targetInvestorId ?? null, + ) +} + +export { ADMIN_COOKIE_NAME } diff --git a/pitch-deck/lib/auth.ts b/pitch-deck/lib/auth.ts index cc6684d..caf7f63 100644 --- a/pitch-deck/lib/auth.ts +++ b/pitch-deck/lib/auth.ts @@ -148,13 +148,16 @@ export async function logAudit( details: Record = {}, request?: Request, slideId?: string, - sessionId?: string + sessionId?: string, + adminId?: string | null, + targetInvestorId?: string | null, ): Promise { 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] ) } diff --git a/pitch-deck/middleware.ts b/pitch-deck/middleware.ts index e34fd81..8e8f27e 100644 --- a/pitch-deck/middleware.ts +++ b/pitch-deck/middleware.ts @@ -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).*)'], } diff --git a/pitch-deck/migrations/002_admin_users.sql b/pitch-deck/migrations/002_admin_users.sql new file mode 100644 index 0000000..b620e74 --- /dev/null +++ b/pitch-deck/migrations/002_admin_users.sql @@ -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); diff --git a/pitch-deck/package-lock.json b/pitch-deck/package-lock.json index 8270760..f842022 100644 --- a/pitch-deck/package-lock.json +++ b/pitch-deck/package-lock.json @@ -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", diff --git a/pitch-deck/package.json b/pitch-deck/package.json index f30cf06..1fcb142 100644 --- a/pitch-deck/package.json +++ b/pitch-deck/package.json @@ -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" } } diff --git a/pitch-deck/scripts/create-admin.ts b/pitch-deck/scripts/create-admin.ts new file mode 100644 index 0000000..6a4f498 --- /dev/null +++ b/pitch-deck/scripts/create-admin.ts @@ -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) +})