diff --git a/.env.example b/.env.example index cbfe0d1..88900b6 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,12 @@ ANTHROPIC_API_KEY= # 32-byte hex for AES-256-GCM; generate with: openssl rand -hex 32 SECRETS_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 +# ---- Admin bootstrap ---- +# On API boot, an admin user is upserted with these credentials (idempotent). +ADMIN_EMAIL= +ADMIN_PASSWORD= +ADMIN_NAME=Admin + # ---- OAuth signing (RS256 JWKS) ---- # Path to PEM keypair; auto-generated on api boot if missing OAUTH_KEY_DIR=./keys diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 9ab15c5..ccddb5e 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -13,6 +13,9 @@ const Env = z.object({ .min(64, '32 bytes hex required') .default('0000000000000000000000000000000000000000000000000000000000000000'), CONTROL_PLANE_PUBLIC_URL: z.string().default('http://localhost:4000'), + ADMIN_EMAIL: z.string().email().optional(), + ADMIN_PASSWORD: z.string().min(8).optional(), + ADMIN_NAME: z.string().optional(), }); export const config = Env.parse({ @@ -24,6 +27,10 @@ export const config = Env.parse({ OAUTH_KEY_DIR: process.env.OAUTH_KEY_DIR, ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, SECRETS_ENCRYPTION_KEY: process.env.SECRETS_ENCRYPTION_KEY, + CONTROL_PLANE_PUBLIC_URL: process.env.CONTROL_PLANE_PUBLIC_URL, + ADMIN_EMAIL: process.env.ADMIN_EMAIL, + ADMIN_PASSWORD: process.env.ADMIN_PASSWORD, + ADMIN_NAME: process.env.ADMIN_NAME, }); export type Config = z.infer; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a12cf3f..2a0bba6 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -2,11 +2,13 @@ import Fastify from 'fastify'; import cors from '@fastify/cors'; import cookie from '@fastify/cookie'; import websocket from '@fastify/websocket'; +import { seedAdmin } from '@bmm/auth'; import { config } from './config.js'; import { authRoutes } from './routes/auth.js'; import { serverRoutes } from './routes/servers.js'; import { oauthRoutes } from './routes/oauth.js'; import { settingsRoutes } from './routes/settings.js'; +import { adminRoutes } from './routes/admin.js'; const app = Fastify({ logger: { @@ -27,6 +29,24 @@ await app.register(authRoutes); await app.register(serverRoutes); await app.register(oauthRoutes); await app.register(settingsRoutes); +await app.register(adminRoutes); + +// Bootstrap admin user from env (idempotent) +if (config.ADMIN_EMAIL && config.ADMIN_PASSWORD) { + try { + const result = await seedAdmin({ + email: config.ADMIN_EMAIL, + password: config.ADMIN_PASSWORD, + name: config.ADMIN_NAME, + }); + app.log.info( + { email: config.ADMIN_EMAIL, created: result.created }, + `[admin-seed] ${result.created ? 'created' : 'updated'} admin user`, + ); + } catch (err) { + app.log.error({ err }, '[admin-seed] failed to seed admin user'); + } +} app.setErrorHandler((err, _req, reply) => { app.log.error(err); diff --git a/apps/api/src/plugins/session.ts b/apps/api/src/plugins/session.ts index 6efd184..52655d5 100644 --- a/apps/api/src/plugins/session.ts +++ b/apps/api/src/plugins/session.ts @@ -21,6 +21,21 @@ export async function requireAuth( req.user = session; } +export async function requireAdmin( + req: FastifyRequest, + reply: FastifyReply, +): Promise { + const token = req.cookies[SESSION_COOKIE]; + const session = await getSession(token); + if (!session) { + return reply.code(401).send({ error: 'unauthorized' }); + } + if (!session.isAdmin) { + return reply.code(403).send({ error: 'forbidden' }); + } + req.user = session; +} + export function registerSession(_app: FastifyInstance): void { // no-op; preHandler `requireAuth` does the work per-route } diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts new file mode 100644 index 0000000..b266dc7 --- /dev/null +++ b/apps/api/src/routes/admin.ts @@ -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 { + // ---- 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((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 +} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 26ae617..6ac8fba 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -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 { 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; diff --git a/apps/web/app/admin/audit/page.tsx b/apps/web/app/admin/audit/page.tsx new file mode 100644 index 0000000..efd1f69 --- /dev/null +++ b/apps/web/app/admin/audit/page.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { apiFetch } from '@/lib/api'; +import { Input } from '@/components/input'; +import { Button } from '@/components/ui/button'; + +interface Row { + entry: { + id: string; + orgId: string | null; + userId: string | null; + action: string; + resourceType: string | null; + resourceId: string | null; + metadata: Record | null; + ipAddress: string | null; + createdAt: string; + }; + userEmail: string | null; +} + +const ACTION_FILTERS = [ + '', + 'auth.login', + 'auth.logout', + 'admin.login', + 'server.create', + 'server.iterate', + 'server.delete', + 'admin.user.update', + 'admin.user.delete', + 'admin.org.update', + 'admin.server.rebuild', + 'admin.server.delete', + 'admin.prompt.update', +]; + +export default function AdminAuditPage() { + const [rows, setRows] = useState(null); + const [action, setAction] = useState(''); + const [search, setSearch] = useState(''); + + useEffect(() => { + apiFetch<{ entries: Row[] }>(`/v1/admin/audit${action ? `?action=${action}` : ''}`) + .then((r) => setRows(r.entries)) + .catch(() => setRows([])); + }, [action]); + + function exportCsv() { + if (!rows) return; + const header = ['when', 'action', 'user', 'resource', 'ip', 'metadata']; + const lines = rows.map((r) => + [ + new Date(r.entry.createdAt).toISOString(), + r.entry.action, + r.userEmail ?? '', + `${r.entry.resourceType ?? ''}/${r.entry.resourceId ?? ''}`, + r.entry.ipAddress ?? '', + r.entry.metadata ? JSON.stringify(r.entry.metadata).replace(/"/g, '""') : '', + ] + .map((v) => `"${String(v).replace(/"/g, '""')}"`) + .join(','), + ); + const csv = [header.join(','), ...lines].join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bmm-audit-${Date.now()}.csv`; + a.click(); + URL.revokeObjectURL(url); + } + + const visible = rows?.filter((r) => + search + ? r.entry.action.includes(search) || + r.userEmail?.includes(search) || + r.entry.resourceId?.includes(search) || + r.entry.ipAddress?.includes(search) + : true, + ); + + return ( +
+
+
+

