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:
62
pitch-deck/app/api/admin-auth/login/route.ts
Normal file
62
pitch-deck/app/api/admin-auth/login/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { verifyPassword, createAdminSession, setAdminCookie, logAdminAudit } from '@/lib/admin-auth'
|
||||
import { getClientIp } from '@/lib/auth'
|
||||
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ip = getClientIp(request) || 'unknown'
|
||||
|
||||
// Reuse the auth-verify rate limit (10/IP/15min)
|
||||
const rl = checkRateLimit(`admin-login:${ip}`, RATE_LIMITS.authVerify)
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json({ error: 'Too many attempts. Try again later.' }, { status: 429 })
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const email = (body.email || '').trim().toLowerCase()
|
||||
const password = body.password || ''
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json({ error: 'Email and password required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, email, name, password_hash, is_active FROM pitch_admins WHERE email = $1`,
|
||||
[email],
|
||||
)
|
||||
|
||||
if (rows.length === 0) {
|
||||
await logAdminAudit(null, 'admin_login_failed', { email, reason: 'unknown_email' }, request)
|
||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
|
||||
}
|
||||
|
||||
const admin = rows[0]
|
||||
|
||||
if (!admin.is_active) {
|
||||
await logAdminAudit(admin.id, 'admin_login_failed', { reason: 'inactive' }, request)
|
||||
return NextResponse.json({ error: 'Account disabled' }, { status: 403 })
|
||||
}
|
||||
|
||||
const ok = await verifyPassword(password, admin.password_hash)
|
||||
if (!ok) {
|
||||
await logAdminAudit(admin.id, 'admin_login_failed', { reason: 'wrong_password' }, request)
|
||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
|
||||
}
|
||||
|
||||
const ua = request.headers.get('user-agent')
|
||||
const { jwt } = await createAdminSession(admin.id, ip, ua)
|
||||
await setAdminCookie(jwt)
|
||||
|
||||
await pool.query(
|
||||
`UPDATE pitch_admins SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1`,
|
||||
[admin.id],
|
||||
)
|
||||
|
||||
await logAdminAudit(admin.id, 'admin_login_success', { email }, request)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
admin: { id: admin.id, email: admin.email, name: admin.name },
|
||||
})
|
||||
}
|
||||
17
pitch-deck/app/api/admin-auth/logout/route.ts
Normal file
17
pitch-deck/app/api/admin-auth/logout/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
getAdminPayloadFromCookie,
|
||||
revokeAdminSession,
|
||||
clearAdminCookie,
|
||||
logAdminAudit,
|
||||
} from '@/lib/admin-auth'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const payload = await getAdminPayloadFromCookie()
|
||||
if (payload) {
|
||||
await revokeAdminSession(payload.sessionId)
|
||||
await logAdminAudit(payload.sub, 'admin_logout', {}, request)
|
||||
}
|
||||
await clearAdminCookie()
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
10
pitch-deck/app/api/admin-auth/me/route.ts
Normal file
10
pitch-deck/app/api/admin-auth/me/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getAdminFromCookie } from '@/lib/admin-auth'
|
||||
|
||||
export async function GET() {
|
||||
const admin = await getAdminFromCookie()
|
||||
if (!admin) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
|
||||
}
|
||||
return NextResponse.json({ admin })
|
||||
}
|
||||
Reference in New Issue
Block a user