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:
Marco Sadjadi
2026-05-19 23:01:26 +02:00
parent 9acc2adb0d
commit c62fcd07ef
18 changed files with 2319 additions and 2 deletions

View File

@@ -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<typeof Env>;

View File

@@ -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);

View File

@@ -21,6 +21,21 @@ export async function requireAuth(
req.user = session;
}
export async function requireAdmin(
req: FastifyRequest,
reply: FastifyReply,
): Promise<FastifyReply | void> {
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
}

View 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
}

View File

@@ -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;