Audit log

+

+ System-wide. {visible?.length ?? 0} visible. +

+
+ +
+ +
+ + setSearch(e.target.value)} + placeholder="Filter by user email, resource id, or ip…" + className="w-96" + /> +
+ +
+ {!visible && ( +

Loading…

+ )} + {visible && visible.length === 0 && ( +

No matches.

+ )} + {visible && visible.length > 0 && ( + + + + + + + + + + + + + {visible.map((r) => ( + + + + + + + + + ))} + +
WhenActionUserResourceIPMetadata
+ {new Date(r.entry.createdAt).toLocaleString()} + + + {r.entry.action} + + {r.userEmail ?? '—'} + {r.entry.resourceType + ? `${r.entry.resourceType}/${r.entry.resourceId?.slice(0, 8) ?? '—'}` + : '—'} + {r.entry.ipAddress ?? '—'} + {r.entry.metadata ? JSON.stringify(r.entry.metadata) : '—'} +
+ )} +
+
+ ); +} diff --git a/apps/web/app/admin/builds/page.tsx b/apps/web/app/admin/builds/page.tsx new file mode 100644 index 0000000..806ce02 --- /dev/null +++ b/apps/web/app/admin/builds/page.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { apiFetch } from '@/lib/api'; +import { StatusPill } from '@/components/status-pill'; + +interface Row { + build: { + id: string; + version: number; + prompt: string; + status: string; + errorMessage: string | null; + startedAt: string | null; + finishedAt: string | null; + createdAt: string; + }; + server: { id: string; name: string; slug: string }; + org: { id: string; name: string }; +} + +const STATUS_FILTERS = ['', 'success', 'failed', 'queued', 'generating', 'building', 'deploying', 'cancelled']; + +export default function AdminBuildsPage() { + const [rows, setRows] = useState(null); + const [status, setStatus] = useState(''); + + useEffect(() => { + apiFetch<{ builds: Row[] }>(`/v1/admin/builds${status ? `?status=${status}` : ''}`) + .then((r) => setRows(r.builds)) + .catch(() => setRows([])); + }, [status]); + + return ( +
+
+

Builds

+

+ Every spec → image → deploy run across the fleet. +

+
+ +
+ +
+ +
+ {rows === null && ( +

Loading…

+ )} + {rows && rows.length === 0 && ( +

No builds.

+ )} + {rows && rows.length > 0 && ( + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + ))} + +
WhenServerOrgStatusPrompt / Error
+ {new Date(r.build.createdAt).toLocaleString()} +
v{r.build.version}
+
+
{r.server.name}
+
{r.server.slug}
+
{r.org.name} + + + {r.build.errorMessage ? ( +
+ {r.build.errorMessage} +
+ ) : ( +
{r.build.prompt}
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx new file mode 100644 index 0000000..9dd38ee --- /dev/null +++ b/apps/web/app/admin/layout.tsx @@ -0,0 +1,159 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname, useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { + LayoutGrid, + Users, + Building2, + Server, + Hammer, + FileClock, + Activity, + Wand2, + LogOut, + ShieldAlert, +} from 'lucide-react'; +import { apiFetch } from '@/lib/api'; +import { cn } from '@/lib/cn'; +import { Logo } from '@/components/logo'; + +interface MeUser { + userId: string; + email: string; + isAdmin: boolean; +} + +const NAV: { href: string; label: string; icon: React.ComponentType<{ size?: number }> }[] = [ + { href: '/admin', label: 'Overview', icon: LayoutGrid }, + { href: '/admin/users', label: 'Users', icon: Users }, + { href: '/admin/orgs', label: 'Organizations', icon: Building2 }, + { href: '/admin/servers', label: 'MCP servers', icon: Server }, + { href: '/admin/builds', label: 'Builds', icon: Hammer }, + { href: '/admin/audit', label: 'Audit log', icon: FileClock }, + { href: '/admin/system', label: 'System health', icon: Activity }, + { href: '/admin/prompt', label: 'AI prompt', icon: Wand2 }, +]; + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const router = useRouter(); + const [user, setUser] = useState(null); + const [authState, setAuthState] = useState<'checking' | 'ok' | 'forbidden'>('checking'); + + useEffect(() => { + if (pathname === '/admin/login') { + setAuthState('ok'); + return; + } + apiFetch<{ user: MeUser }>('/v1/auth/me') + .then((r) => { + if (r.user.isAdmin) { + setUser(r.user); + setAuthState('ok'); + } else { + setAuthState('forbidden'); + } + }) + .catch(() => setAuthState('forbidden')); + }, [pathname]); + + useEffect(() => { + if (authState === 'forbidden' && pathname !== '/admin/login') { + router.replace('/admin/login'); + } + }, [authState, pathname, router]); + + async function logout() { + await apiFetch('/v1/auth/logout', { method: 'POST' }).catch(() => undefined); + router.replace('/admin/login'); + } + + if (pathname === '/admin/login') return <>{children}; + + if (authState === 'checking') { + return ( +
+

verifying admin…

+
+ ); + } + if (authState === 'forbidden') { + return ( +
+ +

Admin access required.

+ + /admin/login + +
+ ); + } + + return ( +
+ +
{children}
+
+ ); +} diff --git a/apps/web/app/admin/login/page.tsx b/apps/web/app/admin/login/page.tsx new file mode 100644 index 0000000..4538fc8 --- /dev/null +++ b/apps/web/app/admin/login/page.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Logo } from '@/components/logo'; +import { Button } from '@/components/ui/button'; +import { Input, Label } from '@/components/input'; +import { apiFetch } from '@/lib/api'; + +export default function AdminLogin() { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [state, setState] = useState<'idle' | 'submitting' | 'error'>('idle'); + const [error, setError] = useState(null); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setState('submitting'); + setError(null); + try { + await apiFetch('/v1/auth/admin/login', { + method: 'POST', + body: JSON.stringify({ email, password }), + }); + router.replace('/admin'); + } catch (err) { + const detail = (err as { detail?: { error?: string } }).detail; + const code = detail?.error; + setError( + code === 'not_admin' + ? 'That account exists but is not an admin.' + : code === 'invalid_credentials' + ? 'Wrong email or password.' + : (err as Error).message, + ); + setState('error'); + } + } + + return ( +
+
+ +
+

Admin sign in

+ + restricted + +
+

+ Email + password. Non-admin accounts use the magic link at{' '} + + /login + + . +

+ +
+
+ + setEmail(e.target.value)} + placeholder="admin@yourcompany.com" + /> +
+
+ + setPassword(e.target.value)} + placeholder="••••••••" + /> +
+ + {error &&

{error}

} +
+ +
+ + ← Back to home + +
+
+
+ ); +} diff --git a/apps/web/app/admin/orgs/page.tsx b/apps/web/app/admin/orgs/page.tsx new file mode 100644 index 0000000..121c9d1 --- /dev/null +++ b/apps/web/app/admin/orgs/page.tsx @@ -0,0 +1,182 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { apiFetch } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/input'; + +interface AdminOrg { + id: string; + slug: string; + name: string; + plan: string; + monthlyCallQuota: number; + callsThisPeriod: number; + periodStartsAt: string; + suspended: boolean; + suspendedReason: string | null; + createdAt: string; + memberCount: number; + serverCount: number; +} + +export default function AdminOrgsPage() { + const [orgs, setOrgs] = useState(null); + const [search, setSearch] = useState(''); + + async function reload() { + const r = await apiFetch<{ orgs: AdminOrg[] }>('/v1/admin/orgs'); + setOrgs(r.orgs); + } + + useEffect(() => { + reload(); + }, []); + + async function changePlan(o: AdminOrg) { + const plan = prompt( + `Change plan for "${o.name}". Current: ${o.plan}. Pick one of: hobby, pro, team, enterprise`, + o.plan, + ); + if (!plan || !['hobby', 'pro', 'team', 'enterprise'].includes(plan)) return; + await apiFetch(`/v1/admin/orgs/${o.id}`, { + method: 'PATCH', + body: JSON.stringify({ plan }), + }); + reload(); + } + + async function setQuota(o: AdminOrg) { + const next = prompt(`New monthly call quota for "${o.name}":`, String(o.monthlyCallQuota)); + if (!next) return; + const parsed = Number(next); + if (!Number.isFinite(parsed) || parsed < 0) { + alert('Invalid number'); + return; + } + await apiFetch(`/v1/admin/orgs/${o.id}`, { + method: 'PATCH', + body: JSON.stringify({ monthlyCallQuota: parsed }), + }); + reload(); + } + + async function toggleSuspend(o: AdminOrg) { + if (o.suspended) { + if (!confirm(`Lift suspension on "${o.name}"?`)) return; + await apiFetch(`/v1/admin/orgs/${o.id}`, { + method: 'PATCH', + body: JSON.stringify({ suspended: false, suspendedReason: null }), + }); + } else { + const reason = prompt( + `Suspend "${o.name}"? This pauses ALL their MCP servers. Reason (visible in audit log):`, + '', + ); + if (reason === null) return; + await apiFetch(`/v1/admin/orgs/${o.id}`, { + method: 'PATCH', + body: JSON.stringify({ suspended: true, suspendedReason: reason || 'Suspended by admin' }), + }); + } + reload(); + } + + const filtered = orgs?.filter((o) => + search + ? o.name.toLowerCase().includes(search.toLowerCase()) || + o.slug.toLowerCase().includes(search.toLowerCase()) + : true, + ); + + return ( +
+
+

