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:
81
pitch-deck/app/api/admin/admins/[id]/route.ts
Normal file
81
pitch-deck/app/api/admin/admins/[id]/route.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin, logAdminAudit, hashPassword, revokeAllAdminSessions } from '@/lib/admin-auth'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, ctx: RouteContext) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const actorAdminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const { id } = await ctx.params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { name, is_active, password } = body
|
||||
|
||||
const before = await pool.query(
|
||||
`SELECT email, name, is_active FROM pitch_admins WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
if (before.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Admin not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const updates: string[] = []
|
||||
const params: unknown[] = []
|
||||
let p = 1
|
||||
|
||||
if (typeof name === 'string' && name.trim()) {
|
||||
updates.push(`name = $${p++}`)
|
||||
params.push(name.trim())
|
||||
}
|
||||
if (typeof is_active === 'boolean') {
|
||||
updates.push(`is_active = $${p++}`)
|
||||
params.push(is_active)
|
||||
}
|
||||
if (typeof password === 'string') {
|
||||
if (password.length < 12) {
|
||||
return NextResponse.json({ error: 'password must be at least 12 characters' }, { status: 400 })
|
||||
}
|
||||
const hash = await hashPassword(password)
|
||||
updates.push(`password_hash = $${p++}`)
|
||||
params.push(hash)
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return NextResponse.json({ error: 'no fields to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
updates.push(`updated_at = NOW()`)
|
||||
params.push(id)
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE pitch_admins SET ${updates.join(', ')}
|
||||
WHERE id = $${p}
|
||||
RETURNING id, email, name, is_active, last_login_at, created_at`,
|
||||
params,
|
||||
)
|
||||
|
||||
// If deactivated or password changed, revoke their sessions
|
||||
if (is_active === false || typeof password === 'string') {
|
||||
await revokeAllAdminSessions(id)
|
||||
}
|
||||
|
||||
const action = is_active === false ? 'admin_deactivated' : 'admin_edited'
|
||||
await logAdminAudit(
|
||||
actorAdminId,
|
||||
action,
|
||||
{
|
||||
target_admin_id: id,
|
||||
target_email: before.rows[0].email,
|
||||
before: before.rows[0],
|
||||
after: { name: rows[0].name, is_active: rows[0].is_active },
|
||||
password_changed: typeof password === 'string',
|
||||
},
|
||||
request,
|
||||
)
|
||||
|
||||
return NextResponse.json({ admin: rows[0] })
|
||||
}
|
||||
52
pitch-deck/app/api/admin/admins/route.ts
Normal file
52
pitch-deck/app/api/admin/admins/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin, logAdminAudit, hashPassword } from '@/lib/admin-auth'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, email, name, is_active, last_login_at, created_at, updated_at
|
||||
FROM pitch_admins ORDER BY created_at ASC`,
|
||||
)
|
||||
return NextResponse.json({ admins: rows })
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const email = (body.email || '').trim().toLowerCase()
|
||||
const name = (body.name || '').trim()
|
||||
const password = body.password || ''
|
||||
|
||||
if (!email || !name || !password) {
|
||||
return NextResponse.json({ error: 'email, name, password required' }, { status: 400 })
|
||||
}
|
||||
if (password.length < 12) {
|
||||
return NextResponse.json({ error: 'password must be at least 12 characters' }, { status: 400 })
|
||||
}
|
||||
|
||||
const hash = await hashPassword(password)
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO pitch_admins (email, name, password_hash, is_active)
|
||||
VALUES ($1, $2, $3, true)
|
||||
RETURNING id, email, name, is_active, created_at`,
|
||||
[email, name, hash],
|
||||
)
|
||||
const newAdmin = rows[0]
|
||||
await logAdminAudit(adminId, 'admin_created', { email, name, new_admin_id: newAdmin.id }, request)
|
||||
return NextResponse.json({ admin: newAdmin })
|
||||
} catch (err) {
|
||||
const e = err as { code?: string }
|
||||
if (e.code === '23505') {
|
||||
return NextResponse.json({ error: 'Email already exists' }, { status: 409 })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user