buildmymcpserver/apps/web/app/admin/page.tsx
Marco Sadjadi c62fcd07ef 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.
2026-05-19 23:01:26 +02:00

199 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<string, unknown> | null;
ipAddress: string | null;
createdAt: string;
}[];
}
export default function AdminOverview() {
const [data, setData] = useState<Overview | null>(null);
useEffect(() => {
apiFetch<Overview>('/v1/admin/overview').then(setData);
const t = setInterval(() => apiFetch<Overview>('/v1/admin/overview').then(setData), 8000);
return () => clearInterval(t);
}, []);
if (!data) {
return <div className="px-8 py-8 mono text-[12px] text-[--color-fg-muted]">Loading</div>;
}
return (
<div className="px-8 py-8">
<header className="mb-6">
<h1 className="text-[22px] font-semibold tracking-tight">Admin overview</h1>
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
Live system metrics refreshes every 8 seconds.
</p>
</header>
<div className="grid gap-3 md:grid-cols-4">
<Card label="Users" value={data.totals.users} sub={`+${data.trends.newUsersLast7d} last 7d`} />
<Card
label="Organizations"
value={data.totals.orgs}
sub={`${data.totals.users / Math.max(1, data.totals.orgs)}× users/org avg`}
subRender={`${(data.totals.users / Math.max(1, data.totals.orgs)).toFixed(1)}× users/org avg`}
/>
<Card
label="MCP servers"
value={data.totals.servers}
sub={`${data.totals.liveServers} live · +${data.trends.newServersLast7d} last 7d`}
/>
<Card
label="Tool calls"
value={data.totals.toolCalls}
sub={
data.totals.builds === 0
? '0 builds'
: `${(((data.totals.builds - data.totals.failedBuilds) / data.totals.builds) * 100).toFixed(0)}% build success`
}
/>
</div>
<div className="mt-8 grid gap-6 md:grid-cols-2">
<Panel title="Server status">
{data.statusBreakdown.length === 0 ? (
<Empty text="No servers." />
) : (
<ul className="space-y-2">
{data.statusBreakdown.map((row) => (
<li
key={row.status}
className="flex items-center justify-between text-[12.5px]"
>
<StatusPill status={row.status as never} />
<span className="mono text-[--color-fg]">{row.c}</span>
</li>
))}
</ul>
)}
</Panel>
<Panel title="Builds (last 24h)">
{data.recentBuilds24h.length === 0 ? (
<Empty text="No builds in the last 24h." />
) : (
<ul className="space-y-2">
{data.recentBuilds24h.map((row) => (
<li
key={row.status}
className="flex items-center justify-between text-[12.5px]"
>
<StatusPill status={row.status as never} />
<span className="mono text-[--color-fg]">{row.c}</span>
</li>
))}
</ul>
)}
</Panel>
</div>
<div className="mt-8">
<div className="flex items-baseline justify-between">
<h2 className="text-[14px] font-semibold tracking-tight">Recent activity</h2>
<Link href="/admin/audit" className="text-[12px] text-[--color-fg-muted] hover:text-[--color-fg]">
Full audit log
</Link>
</div>
<div className="panel mt-3">
{data.recentActivity.length === 0 ? (
<Empty text="No activity yet." />
) : (
<table className="w-full text-[12px]">
<thead className="border-b border-[--color-border] text-[--color-fg-subtle]">
<tr>
<th className="px-4 py-2 text-left font-medium">When</th>
<th className="px-4 py-2 text-left font-medium">Action</th>
<th className="px-4 py-2 text-left font-medium">Resource</th>
<th className="px-4 py-2 text-left font-medium">IP</th>
</tr>
</thead>
<tbody>
{data.recentActivity.map((e) => (
<tr key={e.id} className="border-b border-[--color-border] last:border-0">
<td className="px-4 py-2 mono text-[--color-fg-muted]">
{new Date(e.createdAt).toLocaleString()}
</td>
<td className="px-4 py-2">
<span className="mono rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-2 py-0.5 text-[11px]">
{e.action}
</span>
</td>
<td className="px-4 py-2 mono text-[--color-fg-muted]">
{e.resourceType
? `${e.resourceType}/${e.resourceId?.slice(0, 8) ?? '—'}`
: '—'}
</td>
<td className="px-4 py-2 mono text-[--color-fg-muted]">{e.ipAddress ?? '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}
function Card({
label,
value,
sub,
subRender,
}: {
label: string;
value: number;
sub?: string;
subRender?: string;
}) {
return (
<div className="panel p-4">
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">{label}</div>
<div className="mt-1.5 text-[24px] font-semibold tabular-nums tracking-tight">
{value.toLocaleString()}
</div>
{sub && (
<div className="mt-1 text-[12px] text-[--color-fg-muted]">{subRender ?? sub}</div>
)}
</div>
);
}
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="panel p-4">
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">{title}</div>
<div className="mt-3">{children}</div>
</div>
);
}
function Empty({ text }: { text: string }) {
return <p className="text-[12.5px] text-[--color-fg-muted]">{text}</p>;
}