Files
breakpilot-core/pitch-deck/lib/admin-auth.ts
Sharang Parnerkar fc71439011
Some checks failed
CI / Deploy (pull_request) Has been skipped
CI / go-lint (pull_request) Failing after 2s
CI / python-lint (pull_request) Failing after 11s
CI / nodejs-lint (pull_request) Failing after 2s
CI / test-go-consent (pull_request) Failing after 2s
CI / test-python-voice (pull_request) Failing after 9s
CI / test-bqas (pull_request) Failing after 8s
feat(pitch-deck): admin UI for investor + financial-model management
Adds /pitch-admin dashboard with real admin accounts (bcrypt) and full
audit attribution for every state-changing action.

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:27:18 +02:00

207 lines
6.1 KiB
TypeScript

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