feat(pitch-deck): admin UI for investor + financial-model management (#3)
All checks were successful
CI / test-go-consent (push) Successful in 42s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 30s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / Deploy (push) Successful in 2s
All checks were successful
CI / test-go-consent (push) Successful in 42s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 30s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / Deploy (push) Successful in 2s
Adds /pitch-admin dashboard with real bcrypt admin accounts and full audit attribution for every state-changing action. - pitch_admins + pitch_admin_sessions tables (migration 002) - pitch_audit_logs.admin_id + target_investor_id columns - lib/admin-auth.ts: bcryptjs, single-session, jose JWT with audience claim - middleware.ts: two-cookie gating with bearer-secret CLI fallback - 14 new API routes (admin-auth, dashboard, investor detail/edit/resend, admins CRUD, fm scenarios + assumptions PATCH) - 9 admin pages: login, dashboard, investors list/new/[id], audit, financial-model list/[id], admins - Bootstrap CLI: npm run admin:create - 36 vitest tests covering auth, admin-auth, rate-limit primitives Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #3.
This commit is contained in:
206
pitch-deck/lib/admin-auth.ts
Normal file
206
pitch-deck/lib/admin-auth.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { SignJWT, jwtVerify } from 'jose'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextResponse } from 'next/server'
|
||||
import pool from './db'
|
||||
import { hashToken, generateToken, getClientIp, logAudit } from './auth'
|
||||
|
||||
const ADMIN_COOKIE_NAME = 'pitch_admin_session'
|
||||
const ADMIN_JWT_AUDIENCE = 'pitch-admin'
|
||||
const ADMIN_JWT_EXPIRY = '2h'
|
||||
const ADMIN_SESSION_EXPIRY_HOURS = 12
|
||||
|
||||
function getJwtSecret() {
|
||||
const secret = process.env.PITCH_JWT_SECRET
|
||||
if (!secret) throw new Error('PITCH_JWT_SECRET not set')
|
||||
return new TextEncoder().encode(secret)
|
||||
}
|
||||
|
||||
export interface Admin {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
last_login_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AdminJwtPayload {
|
||||
sub: string // admin id
|
||||
email: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 12)
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash)
|
||||
}
|
||||
|
||||
export async function createAdminJwt(payload: AdminJwtPayload): Promise<string> {
|
||||
return new SignJWT({ ...payload })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(ADMIN_JWT_EXPIRY)
|
||||
.setAudience(ADMIN_JWT_AUDIENCE)
|
||||
.sign(getJwtSecret())
|
||||
}
|
||||
|
||||
export async function verifyAdminJwt(token: string): Promise<AdminJwtPayload | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getJwtSecret(), { audience: ADMIN_JWT_AUDIENCE })
|
||||
return payload as unknown as AdminJwtPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAdminSession(
|
||||
adminId: string,
|
||||
ip: string | null,
|
||||
userAgent: string | null,
|
||||
): Promise<{ sessionId: string; jwt: string }> {
|
||||
// Single session per admin
|
||||
await pool.query(
|
||||
`UPDATE pitch_admin_sessions SET revoked = true WHERE admin_id = $1 AND revoked = false`,
|
||||
[adminId],
|
||||
)
|
||||
|
||||
const sessionToken = generateToken()
|
||||
const tokenHash = hashToken(sessionToken)
|
||||
const expiresAt = new Date(Date.now() + ADMIN_SESSION_EXPIRY_HOURS * 60 * 60 * 1000)
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO pitch_admin_sessions (admin_id, token_hash, ip_address, user_agent, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
[adminId, tokenHash, ip, userAgent, expiresAt],
|
||||
)
|
||||
|
||||
const sessionId = rows[0].id
|
||||
|
||||
const adminRes = await pool.query(`SELECT email FROM pitch_admins WHERE id = $1`, [adminId])
|
||||
const jwt = await createAdminJwt({
|
||||
sub: adminId,
|
||||
email: adminRes.rows[0].email,
|
||||
sessionId,
|
||||
})
|
||||
|
||||
return { sessionId, jwt }
|
||||
}
|
||||
|
||||
export async function validateAdminSession(sessionId: string, adminId: string): Promise<boolean> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT s.id FROM pitch_admin_sessions s
|
||||
JOIN pitch_admins a ON a.id = s.admin_id
|
||||
WHERE s.id = $1 AND s.admin_id = $2 AND s.revoked = false AND s.expires_at > NOW() AND a.is_active = true`,
|
||||
[sessionId, adminId],
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
export async function revokeAdminSession(sessionId: string): Promise<void> {
|
||||
await pool.query(`UPDATE pitch_admin_sessions SET revoked = true WHERE id = $1`, [sessionId])
|
||||
}
|
||||
|
||||
export async function revokeAllAdminSessions(adminId: string): Promise<void> {
|
||||
await pool.query(`UPDATE pitch_admin_sessions SET revoked = true WHERE admin_id = $1`, [adminId])
|
||||
}
|
||||
|
||||
export async function setAdminCookie(jwt: string): Promise<void> {
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set(ADMIN_COOKIE_NAME, jwt, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: ADMIN_SESSION_EXPIRY_HOURS * 60 * 60,
|
||||
})
|
||||
}
|
||||
|
||||
export async function clearAdminCookie(): Promise<void> {
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.delete(ADMIN_COOKIE_NAME)
|
||||
}
|
||||
|
||||
export async function getAdminPayloadFromCookie(): Promise<AdminJwtPayload | null> {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get(ADMIN_COOKIE_NAME)?.value
|
||||
if (!token) return null
|
||||
return verifyAdminJwt(token)
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side: read the admin row from the cookie. Returns null if no valid session
|
||||
* or the admin is inactive. Use in layout.tsx and API routes.
|
||||
*/
|
||||
export async function getAdminFromCookie(): Promise<Admin | null> {
|
||||
const payload = await getAdminPayloadFromCookie()
|
||||
if (!payload) return null
|
||||
|
||||
const valid = await validateAdminSession(payload.sessionId, payload.sub)
|
||||
if (!valid) return null
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, email, name, is_active, last_login_at, created_at
|
||||
FROM pitch_admins WHERE id = $1`,
|
||||
[payload.sub],
|
||||
)
|
||||
if (rows.length === 0 || !rows[0].is_active) return null
|
||||
return rows[0] as Admin
|
||||
}
|
||||
|
||||
/**
|
||||
* API guard: returns the Admin row, OR a NextResponse 401/403 to return early.
|
||||
* Also accepts the legacy PITCH_ADMIN_SECRET bearer header for CLI/automation —
|
||||
* in that case the returned admin id is null but the request is allowed.
|
||||
*/
|
||||
export type AdminGuardResult =
|
||||
| { kind: 'admin'; admin: Admin }
|
||||
| { kind: 'cli' }
|
||||
| { kind: 'response'; response: NextResponse }
|
||||
|
||||
export async function requireAdmin(request: Request): Promise<AdminGuardResult> {
|
||||
// CLI fallback via shared secret
|
||||
const secret = process.env.PITCH_ADMIN_SECRET
|
||||
if (secret) {
|
||||
const auth = request.headers.get('authorization')
|
||||
if (auth === `Bearer ${secret}`) {
|
||||
return { kind: 'cli' }
|
||||
}
|
||||
}
|
||||
|
||||
const admin = await getAdminFromCookie()
|
||||
if (!admin) {
|
||||
return {
|
||||
kind: 'response',
|
||||
response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
}
|
||||
}
|
||||
return { kind: 'admin', admin }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: log an admin-initiated audit event. Falls back to CLI actor when admin is null.
|
||||
*/
|
||||
export async function logAdminAudit(
|
||||
adminId: string | null,
|
||||
action: string,
|
||||
details: Record<string, unknown> = {},
|
||||
request?: Request,
|
||||
targetInvestorId?: string | null,
|
||||
): Promise<void> {
|
||||
await logAudit(
|
||||
null, // investor_id
|
||||
action,
|
||||
details,
|
||||
request,
|
||||
undefined, // slide_id
|
||||
undefined, // session_id
|
||||
adminId,
|
||||
targetInvestorId ?? null,
|
||||
)
|
||||
}
|
||||
|
||||
export { ADMIN_COOKIE_NAME }
|
||||
@@ -148,13 +148,16 @@ export async function logAudit(
|
||||
details: Record<string, unknown> = {},
|
||||
request?: Request,
|
||||
slideId?: string,
|
||||
sessionId?: string
|
||||
sessionId?: string,
|
||||
adminId?: string | null,
|
||||
targetInvestorId?: string | null,
|
||||
): Promise<void> {
|
||||
const ip = request ? getClientIp(request) : null
|
||||
const ua = request ? request.headers.get('user-agent') : null
|
||||
await pool.query(
|
||||
`INSERT INTO pitch_audit_logs (investor_id, action, details, ip_address, user_agent, slide_id, session_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[investorId, action, JSON.stringify(details), ip, ua, slideId, sessionId]
|
||||
`INSERT INTO pitch_audit_logs
|
||||
(investor_id, action, details, ip_address, user_agent, slide_id, session_id, admin_id, target_investor_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[investorId, action, JSON.stringify(details), ip, ua, slideId, sessionId, adminId ?? null, targetInvestorId ?? null]
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user