page.tsx131 lines · main
1import { ArrowLeftRightIcon } from '@/components/ui/arrow-left-right';
2import { BotIcon } from '@/components/ui/bot';
3import { ZapIcon } from '@/components/ui/zap';
4
5import { apiJson } from '@/lib/api';
6import { apiOrigin } from '@/lib/env';
7
8import { EmptyState } from '../_components/empty-state';
9import { Section } from '../_components/section';
10import { McpGlobalToggle, McpProjectControls, type ProjectAccess } from './mcp-key-form';
11
12export const metadata = { title: 'mcp / agent access · admin' };
13export const dynamic = 'force-dynamic';
14
15interface AuditRow {
16 id: string;
17 action: string;
18 actorId: string | null;
19 metadata: Record<string, unknown> | null;
20 createdAt: string;
21}
22
23interface McpStatus {
24 globalEnabled: boolean;
25 projects: ProjectAccess[];
26 audit: AuditRow[];
27}
28
29export default async function AdminMcpPage() {
30 const { globalEnabled, projects, audit } = await apiJson<McpStatus>('/v1/admin/mcp').catch(
31 (): McpStatus => ({ globalEnabled: false, projects: [], audit: [] }),
32 );
33
34 const enabled = projects.filter((p) => p.mcpEnabled);
35 const eligible = projects.filter((p) => !p.mcpEnabled && p.eligible);
36 const free = projects.filter((p) => !p.mcpEnabled && !p.eligible);
37 // Actionable projects first (enabled, then enable-able), then the muted
38 // free-tier ones — the operator's eye lands on what they can act on.
39 const ordered = [...enabled, ...eligible, ...free];
40
41 return (
42 <div className="flex flex-col gap-10">
43 <header className="flex flex-col gap-2">
44 <div className="flex items-center gap-2">
45 <span className="text-[var(--color-primary)]">
46 <BotIcon size={20} />
47 </span>
48 <h1 className="font-mono text-xl tracking-tight">mcp / agent access</h1>
49 </div>
50 <p className="max-w-prose font-mono text-sm text-[var(--color-text-muted)]">
51 control which agents and mcp clients can reach the platform — the global switch,
52 per-project access (Pro/Team only), issued keys, and the agent-activity audit trail. the
53 mcp server itself ships separately; this is the operator surface that gates it.
54 </p>
55 </header>
56
57 {/* ── global kill-switch ───────────────────────────────────────── */}
58 <Section title="global kill-switch" icon={<ZapIcon size={16} />}>
59 <McpGlobalToggle apiOrigin={apiOrigin} enabled={globalEnabled} />
60 </Section>
61
62 {/* ── per-project access ───────────────────────────────────────── */}
63 <Section title="per-project access" icon={<ArrowLeftRightIcon size={16} />}>
64 {projects.length === 0 ? (
65 <EmptyState
66 icon={<ArrowLeftRightIcon size={24} />}
67 title="no projects yet"
68 message="once a customer creates one on a Pro/Team plan, you'll be able to turn MCP access on for it here."
69 />
70 ) : (
71 <div className="flex flex-col gap-4">
72 {ordered.map((p) => (
73 <McpProjectControls key={p.projectId} apiOrigin={apiOrigin} project={p} />
74 ))}
75 </div>
76 )}
77 </Section>
78
79 {/* ── audit trail ──────────────────────────────────────────────── */}
80 <Section title="agent-access audit trail" icon={<BotIcon size={16} />}>
81 {audit.length === 0 ? (
82 <EmptyState
83 icon={<BotIcon size={24} />}
84 title="no MCP activity recorded yet"
85 message="every toggle, enable/disable, key issue and revoke shows up here."
86 />
87 ) : (
88 <div className="rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6 sm:p-8">
89 <ol className="relative ml-1 flex flex-col gap-7 border-l border-[var(--color-border-subtle)] pl-7">
90 {audit.map((row) => (
91 <li key={row.id} className="relative flex flex-col gap-1">
92 <span
93 aria-hidden
94 className="absolute -left-[33px] top-[3px] size-2.5 rounded-full border-2 border-[var(--color-surface)] bg-[var(--color-primary)]"
95 />
96 <div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 font-mono text-xs">
97 <span className="text-[var(--color-text)]">{row.action}</span>
98 {auditProjectId(row) ? (
99 <span className="text-[var(--color-text-muted)]">
100 project {auditProjectId(row)}
101 </span>
102 ) : null}
103 {row.actorId ? (
104 <span className="text-[var(--color-text-subtle)]">by {row.actorId}</span>
105 ) : null}
106 </div>
107 <time
108 className="font-mono text-[10px] text-[var(--color-text-subtle)]"
109 dateTime={row.createdAt}
110 >
111 {new Date(row.createdAt).toLocaleString()}
112 </time>
113 </li>
114 ))}
115 </ol>
116 </div>
117 )}
118 </Section>
119 </div>
120 );
121}
122
123/** The project a row touched — from the audit metadata, when present. */
124function auditProjectId(row: AuditRow): string | null {
125 const meta = row.metadata;
126 if (meta && typeof meta === 'object') {
127 const pid = (meta as Record<string, unknown>).projectId;
128 if (typeof pid === 'string') return pid;
129 }
130 return null;
131}