mcp-key-form.tsx560 lines · main
1'use client';
2
3import { motion } from 'motion/react';
4import { useRouter } from 'next/navigation';
5import { useState, useTransition } from 'react';
6
7import { StepUpPrompt } from '@/components/step-up-prompt';
8
9/* ─── shared types (mirror the /v1/admin/mcp payload) ────────────────────── */
10
11export type McpScope = 'read' | 'read-write' | 'admin';
12export type PlanTier = 'free' | 'pro' | 'team';
13
14export interface MaskedKey {
15 id: string;
16 name: string;
17 prefix: string;
18 suffix: string;
19 scope: McpScope;
20 enabled: boolean;
21 createdAt: string | null;
22 lastUsedAt: string | null;
23 revokedAt: string | null;
24}
25
26export interface ProjectAccess {
27 projectId: string;
28 projectName: string;
29 planTier: PlanTier;
30 mcpEnabled: boolean;
31 eligible: boolean;
32 keys: MaskedKey[];
33}
34
35/* ─── small fetch helper with step-up detection ──────────────────────────── */
36
37type SendResult =
38 | { kind: 'ok'; data: unknown }
39 | { kind: 'step_up' }
40 | { kind: 'error'; message: string };
41
42async function post(apiOrigin: string, path: string, body: unknown): Promise<SendResult> {
43 try {
44 const res = await fetch(`${apiOrigin}${path}`, {
45 method: 'POST',
46 credentials: 'include',
47 headers: { 'content-type': 'application/json' },
48 body: JSON.stringify(body),
49 });
50 if (res.status === 403) {
51 const parsed = (await res.json().catch(() => null)) as { code?: string } | null;
52 if (parsed?.code === 'step_up_required') return { kind: 'step_up' };
53 if (parsed?.code === 'mcp_plan_required') {
54 return { kind: 'error', message: 'this project needs a Pro or Team plan.' };
55 }
56 }
57 if (!res.ok) {
58 const text = await res.text().catch(() => '');
59 return { kind: 'error', message: text || `request failed: ${res.status}` };
60 }
61 return { kind: 'ok', data: await res.json().catch(() => ({})) };
62 } catch (err) {
63 return { kind: 'error', message: err instanceof Error ? err.message : 'request failed' };
64 }
65}
66
67/* ─── pulsing status dot (matches the cockpit's health dots) ─────────────── */
68
69/**
70 * Green pulse when the thing is alive, solid red when it's cut. The pulse
71 * only runs in the "on" state so a killed switch reads as unmistakably dead.
72 */
73function PulseDot({ on, size = 'md' }: { on: boolean; size?: 'md' | 'lg' }) {
74 const dim = size === 'lg' ? 'h-3.5 w-3.5' : 'h-2.5 w-2.5';
75 const color = on ? 'bg-[var(--color-success)]' : 'bg-[var(--color-error)]';
76 return (
77 <span className={`relative flex shrink-0 ${dim}`} aria-hidden>
78 {on ? (
79 <span
80 className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-60 ${color}`}
81 />
82 ) : null}
83 <span className={`relative inline-flex rounded-full ${dim} ${color}`} />
84 </span>
85 );
86}
87
88/* ─── global kill-switch (hero control) ──────────────────────────────────── */
89
90export function McpGlobalToggle({
91 apiOrigin,
92 enabled,
93}: {
94 apiOrigin: string;
95 enabled: boolean;
96}) {
97 const router = useRouter();
98 const [busy, setBusy] = useState(false);
99 const [error, setError] = useState<string | null>(null);
100 const [confirming, setConfirming] = useState(false);
101 const [pendingValue, setPendingValue] = useState<boolean | null>(null);
102 const [, startTransition] = useTransition();
103
104 async function send(next: boolean) {
105 setBusy(true);
106 setError(null);
107 const result = await post(apiOrigin, '/v1/admin/mcp/global', { enabled: next });
108 setBusy(false);
109 if (result.kind === 'step_up') {
110 setPendingValue(next);
111 return;
112 }
113 if (result.kind === 'error') {
114 setError(result.message);
115 return;
116 }
117 setConfirming(false);
118 startTransition(() => router.refresh());
119 }
120
121 function onClick() {
122 // Turning OFF cuts every agent at once — confirm first.
123 if (enabled) {
124 setConfirming(true);
125 return;
126 }
127 void send(true);
128 }
129
130 return (
131 <motion.div
132 initial={{ opacity: 0, y: 8 }}
133 animate={{ opacity: 1, y: 0 }}
134 transition={{ duration: 0.4, ease: 'easeOut' }}
135 className="flex flex-col gap-6 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6 sm:p-8"
136 >
137 <div className="flex flex-wrap items-center justify-between gap-6">
138 <div className="flex items-start gap-5">
139 <span className="mt-2.5">
140 <PulseDot on={enabled} size="lg" />
141 </span>
142 <div className="flex flex-col gap-1.5">
143 <span className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
144 global agent access
145 </span>
146 <span
147 className={`font-mono text-3xl tracking-tight ${
148 enabled ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'
149 }`}
150 >
151 {enabled ? 'on' : 'off'}
152 </span>
153 <span className="max-w-prose font-mono text-xs leading-relaxed text-[var(--color-text-muted)]">
154 the master switch. off cuts every agent and mcp client at once, regardless of
155 per-project settings.
156 </span>
157 </div>
158 </div>
159
160 <button
161 type="button"
162 onClick={onClick}
163 disabled={busy}
164 className={
165 enabled
166 ? 'rounded-md border border-[var(--color-error)] px-5 py-2.5 font-mono text-xs text-[var(--color-error)] transition hover:bg-[var(--color-error)] hover:text-[var(--color-text-inverse)] disabled:opacity-50'
167 : 'rounded-md bg-[var(--color-primary)] px-5 py-2.5 font-mono text-xs text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)] disabled:opacity-50'
168 }
169 >
170 {busy ? 'saving…' : enabled ? 'turn off' : 'turn on'}
171 </button>
172 </div>
173
174 {confirming ? (
175 <div className="flex flex-col gap-3 rounded-lg border border-[var(--color-warning)] bg-[var(--color-surface)] p-4 font-mono text-xs">
176 <p className="text-[var(--color-text)]">
177 turn OFF global agent access? this immediately blocks every agent / MCP client across
178 ALL projects.
179 </p>
180 <div className="flex items-center gap-3">
181 <button
182 type="button"
183 onClick={() => void send(false)}
184 disabled={busy}
185 className="rounded-md bg-[var(--color-error)] px-4 py-2 font-mono text-xs text-[var(--color-text-inverse)] disabled:opacity-50"
186 >
187 {busy ? 'turning off…' : 'yes, turn off'}
188 </button>
189 <button
190 type="button"
191 onClick={() => setConfirming(false)}
192 disabled={busy}
193 className="font-mono text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
194 >
195 cancel
196 </button>
197 </div>
198 </div>
199 ) : null}
200
201 {error ? <p className="font-mono text-[10px] text-[var(--color-error)]">{error}</p> : null}
202
203 {pendingValue != null ? (
204 <StepUpPrompt
205 apiOrigin={apiOrigin}
206 reason="flipping the global MCP switch requires fresh step-up auth."
207 onSuccess={async () => {
208 const v = pendingValue;
209 setPendingValue(null);
210 if (v != null) await send(v);
211 }}
212 onCancel={() => setPendingValue(null)}
213 />
214 ) : null}
215 </motion.div>
216 );
217}
218
219/* ─── per-project controls (enable / disable / issue / revoke) ───────────── */
220
221export function McpProjectControls({
222 apiOrigin,
223 project,
224}: {
225 apiOrigin: string;
226 project: ProjectAccess;
227}) {
228 const router = useRouter();
229 const [busy, setBusy] = useState(false);
230 const [error, setError] = useState<string | null>(null);
231 // The pending action we retry after a successful step-up.
232 const [pending, setPending] = useState<null | { run: () => Promise<void>; reason: string }>(null);
233 // The freshly-issued key, shown ONCE.
234 const [revealed, setRevealed] = useState<{ plaintext: string; name: string } | null>(null);
235 const [keyName, setKeyName] = useState('');
236 const [scope, setScope] = useState<McpScope>('read');
237 const [, startTransition] = useTransition();
238
239 function refresh() {
240 startTransition(() => router.refresh());
241 }
242
243 async function toggle(enable: boolean) {
244 const run = async () => {
245 setBusy(true);
246 setError(null);
247 const result = await post(
248 apiOrigin,
249 enable ? '/v1/admin/mcp/projects/enable' : '/v1/admin/mcp/projects/disable',
250 { projectId: project.projectId },
251 );
252 setBusy(false);
253 if (result.kind === 'step_up') {
254 setPending({ run, reason: `changing MCP access for ${project.projectName}.` });
255 return;
256 }
257 if (result.kind === 'error') {
258 setError(result.message);
259 return;
260 }
261 refresh();
262 };
263 await run();
264 }
265
266 async function issue() {
267 if (keyName.trim().length === 0) {
268 setError('name the key first.');
269 return;
270 }
271 const run = async () => {
272 setBusy(true);
273 setError(null);
274 const result = await post(apiOrigin, '/v1/admin/mcp/keys', {
275 projectId: project.projectId,
276 name: keyName.trim(),
277 scope,
278 });
279 setBusy(false);
280 if (result.kind === 'step_up') {
281 setPending({ run, reason: `issuing an MCP key for ${project.projectName}.` });
282 return;
283 }
284 if (result.kind === 'error') {
285 setError(result.message);
286 return;
287 }
288 const data = result.data as { plaintext?: string } | null;
289 if (data?.plaintext) setRevealed({ plaintext: data.plaintext, name: keyName.trim() });
290 setKeyName('');
291 setScope('read');
292 refresh();
293 };
294 await run();
295 }
296
297 async function revoke(keyId: string, keyLabel: string) {
298 const run = async () => {
299 setBusy(true);
300 setError(null);
301 const result = await post(apiOrigin, '/v1/admin/mcp/keys/revoke', { keyId });
302 setBusy(false);
303 if (result.kind === 'step_up') {
304 setPending({ run, reason: `revoking key ${keyLabel}.` });
305 return;
306 }
307 if (result.kind === 'error') {
308 setError(result.message);
309 return;
310 }
311 refresh();
312 };
313 await run();
314 }
315
316 // Delete only ever runs on an already-revoked key (the button is shown only
317 // for revoked keys) — REVOKE-THEN-DELETE. Hard-removes the row.
318 async function remove(keyId: string, keyLabel: string) {
319 const run = async () => {
320 setBusy(true);
321 setError(null);
322 const result = await post(apiOrigin, '/v1/admin/mcp/keys/delete', { keyId });
323 setBusy(false);
324 if (result.kind === 'step_up') {
325 setPending({ run, reason: `deleting key ${keyLabel}.` });
326 return;
327 }
328 if (result.kind === 'error') {
329 setError(result.message);
330 return;
331 }
332 refresh();
333 };
334 await run();
335 }
336
337 return (
338 <motion.div
339 initial={{ opacity: 0, y: 8 }}
340 animate={{ opacity: 1, y: 0 }}
341 transition={{ duration: 0.4, ease: 'easeOut' }}
342 className="flex flex-col gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6"
343 >
344 <div className="flex flex-wrap items-center justify-between gap-3">
345 <div className="flex flex-wrap items-center gap-2.5">
346 <span className="font-mono text-sm text-[var(--color-text)]">{project.projectName}</span>
347 <PlanBadge tier={project.planTier} />
348 {project.mcpEnabled ? (
349 <span className="inline-flex items-center gap-1.5 rounded-md bg-[var(--color-primary-subtle)] px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider text-[var(--color-primary)]">
350 <span className="size-1.5 rounded-full bg-[var(--color-primary)]" /> mcp on
351 </span>
352 ) : null}
353 </div>
354
355 {project.mcpEnabled ? (
356 <button
357 type="button"
358 onClick={() => void toggle(false)}
359 disabled={busy}
360 className="rounded-md border border-[var(--color-border)] px-3 py-1.5 font-mono text-[10px] text-[var(--color-text-muted)] transition hover:text-[var(--color-error)] disabled:opacity-50"
361 >
362 disable mcp
363 </button>
364 ) : project.eligible ? (
365 <button
366 type="button"
367 onClick={() => void toggle(true)}
368 disabled={busy}
369 className="rounded-md bg-[var(--color-primary)] px-3 py-1.5 font-mono text-[10px] text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)] disabled:opacity-50"
370 >
371 enable mcp
372 </button>
373 ) : (
374 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
375 requires Pro/Team
376 </span>
377 )}
378 </div>
379
380 {/* keys + issue, only when enabled */}
381 {project.mcpEnabled ? (
382 <div className="flex flex-col gap-4 border-t border-[var(--color-border-subtle)] pt-4">
383 {project.keys.length === 0 ? (
384 <p className="font-mono text-[10px] text-[var(--color-text-subtle)]">
385 no keys issued yet.
386 </p>
387 ) : (
388 <ul className="flex flex-col gap-2">
389 {project.keys.map((k) => (
390 <li
391 key={k.id}
392 className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-[var(--color-surface-raised)] px-4 py-2.5"
393 >
394 <span className="flex flex-wrap items-center gap-2.5 font-mono text-[10px] text-[var(--color-text-muted)]">
395 <span className="text-xs text-[var(--color-text)]">{k.name}</span>
396 <span className="text-[var(--color-text-subtle)]">
397 {k.prefix}•••{k.suffix}
398 </span>
399 <span className="rounded bg-[var(--color-surface)] px-1.5 py-0.5">{k.scope}</span>
400 {k.revokedAt ? (
401 <span className="text-[var(--color-error)]">revoked</span>
402 ) : (
403 <span className="text-[var(--color-primary)]">active</span>
404 )}
405 </span>
406 {!k.revokedAt ? (
407 <button
408 type="button"
409 onClick={() => void revoke(k.id, `${k.prefix}•••${k.suffix}`)}
410 disabled={busy}
411 className="font-mono text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-error)] disabled:opacity-50"
412 >
413 revoke
414 </button>
415 ) : (
416 <button
417 type="button"
418 onClick={() => void remove(k.id, `${k.prefix}•••${k.suffix}`)}
419 disabled={busy}
420 className="font-mono text-[10px] text-[var(--color-text-subtle)] hover:text-[var(--color-error)] disabled:opacity-50"
421 >
422 delete
423 </button>
424 )}
425 </li>
426 ))}
427 </ul>
428 )}
429
430 {revealed ? (
431 <RevealOnce
432 plaintext={revealed.plaintext}
433 name={revealed.name}
434 onDismiss={() => setRevealed(null)}
435 />
436 ) : (
437 <div className="flex flex-wrap items-end gap-3">
438 <label className="flex flex-col gap-1">
439 <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
440 key name
441 </span>
442 <input
443 value={keyName}
444 onChange={(e) => setKeyName(e.currentTarget.value)}
445 placeholder="e.g. cursor-agent"
446 maxLength={120}
447 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1.5 font-mono text-xs text-[var(--color-text)] outline-none focus:border-[var(--color-primary)]"
448 />
449 </label>
450 <label className="flex flex-col gap-1">
451 <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
452 scope
453 </span>
454 <select
455 value={scope}
456 onChange={(e) => setScope(e.currentTarget.value as McpScope)}
457 className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1.5 font-mono text-xs text-[var(--color-text)] outline-none focus:border-[var(--color-primary)]"
458 >
459 <option value="read">read</option>
460 <option value="read-write">read-write</option>
461 <option value="admin">admin</option>
462 </select>
463 </label>
464 <button
465 type="button"
466 onClick={() => void issue()}
467 disabled={busy}
468 className="rounded-md bg-[var(--color-primary)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)] disabled:opacity-50"
469 >
470 {busy ? 'issuing…' : 'issue key'}
471 </button>
472 </div>
473 )}
474 </div>
475 ) : null}
476
477 {error ? <p className="font-mono text-[10px] text-[var(--color-error)]">{error}</p> : null}
478
479 {pending != null ? (
480 <StepUpPrompt
481 apiOrigin={apiOrigin}
482 reason={pending.reason}
483 onSuccess={async () => {
484 const job = pending;
485 setPending(null);
486 if (job) await job.run();
487 }}
488 onCancel={() => setPending(null)}
489 />
490 ) : null}
491 </motion.div>
492 );
493}
494
495/* ─── one-time key reveal (copy-once) ────────────────────────────────────── */
496
497function RevealOnce({
498 plaintext,
499 name,
500 onDismiss,
501}: {
502 plaintext: string;
503 name: string;
504 onDismiss: () => void;
505}) {
506 const [copied, setCopied] = useState(false);
507
508 async function copy() {
509 try {
510 await navigator.clipboard.writeText(plaintext);
511 setCopied(true);
512 setTimeout(() => setCopied(false), 2500);
513 } catch {
514 setCopied(false);
515 }
516 }
517
518 return (
519 <div className="flex flex-col gap-3 rounded-lg border border-[var(--color-warning)] bg-[var(--color-surface)] p-4">
520 <p className="font-mono text-[10px] text-[var(--color-warning)]">
521 copy &ldquo;{name}&rdquo; now — this is the ONLY time the full key is shown. after you
522 dismiss it, only the prefix…suffix hint remains.
523 </p>
524 <code className="block overflow-x-auto rounded bg-[var(--color-bg)] px-2.5 py-2 font-mono text-xs text-[var(--color-text)]">
525 {plaintext}
526 </code>
527 <div className="flex items-center gap-2">
528 <button
529 type="button"
530 onClick={() => void copy()}
531 className="rounded-md bg-[var(--color-primary)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)]"
532 >
533 {copied ? 'copied ✓' : 'copy key'}
534 </button>
535 <button
536 type="button"
537 onClick={onDismiss}
538 className="font-mono text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
539 >
540 done — hide it
541 </button>
542 </div>
543 </div>
544 );
545}
546
547function PlanBadge({ tier }: { tier: PlanTier }) {
548 const paid = tier === 'pro' || tier === 'team';
549 return (
550 <span
551 className={
552 paid
553 ? 'inline-flex rounded-md bg-[var(--color-surface-raised)] px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider text-[var(--color-text)]'
554 : 'inline-flex rounded-md bg-[var(--color-surface-raised)] px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]'
555 }
556 >
557 {tier}
558 </span>
559 );
560}