Organizations

+

+ Plan management, quota overrides, suspension. +

+
+ +
+ setSearch(e.target.value)} + placeholder="Search by name or slug…" + className="w-80" + /> +
+ +
+ {!filtered && ( +

Loading…

+ )} + {filtered && filtered.length === 0 && ( +

No matches.

+ )} + {filtered && filtered.length > 0 && ( + + + + + + + + + + + + + + {filtered.map((o) => ( + + + + + + + + + + ))} + +
NamePlanMembersServersCalls / quotaStatusActions
+
{o.name}
+
{o.slug}
+
+ + {o.plan} + + {o.memberCount}{o.serverCount} + {o.callsThisPeriod.toLocaleString()} / {o.monthlyCallQuota.toLocaleString()} + + {o.suspended ? ( + + suspended + + ) : ( + + active + + )} + +
+ + + +
+
+ )} +
+
+ ); +} diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx new file mode 100644 index 0000000..f3c1d9a --- /dev/null +++ b/apps/web/app/admin/page.tsx @@ -0,0 +1,198 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { apiFetch } from '@/lib/api'; +import { StatusPill } from '@/components/status-pill'; + +interface Overview { + totals: { + users: number; + orgs: number; + servers: number; + liveServers: number; + builds: number; + failedBuilds: number; + toolCalls: number; + }; + trends: { newUsersLast7d: number; newServersLast7d: number }; + statusBreakdown: { status: string; c: number }[]; + recentBuilds24h: { status: string; c: number }[]; + recentActivity: { + id: string; + action: string; + resourceType: string | null; + resourceId: string | null; + metadata: Record | null; + ipAddress: string | null; + createdAt: string; + }[]; +} + +export default function AdminOverview() { + const [data, setData] = useState(null); + + useEffect(() => { + apiFetch('/v1/admin/overview').then(setData); + const t = setInterval(() => apiFetch('/v1/admin/overview').then(setData), 8000); + return () => clearInterval(t); + }, []); + + if (!data) { + return
Loading…
; + } + + return ( +
+
+

Admin overview

+

+ Live system metrics — refreshes every 8 seconds. +

+
+ +
+ + + + +
+ +
+ + {data.statusBreakdown.length === 0 ? ( + + ) : ( +
    + {data.statusBreakdown.map((row) => ( +
  • + + {row.c} +
  • + ))} +
+ )} +
+ + {data.recentBuilds24h.length === 0 ? ( + + ) : ( +
    + {data.recentBuilds24h.map((row) => ( +
  • + + {row.c} +
  • + ))} +
+ )} +
+
+ +
+
+

Recent activity

+ + Full audit log → + +
+
+ {data.recentActivity.length === 0 ? ( + + ) : ( + + + + + + + + + + + {data.recentActivity.map((e) => ( + + + + + + + ))} + +
WhenActionResourceIP
+ {new Date(e.createdAt).toLocaleString()} + + + {e.action} + + + {e.resourceType + ? `${e.resourceType}/${e.resourceId?.slice(0, 8) ?? '—'}` + : '—'} + {e.ipAddress ?? '—'}
+ )} +
+
+
+ ); +} + +function Card({ + label, + value, + sub, + subRender, +}: { + label: string; + value: number; + sub?: string; + subRender?: string; +}) { + return ( +
+
{label}
+
+ {value.toLocaleString()} +
+ {sub && ( +
{subRender ?? sub}
+ )} +
+ ); +} + +function Panel({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ); +} + +function Empty({ text }: { text: string }) { + return

{text}

; +} diff --git a/apps/web/app/admin/prompt/page.tsx b/apps/web/app/admin/prompt/page.tsx new file mode 100644 index 0000000..e5a8e9f --- /dev/null +++ b/apps/web/app/admin/prompt/page.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { apiFetch } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/input'; +import { CodeBlock } from '@/components/code-block'; + +interface PromptData { + builtin: string; + override: string | null; + updatedAt: string | null; +} + +export default function AdminPromptPage() { + const [data, setData] = useState(null); + const [draft, setDraft] = useState(''); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(null); + + async function reload() { + const r = await apiFetch('/v1/admin/prompt'); + setData(r); + setDraft(r.override ?? r.builtin); + } + + useEffect(() => { + reload(); + }, []); + + async function save() { + if (!data) return; + setSaving(true); + setMessage(null); + try { + // Only persist as override if it differs from built-in + const value = draft === data.builtin ? null : draft; + await apiFetch('/v1/admin/prompt', { + method: 'PATCH', + body: JSON.stringify({ value }), + }); + setMessage(value === null ? 'Reverted to built-in prompt.' : 'Override saved.'); + await reload(); + } catch (e) { + setMessage(`Failed: ${(e as Error).message}`); + } finally { + setSaving(false); + } + } + + async function revert() { + if (!data) return; + if (!confirm('Drop the override and use the built-in prompt?')) return; + await apiFetch('/v1/admin/prompt', { method: 'PATCH', body: JSON.stringify({ value: null }) }); + await reload(); + setMessage('Reverted to built-in.'); + } + + if (!data) return
Loading…
; + + const dirty = draft !== (data.override ?? data.builtin); + const isOverridden = data.override !== null; + + return ( +
+
+

AI prompt

+

+ The system prompt the generator hands to Claude when parsing user requests. Override at + your own risk — bad prompts cause low-quality builds. +

+
+ +
+
+
+

Live prompt

+ + {draft.length} chars + +
+