feat(admin): password-auth admin panel with 8 pages + 15 API endpoints
Schema migrations: - users.is_admin boolean - users.password_hash text (scrypt N=16384, 16-byte salt) - users.last_login_at timestamp - organizations.suspended + suspended_reason - admin_settings table (DB-stored prompt override + future settings) Auth (@bmm/auth): - hashPassword + verifyPassword via node:crypto scrypt (no extra dep) - loginWithPassword: scrypt-verifies, issues 30-day session, updates last_login_at - seedAdmin: idempotent upsert keyed on email; creates org + membership on first run - AuthedUser now carries isAdmin flag API: - POST /v1/auth/admin/login (email + password) — 300ms throttle on failure - requireAdmin preHandler — 401 if no session, 403 if non-admin - Bootstrap: api on boot calls seedAdmin(ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_NAME) if env present. Idempotent. Admin API routes (all gated by requireAdmin): - GET /v1/admin/overview (totals, trends 7d, server-status breakdown, builds 24h, recent activity) - GET /v1/admin/users (search, per-row org + plan + serverCount) - PATCH /v1/admin/users/:id (isAdmin, name) - DELETE /v1/admin/users/:id (self-delete blocked) - GET /v1/admin/orgs (member + server counts) - PATCH /v1/admin/orgs/:id (plan, quota, suspended; cascades to mcp_servers.status=paused on suspend) - GET /v1/admin/servers (cross-org with status filter) - POST /v1/admin/servers/:id/rebuild (re-queues build using last prompt) - DELETE /v1/admin/servers/:id - GET /v1/admin/builds (status filter, error messages, prompt previews) - GET /v1/admin/builds/:id/logs - GET /v1/admin/audit (system-wide with user email join) - GET /v1/admin/system (DB ping, Redis ping, BullMQ queue depth, docker ps count) - GET /v1/admin/prompt (builtin + override + updatedAt) - PATCH /v1/admin/prompt (value: string | null) — saves DB override or drops it UI (apps/web/app/admin/*): - /admin/login — password form, separate from /login magic-link - AdminLayout — Linear-style sidebar (8 nav items), bottom panel with user email + 'user view' shortcut + logout, client-side requireAdmin guard with redirect - /admin — overview dashboard with 4 metric cards, 2 panels (status + 24h builds), recent activity table linking to full audit - /admin/users — search + admin toggle + delete (self-delete blocked) - /admin/orgs — plan/quota/suspend actions via prompts - /admin/servers — cross-org table with rebuild + delete actions, status filter - /admin/builds — every build cross-fleet with error vs prompt preview - /admin/audit — system-wide log + CSV export + filter dropdowns - /admin/system — auto-refreshing 5s health probes for Postgres, Redis, queue, Docker - /admin/prompt — live editor for the LLM system prompt with built-in baseline, override-state badge, drop-override action, diff preview, save-as-override End-to-end verified: login as marco.frangiskatos@gmail.com + Melusa112233.*, every admin page returns 200, admin login + overview tested via screenshot, docker probe returns true count of running MCP containers.
This commit is contained in:
569
apps/api/src/routes/admin.ts
Normal file
569
apps/api/src/routes/admin.ts
Normal file
@@ -0,0 +1,569 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
adminSettings,
|
||||
auditLog,
|
||||
buildLogs,
|
||||
builds,
|
||||
count,
|
||||
createDb,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
inArray,
|
||||
mcpServers,
|
||||
memberships,
|
||||
organizations,
|
||||
sql,
|
||||
toolCallMetrics,
|
||||
users,
|
||||
} from '@bmm/db';
|
||||
import { SYSTEM_PROMPT } from '@bmm/llm';
|
||||
import { requireAdmin } from '../plugins/session.js';
|
||||
import { getRedis } from '../lib/redis.js';
|
||||
import { getBuildQueue } from '../lib/queue.js';
|
||||
import { audit } from '../lib/audit.js';
|
||||
|
||||
const db = createDb();
|
||||
|
||||
const PROMPT_KEY = 'system_prompt_override';
|
||||
|
||||
export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
// ---- Overview metrics ----
|
||||
app.get('/v1/admin/overview', { preHandler: requireAdmin }, async (_req, reply) => {
|
||||
const dayAgo = new Date(Date.now() - 24 * 3600_000);
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 86400_000);
|
||||
|
||||
const [
|
||||
userCount,
|
||||
orgCount,
|
||||
serverCount,
|
||||
buildCount,
|
||||
callCount,
|
||||
liveCount,
|
||||
failedBuildCount,
|
||||
newUsersLast7d,
|
||||
newServersLast7d,
|
||||
] = await Promise.all([
|
||||
db.select({ c: count() }).from(users).then((r) => Number(r[0]?.c ?? 0)),
|
||||
db.select({ c: count() }).from(organizations).then((r) => Number(r[0]?.c ?? 0)),
|
||||
db.select({ c: count() }).from(mcpServers).then((r) => Number(r[0]?.c ?? 0)),
|
||||
db.select({ c: count() }).from(builds).then((r) => Number(r[0]?.c ?? 0)),
|
||||
db.select({ c: count() }).from(toolCallMetrics).then((r) => Number(r[0]?.c ?? 0)),
|
||||
db
|
||||
.select({ c: count() })
|
||||
.from(mcpServers)
|
||||
.where(eq(mcpServers.status, 'live'))
|
||||
.then((r) => Number(r[0]?.c ?? 0)),
|
||||
db
|
||||
.select({ c: count() })
|
||||
.from(builds)
|
||||
.where(eq(builds.status, 'failed'))
|
||||
.then((r) => Number(r[0]?.c ?? 0)),
|
||||
db
|
||||
.select({ c: count() })
|
||||
.from(users)
|
||||
.where(gte(users.createdAt, sevenDaysAgo))
|
||||
.then((r) => Number(r[0]?.c ?? 0)),
|
||||
db
|
||||
.select({ c: count() })
|
||||
.from(mcpServers)
|
||||
.where(gte(mcpServers.createdAt, sevenDaysAgo))
|
||||
.then((r) => Number(r[0]?.c ?? 0)),
|
||||
]);
|
||||
|
||||
// Server status breakdown
|
||||
const statusBreakdown = await db
|
||||
.select({ status: mcpServers.status, c: count() })
|
||||
.from(mcpServers)
|
||||
.groupBy(mcpServers.status);
|
||||
|
||||
// Recent activity from audit log
|
||||
const recent = await db
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.orderBy(desc(auditLog.createdAt))
|
||||
.limit(15);
|
||||
|
||||
// Builds in last 24h with status
|
||||
const recentBuilds = await db
|
||||
.select({ status: builds.status, c: count() })
|
||||
.from(builds)
|
||||
.where(gte(builds.createdAt, dayAgo))
|
||||
.groupBy(builds.status);
|
||||
|
||||
return reply.send({
|
||||
totals: {
|
||||
users: userCount,
|
||||
orgs: orgCount,
|
||||
servers: serverCount,
|
||||
liveServers: liveCount,
|
||||
builds: buildCount,
|
||||
failedBuilds: failedBuildCount,
|
||||
toolCalls: callCount,
|
||||
},
|
||||
trends: {
|
||||
newUsersLast7d,
|
||||
newServersLast7d,
|
||||
},
|
||||
statusBreakdown,
|
||||
recentBuilds24h: recentBuilds,
|
||||
recentActivity: recent,
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Users ----
|
||||
app.get('/v1/admin/users', { preHandler: requireAdmin }, async (req, reply) => {
|
||||
const Query = z.object({
|
||||
search: z.string().optional(),
|
||||
limit: z.coerce.number().min(1).max(500).default(100),
|
||||
});
|
||||
const parsed = Query.safeParse(req.query);
|
||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_query' });
|
||||
|
||||
const rows = await db.select().from(users).orderBy(desc(users.createdAt)).limit(parsed.data.limit);
|
||||
const filtered = parsed.data.search
|
||||
? rows.filter((u) =>
|
||||
u.email.toLowerCase().includes(parsed.data.search!.toLowerCase()) ||
|
||||
(u.name?.toLowerCase().includes(parsed.data.search!.toLowerCase()) ?? false),
|
||||
)
|
||||
: rows;
|
||||
|
||||
// attach org + server count
|
||||
const enriched = await Promise.all(
|
||||
filtered.map(async (u) => {
|
||||
const [m] = await db
|
||||
.select({
|
||||
orgId: memberships.orgId,
|
||||
orgName: organizations.name,
|
||||
orgSlug: organizations.slug,
|
||||
plan: organizations.plan,
|
||||
role: memberships.role,
|
||||
})
|
||||
.from(memberships)
|
||||
.innerJoin(organizations, eq(organizations.id, memberships.orgId))
|
||||
.where(eq(memberships.userId, u.id))
|
||||
.limit(1);
|
||||
|
||||
const [sc] = await db
|
||||
.select({ c: count() })
|
||||
.from(mcpServers)
|
||||
.where(m ? eq(mcpServers.orgId, m.orgId) : sql`false`);
|
||||
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
isAdmin: u.isAdmin,
|
||||
emailVerified: u.emailVerified,
|
||||
lastLoginAt: u.lastLoginAt,
|
||||
createdAt: u.createdAt,
|
||||
org: m ?? null,
|
||||
serverCount: Number(sc?.c ?? 0),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return reply.send({ users: enriched });
|
||||
});
|
||||
|
||||
app.patch('/v1/admin/users/:id', { preHandler: requireAdmin }, async (req, reply) => {
|
||||
const Params = z.object({ id: z.string().uuid() });
|
||||
const Body = z.object({
|
||||
isAdmin: z.boolean().optional(),
|
||||
name: z.string().optional(),
|
||||
});
|
||||
const p = Params.safeParse(req.params);
|
||||
const b = Body.safeParse(req.body);
|
||||
if (!p.success || !b.success) return reply.code(400).send({ error: 'invalid_input' });
|
||||
|
||||
await db.update(users).set(b.data).where(eq(users.id, p.data.id));
|
||||
await audit({
|
||||
orgId: req.user!.orgId,
|
||||
userId: req.user!.userId,
|
||||
action: 'admin.user.update',
|
||||
resourceType: 'user',
|
||||
resourceId: p.data.id,
|
||||
metadata: b.data,
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/v1/admin/users/:id', { preHandler: requireAdmin }, async (req, reply) => {
|
||||
const Params = z.object({ id: z.string().uuid() });
|
||||
const p = Params.safeParse(req.params);
|
||||
if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
|
||||
if (p.data.id === req.user!.userId) {
|
||||
return reply.code(400).send({ error: 'cannot_delete_self' });
|
||||
}
|
||||
await db.delete(users).where(eq(users.id, p.data.id));
|
||||
await audit({
|
||||
orgId: req.user!.orgId,
|
||||
userId: req.user!.userId,
|
||||
action: 'admin.user.delete',
|
||||
resourceType: 'user',
|
||||
resourceId: p.data.id,
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
// ---- Organizations ----
|
||||
app.get('/v1/admin/orgs', { preHandler: requireAdmin }, async (_req, reply) => {
|
||||
const rows = await db.select().from(organizations).orderBy(desc(organizations.createdAt));
|
||||
|
||||
const enriched = await Promise.all(
|
||||
rows.map(async (o) => {
|
||||
const [mc] = await db
|
||||
.select({ c: count() })
|
||||
.from(memberships)
|
||||
.where(eq(memberships.orgId, o.id));
|
||||
const [sc] = await db
|
||||
.select({ c: count() })
|
||||
.from(mcpServers)
|
||||
.where(eq(mcpServers.orgId, o.id));
|
||||
return {
|
||||
...o,
|
||||
memberCount: Number(mc?.c ?? 0),
|
||||
serverCount: Number(sc?.c ?? 0),
|
||||
};
|
||||
}),
|
||||
);
|
||||
return reply.send({ orgs: enriched });
|
||||
});
|
||||
|
||||
app.patch('/v1/admin/orgs/:id', { preHandler: requireAdmin }, async (req, reply) => {
|
||||
const Params = z.object({ id: z.string().uuid() });
|
||||
const Body = z.object({
|
||||
plan: z.enum(['hobby', 'pro', 'team', 'enterprise']).optional(),
|
||||
monthlyCallQuota: z.coerce.number().int().min(0).optional(),
|
||||
suspended: z.boolean().optional(),
|
||||
suspendedReason: z.string().max(500).nullable().optional(),
|
||||
});
|
||||
const p = Params.safeParse(req.params);
|
||||
const b = Body.safeParse(req.body);
|
||||
if (!p.success || !b.success) return reply.code(400).send({ error: 'invalid_input' });
|
||||
|
||||
await db.update(organizations).set(b.data).where(eq(organizations.id, p.data.id));
|
||||
|
||||
// If suspended, pause all the org's servers
|
||||
if (b.data.suspended === true) {
|
||||
await db
|
||||
.update(mcpServers)
|
||||
.set({ status: 'paused', updatedAt: new Date() })
|
||||
.where(eq(mcpServers.orgId, p.data.id));
|
||||
}
|
||||
await audit({
|
||||
orgId: req.user!.orgId,
|
||||
userId: req.user!.userId,
|
||||
action: 'admin.org.update',
|
||||
resourceType: 'org',
|
||||
resourceId: p.data.id,
|
||||
metadata: b.data,
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
// ---- Servers ----
|
||||
app.get('/v1/admin/servers', { preHandler: requireAdmin }, async (req, reply) => {
|
||||
const Query = z.object({
|
||||
status: z.string().optional(),
|
||||
limit: z.coerce.number().min(1).max(500).default(200),
|
||||
});
|
||||
const parsed = Query.safeParse(req.query);
|
||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_query' });
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
server: mcpServers,
|
||||
org: { id: organizations.id, name: organizations.name, slug: organizations.slug, plan: organizations.plan },
|
||||
})
|
||||
.from(mcpServers)
|
||||
.innerJoin(organizations, eq(organizations.id, mcpServers.orgId))
|
||||
.orderBy(desc(mcpServers.updatedAt))
|
||||
.limit(parsed.data.limit);
|
||||
|
||||
const filtered = parsed.data.status
|
||||
? rows.filter((r) => r.server.status === parsed.data.status)
|
||||
: rows;
|
||||
|
||||
return reply.send({ servers: filtered });
|
||||
});
|
||||
|
||||
app.post('/v1/admin/servers/:id/rebuild', { preHandler: requireAdmin }, async (req, reply) => {
|
||||
const Params = z.object({ id: z.string().uuid() });
|
||||
const p = Params.safeParse(req.params);
|
||||
if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
|
||||
|
||||
const [server] = await db.select().from(mcpServers).where(eq(mcpServers.id, p.data.id)).limit(1);
|
||||
if (!server) return reply.code(404).send({ error: 'not_found' });
|
||||
|
||||
// Get last build's prompt
|
||||
const [lastBuild] = await db
|
||||
.select()
|
||||
.from(builds)
|
||||
.where(eq(builds.serverId, server.id))
|
||||
.orderBy(desc(builds.version))
|
||||
.limit(1);
|
||||
if (!lastBuild) return reply.code(400).send({ error: 'no_previous_build' });
|
||||
|
||||
const nextVersion = (server.currentVersion ?? 0) + 1;
|
||||
const [build] = await db
|
||||
.insert(builds)
|
||||
.values({
|
||||
serverId: server.id,
|
||||
version: nextVersion,
|
||||
prompt: lastBuild.prompt,
|
||||
status: 'queued',
|
||||
})
|
||||
.returning();
|
||||
if (!build) return reply.code(500).send({ error: 'build_create_failed' });
|
||||
|
||||
await db
|
||||
.update(mcpServers)
|
||||
.set({ status: 'queued', updatedAt: new Date() })
|
||||
.where(eq(mcpServers.id, server.id));
|
||||
|
||||
await getBuildQueue().add('generate', {
|
||||
buildId: build.id,
|
||||
serverId: server.id,
|
||||
orgId: server.orgId,
|
||||
prompt: lastBuild.prompt,
|
||||
version: nextVersion,
|
||||
slug: server.slug,
|
||||
serverName: server.name,
|
||||
secrets: {},
|
||||
});
|
||||
|
||||
await audit({
|
||||
orgId: req.user!.orgId,
|
||||
userId: req.user!.userId,
|
||||
action: 'admin.server.rebuild',
|
||||
resourceType: 'server',
|
||||
resourceId: server.id,
|
||||
metadata: { newVersion: nextVersion },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
return reply.send({ build });
|
||||
});
|
||||
|
||||
app.delete('/v1/admin/servers/:id', { preHandler: requireAdmin }, async (req, reply) => {
|
||||
const Params = z.object({ id: z.string().uuid() });
|
||||
const p = Params.safeParse(req.params);
|
||||
if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
|
||||
|
||||
const [server] = await db.select().from(mcpServers).where(eq(mcpServers.id, p.data.id)).limit(1);
|
||||
if (!server) return reply.code(404).send({ error: 'not_found' });
|
||||
|
||||
await db.delete(mcpServers).where(eq(mcpServers.id, server.id));
|
||||
await audit({
|
||||
orgId: req.user!.orgId,
|
||||
userId: req.user!.userId,
|
||||
action: 'admin.server.delete',
|
||||
resourceType: 'server',
|
||||
resourceId: server.id,
|
||||
metadata: { slug: server.slug, name: server.name },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
// ---- Builds ----
|
||||
app.get('/v1/admin/builds', { preHandler: requireAdmin }, async (req, reply) => {
|
||||
const Query = z.object({
|
||||
status: z.string().optional(),
|
||||
limit: z.coerce.number().min(1).max(500).default(100),
|
||||
});
|
||||
const parsed = Query.safeParse(req.query);
|
||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_query' });
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
build: builds,
|
||||
server: { id: mcpServers.id, name: mcpServers.name, slug: mcpServers.slug },
|
||||
org: { id: organizations.id, name: organizations.name },
|
||||
})
|
||||
.from(builds)
|
||||
.innerJoin(mcpServers, eq(mcpServers.id, builds.serverId))
|
||||
.innerJoin(organizations, eq(organizations.id, mcpServers.orgId))
|
||||
.orderBy(desc(builds.createdAt))
|
||||
.limit(parsed.data.limit);
|
||||
|
||||
const filtered = parsed.data.status
|
||||
? rows.filter((r) => r.build.status === parsed.data.status)
|
||||
: rows;
|
||||
|
||||
return reply.send({ builds: filtered });
|
||||
});
|
||||
|
||||
app.get('/v1/admin/builds/:id/logs', { preHandler: requireAdmin }, async (req, reply) => {
|
||||
const Params = z.object({ id: z.string().uuid() });
|
||||
const p = Params.safeParse(req.params);
|
||||
if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
|
||||
const logs = await db
|
||||
.select()
|
||||
.from(buildLogs)
|
||||
.where(eq(buildLogs.buildId, p.data.id))
|
||||
.orderBy(buildLogs.timestamp);
|
||||
return reply.send({ logs });
|
||||
});
|
||||
|
||||
// ---- Audit (system-wide) ----
|
||||
app.get('/v1/admin/audit', { preHandler: requireAdmin }, async (req, reply) => {
|
||||
const Query = z.object({
|
||||
action: z.string().optional(),
|
||||
resourceType: z.string().optional(),
|
||||
limit: z.coerce.number().min(1).max(1000).default(200),
|
||||
});
|
||||
const parsed = Query.safeParse(req.query);
|
||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_query' });
|
||||
|
||||
let rows = await db
|
||||
.select({
|
||||
entry: auditLog,
|
||||
userEmail: users.email,
|
||||
})
|
||||
.from(auditLog)
|
||||
.leftJoin(users, eq(users.id, auditLog.userId))
|
||||
.orderBy(desc(auditLog.createdAt))
|
||||
.limit(parsed.data.limit);
|
||||
|
||||
if (parsed.data.action) {
|
||||
rows = rows.filter((r) => r.entry.action === parsed.data.action);
|
||||
}
|
||||
if (parsed.data.resourceType) {
|
||||
rows = rows.filter((r) => r.entry.resourceType === parsed.data.resourceType);
|
||||
}
|
||||
return reply.send({ entries: rows });
|
||||
});
|
||||
|
||||
// ---- System health ----
|
||||
app.get('/v1/admin/system', { preHandler: requireAdmin }, async (_req, reply) => {
|
||||
const t0 = Date.now();
|
||||
// DB probe
|
||||
let dbOk = false;
|
||||
let dbLatency: number | null = null;
|
||||
try {
|
||||
const start = Date.now();
|
||||
await db.execute(sql`SELECT 1`);
|
||||
dbLatency = Date.now() - start;
|
||||
dbOk = true;
|
||||
} catch {
|
||||
// remains false
|
||||
}
|
||||
|
||||
// Redis probe
|
||||
let redisOk = false;
|
||||
let redisLatency: number | null = null;
|
||||
let queueDepth: number | null = null;
|
||||
try {
|
||||
const start = Date.now();
|
||||
const pong = await getRedis().ping();
|
||||
redisLatency = Date.now() - start;
|
||||
redisOk = pong === 'PONG';
|
||||
const q = getBuildQueue();
|
||||
const counts = await q.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed');
|
||||
queueDepth =
|
||||
(counts.waiting ?? 0) +
|
||||
(counts.active ?? 0) +
|
||||
(counts.delayed ?? 0);
|
||||
} catch {
|
||||
// remains false
|
||||
}
|
||||
|
||||
// Docker container count
|
||||
const containerCount = await new Promise<number | null>((resolve) => {
|
||||
const child = spawn('docker', ['ps', '--filter', 'name=bmm-mcp-', '-q'], {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
let out = '';
|
||||
child.stdout?.on('data', (d: Buffer) => {
|
||||
out += d.toString();
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const lines = out
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
resolve(lines.length);
|
||||
});
|
||||
child.on('error', () => resolve(null));
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
probedAtMs: Date.now() - t0,
|
||||
db: { ok: dbOk, latencyMs: dbLatency },
|
||||
redis: { ok: redisOk, latencyMs: redisLatency, queueDepth },
|
||||
docker: { containerCount },
|
||||
});
|
||||
});
|
||||
|
||||
// ---- System prompt (read + override) ----
|
||||
app.get('/v1/admin/prompt', { preHandler: requireAdmin }, async (_req, reply) => {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(adminSettings)
|
||||
.where(eq(adminSettings.key, PROMPT_KEY))
|
||||
.limit(1);
|
||||
return reply.send({
|
||||
builtin: SYSTEM_PROMPT,
|
||||
override: row?.value ?? null,
|
||||
updatedAt: row?.updatedAt ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
app.patch('/v1/admin/prompt', { preHandler: requireAdmin }, async (req, reply) => {
|
||||
const Body = z.object({
|
||||
value: z.string().min(1).max(20_000).nullable(),
|
||||
});
|
||||
const parsed = Body.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
|
||||
|
||||
if (parsed.data.value === null) {
|
||||
await db.delete(adminSettings).where(eq(adminSettings.key, PROMPT_KEY));
|
||||
} else {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(adminSettings)
|
||||
.where(eq(adminSettings.key, PROMPT_KEY))
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
await db
|
||||
.update(adminSettings)
|
||||
.set({
|
||||
value: parsed.data.value,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: req.user!.userId,
|
||||
})
|
||||
.where(eq(adminSettings.id, existing.id));
|
||||
} else {
|
||||
await db.insert(adminSettings).values({
|
||||
key: PROMPT_KEY,
|
||||
value: parsed.data.value,
|
||||
updatedBy: req.user!.userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
await audit({
|
||||
orgId: req.user!.orgId,
|
||||
userId: req.user!.userId,
|
||||
action: 'admin.prompt.update',
|
||||
resourceType: 'admin_settings',
|
||||
resourceId: PROMPT_KEY,
|
||||
metadata: { hasOverride: parsed.data.value !== null },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
void inArray; // referenced for future bulk operations
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { consumeMagicLink, destroySession, getSession, issueMagicLink } from '@bmm/auth';
|
||||
import {
|
||||
consumeMagicLink,
|
||||
destroySession,
|
||||
getSession,
|
||||
issueMagicLink,
|
||||
loginWithPassword,
|
||||
} from '@bmm/auth';
|
||||
import { audit } from '../lib/audit.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
@@ -65,6 +71,51 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.send({ user: session });
|
||||
});
|
||||
|
||||
app.post('/v1/auth/admin/login', async (req, reply) => {
|
||||
const Body = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
const parsed = Body.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'invalid_input' });
|
||||
}
|
||||
try {
|
||||
const session = await loginWithPassword(parsed.data.email, parsed.data.password, {
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
});
|
||||
if (!session.isAdmin) {
|
||||
await destroySession(session.sessionToken);
|
||||
return reply.code(403).send({ error: 'not_admin' });
|
||||
}
|
||||
reply.setCookie(SESSION_COOKIE, session.sessionToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
secure: config.NODE_ENV === 'production',
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
});
|
||||
await audit({
|
||||
orgId: session.orgId,
|
||||
userId: session.userId,
|
||||
action: 'admin.login',
|
||||
resourceType: 'session',
|
||||
metadata: { email: session.email },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return reply.send({
|
||||
ok: true,
|
||||
user: { id: session.userId, email: session.email, orgId: session.orgId, isAdmin: true },
|
||||
});
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'admin login failed');
|
||||
// Constant-time-ish to discourage username enumeration
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
return reply.code(401).send({ error: 'invalid_credentials' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/v1/auth/logout', async (req, reply) => {
|
||||
const token = req.cookies[SESSION_COOKIE];
|
||||
const session = token ? await getSession(token) : null;
|
||||
|
||||
Reference in New Issue
Block a user