mcp-access-panel.tsx420 lines · main
1'use client';
2
3import Link from 'next/link';
4import { useRouter } from 'next/navigation';
5import { useState, useTransition } from 'react';
6
7/* ─── shared types (mirror the GET /v1/projects/:id/mcp payload) ─────────── */
8
9export type McpScope = 'read' | 'read-write' | 'admin';
10export type PlanTier = 'free' | 'pro' | 'team';
11
12export interface MaskedKey {
13 id: string;
14 name: string;
15 prefix: string;
16 suffix: string;
17 scope: McpScope;
18 enabled: boolean;
19 createdAt: string | null;
20 lastUsedAt: string | null;
21 revokedAt: string | null;
22}
23
24export interface ProjectMcpStatus {
25 globalEnabled: boolean;
26 planTier: PlanTier;
27 eligible: boolean;
28 mcpEnabled: boolean;
29 keys: MaskedKey[];
30}
31
32/** The endpoint agents/MCP clients connect to. Shown in the connect hint. */
33const MCP_ENDPOINT = 'https://api.briven.tech/mcp';
34
35/* ─── small fetch helper ─────────────────────────────────────────────────── */
36
37type SendResult =
38 | { kind: 'ok'; data: unknown }
39 | { kind: 'error'; message: string };
40
41async function post(apiOrigin: string, path: string, body?: unknown): Promise<SendResult> {
42 try {
43 const res = await fetch(`${apiOrigin}${path}`, {
44 method: 'POST',
45 credentials: 'include',
46 headers: body ? { 'content-type': 'application/json' } : undefined,
47 body: body ? JSON.stringify(body) : undefined,
48 });
49 if (!res.ok) {
50 const parsed = (await res.json().catch(() => null)) as { code?: string; message?: string } | null;
51 if (parsed?.code === 'mcp_plan_required') {
52 return { kind: 'error', message: 'this project needs a Pro or Team plan.' };
53 }
54 if (parsed?.code === 'mcp_global_disabled') {
55 return { kind: 'error', message: 'agent access is currently disabled platform-wide.' };
56 }
57 if (parsed?.code === 'mcp_not_enabled') {
58 return { kind: 'error', message: 'turn agent access on for this project first.' };
59 }
60 return { kind: 'error', message: parsed?.message || `request failed: ${res.status}` };
61 }
62 return { kind: 'ok', data: await res.json().catch(() => ({})) };
63 } catch (err) {
64 return { kind: 'error', message: err instanceof Error ? err.message : 'request failed' };
65 }
66}
67
68/* ─── panel ──────────────────────────────────────────────────────────────── */
69
70export function McpAccessPanel({
71 apiOrigin,
72 projectId,
73 initial,
74}: {
75 apiOrigin: string;
76 projectId: string;
77 initial: ProjectMcpStatus;
78}) {
79 const router = useRouter();
80 const [busy, setBusy] = useState(false);
81 const [error, setError] = useState<string | null>(null);
82 const [revealed, setRevealed] = useState<{ plaintext: string; name: string } | null>(null);
83 const [keyName, setKeyName] = useState('');
84 const [scope, setScope] = useState<McpScope>('read');
85 const [, startTransition] = useTransition();
86
87 const base = `/v1/projects/${projectId}/mcp`;
88
89 function refresh() {
90 startTransition(() => router.refresh());
91 }
92
93 // ── state 1: global off ────────────────────────────────────────────────
94 if (!initial.globalEnabled) {
95 return (
96 <Card>
97 <p className="font-mono text-xs text-[var(--color-text-muted)]">
98 agent access is currently disabled platform-wide. it isn&apos;t available right now —
99 check back later.
100 </p>
101 </Card>
102 );
103 }
104
105 // ── state 2: free tier (not eligible) ──────────────────────────────────
106 if (!initial.eligible) {
107 return (
108 <Card>
109 <div className="flex flex-col gap-3">
110 <p className="font-mono text-sm text-[var(--color-text)]">
111 Agent Access is a Pro/Team feature
112 </p>
113 <p className="font-mono text-xs text-[var(--color-text-muted)]">
114 upgrade this project&apos;s plan to let AI agents and MCP clients connect to it with
115 scoped keys you control.
116 </p>
117 <Link
118 href="/dashboard/billing/upgrade?tier=pro"
119 className="inline-flex w-fit 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)]"
120 >
121 upgrade to Pro/Team
122 </Link>
123 </div>
124 </Card>
125 );
126 }
127
128 // ── state 3: Pro/Team — enable/disable + keys ──────────────────────────
129 async function toggle(enable: boolean) {
130 setBusy(true);
131 setError(null);
132 const result = await post(apiOrigin, enable ? `${base}/enable` : `${base}/disable`);
133 setBusy(false);
134 if (result.kind === 'error') {
135 setError(result.message);
136 return;
137 }
138 refresh();
139 }
140
141 async function issue() {
142 if (keyName.trim().length === 0) {
143 setError('name the key first.');
144 return;
145 }
146 setBusy(true);
147 setError(null);
148 const result = await post(apiOrigin, `${base}/keys`, { name: keyName.trim(), scope });
149 setBusy(false);
150 if (result.kind === 'error') {
151 setError(result.message);
152 return;
153 }
154 const data = result.data as { plaintext?: string } | null;
155 if (data?.plaintext) setRevealed({ plaintext: data.plaintext, name: keyName.trim() });
156 setKeyName('');
157 setScope('read');
158 refresh();
159 }
160
161 async function revoke(keyId: string) {
162 setBusy(true);
163 setError(null);
164 const result = await post(apiOrigin, `${base}/keys/${keyId}/revoke`);
165 setBusy(false);
166 if (result.kind === 'error') {
167 setError(result.message);
168 return;
169 }
170 refresh();
171 }
172
173 // Delete only ever runs on an already-revoked key (the button is shown only
174 // for revoked keys) — REVOKE-THEN-DELETE. Hard-removes the row.
175 async function remove(keyId: string) {
176 setBusy(true);
177 setError(null);
178 const result = await post(apiOrigin, `${base}/keys/${keyId}/delete`);
179 setBusy(false);
180 if (result.kind === 'error') {
181 setError(result.message);
182 return;
183 }
184 refresh();
185 }
186
187 return (
188 <div className="flex flex-col gap-4">
189 {/* enable/disable */}
190 <Card>
191 <div className="flex flex-wrap items-center justify-between gap-3">
192 <div className="flex flex-col gap-1">
193 <span className="font-mono text-sm text-[var(--color-text)]">
194 agent access for this project
195 </span>
196 <span className="font-mono text-xs text-[var(--color-text-muted)]">
197 {initial.mcpEnabled
198 ? 'on — agents with a valid key can reach this project.'
199 : 'off — no agent can connect until you turn it on.'}
200 </span>
201 </div>
202 <div className="flex items-center gap-3">
203 <span
204 className={
205 initial.mcpEnabled
206 ? 'inline-flex items-center gap-1.5 rounded-md bg-[var(--color-primary-subtle)] px-2 py-1 font-mono text-[10px] uppercase tracking-wider text-[var(--color-primary)]'
207 : 'inline-flex items-center gap-1.5 rounded-md bg-[var(--color-surface-raised)] px-2 py-1 font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]'
208 }
209 >
210 <span
211 className={`size-1.5 rounded-full ${
212 initial.mcpEnabled ? 'bg-[var(--color-primary)]' : 'bg-[var(--color-text-subtle)]'
213 }`}
214 />
215 {initial.mcpEnabled ? 'enabled' : 'disabled'}
216 </span>
217 <button
218 type="button"
219 onClick={() => void toggle(!initial.mcpEnabled)}
220 disabled={busy}
221 className={
222 initial.mcpEnabled
223 ? 'rounded-md border border-[var(--color-border)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)] transition hover:text-[var(--color-error)] disabled:opacity-50'
224 : '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'
225 }
226 >
227 {busy ? 'saving…' : initial.mcpEnabled ? 'turn off' : 'turn on'}
228 </button>
229 </div>
230 </div>
231 </Card>
232
233 {/* keys + issue, only when enabled */}
234 {initial.mcpEnabled ? (
235 <Card>
236 <div className="flex flex-col gap-4">
237 <h3 className="font-mono text-sm text-[var(--color-text)]">keys</h3>
238
239 {initial.keys.length === 0 ? (
240 <p className="font-mono text-[10px] text-[var(--color-text-subtle)]">
241 no keys issued yet.
242 </p>
243 ) : (
244 <ul className="flex flex-col gap-1.5">
245 {initial.keys.map((k) => (
246 <li
247 key={k.id}
248 className="flex flex-wrap items-center justify-between gap-2 rounded-md bg-[var(--color-surface-raised)] px-2.5 py-1.5"
249 >
250 <span className="flex flex-wrap items-center gap-2 font-mono text-[10px] text-[var(--color-text-muted)]">
251 <span className="text-[var(--color-text)]">{k.name}</span>
252 <span className="text-[var(--color-text-subtle)]">
253 {k.prefix}•••{k.suffix}
254 </span>
255 <span className="rounded bg-[var(--color-surface)] px-1.5 py-0.5">{k.scope}</span>
256 {k.revokedAt ? (
257 <span className="text-[var(--color-error)]">revoked</span>
258 ) : (
259 <span className="text-[var(--color-primary)]">active</span>
260 )}
261 </span>
262 {!k.revokedAt ? (
263 <button
264 type="button"
265 onClick={() => void revoke(k.id)}
266 disabled={busy}
267 className="font-mono text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-error)] disabled:opacity-50"
268 >
269 revoke
270 </button>
271 ) : (
272 <button
273 type="button"
274 onClick={() => void remove(k.id)}
275 disabled={busy}
276 className="font-mono text-[10px] text-[var(--color-text-subtle)] hover:text-[var(--color-error)] disabled:opacity-50"
277 >
278 delete
279 </button>
280 )}
281 </li>
282 ))}
283 </ul>
284 )}
285
286 {revealed ? (
287 <RevealOnce
288 plaintext={revealed.plaintext}
289 name={revealed.name}
290 onDismiss={() => setRevealed(null)}
291 />
292 ) : (
293 <div className="flex flex-wrap items-end gap-2 border-t border-[var(--color-border-subtle)] pt-4">
294 <label className="flex flex-col gap-1">
295 <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
296 key name
297 </span>
298 <input
299 value={keyName}
300 onChange={(e) => setKeyName(e.currentTarget.value)}
301 placeholder="e.g. cursor-agent"
302 maxLength={120}
303 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)]"
304 />
305 </label>
306 <label className="flex flex-col gap-1">
307 <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
308 scope
309 </span>
310 <select
311 value={scope}
312 onChange={(e) => setScope(e.currentTarget.value as McpScope)}
313 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)]"
314 >
315 <option value="read">read</option>
316 <option value="read-write">read-write</option>
317 <option value="admin">admin</option>
318 </select>
319 </label>
320 <button
321 type="button"
322 onClick={() => void issue()}
323 disabled={busy}
324 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"
325 >
326 {busy ? 'issuing…' : 'issue key'}
327 </button>
328 </div>
329 )}
330 </div>
331 </Card>
332 ) : null}
333
334 {/* how to connect */}
335 {initial.mcpEnabled ? (
336 <Card>
337 <div className="flex flex-col gap-2">
338 <h3 className="font-mono text-sm text-[var(--color-text)]">how to connect</h3>
339 <p className="font-mono text-[10px] text-[var(--color-text-muted)]">
340 point your agent / MCP client at the endpoint below and authenticate with one of the
341 keys above (send it as a Bearer token). the key is locked to this project.
342 </p>
343 <dl className="grid grid-cols-[90px_1fr] gap-x-3 gap-y-1.5 rounded-md bg-[var(--color-surface-raised)] p-3 font-mono text-[11px]">
344 <dt className="text-[var(--color-text-subtle)]">endpoint</dt>
345 <dd>
346 <code className="text-[var(--color-text)]">{MCP_ENDPOINT}</code>
347 </dd>
348 <dt className="text-[var(--color-text-subtle)]">project id</dt>
349 <dd>
350 <code className="text-[var(--color-text)]">{projectId}</code>
351 </dd>
352 </dl>
353 </div>
354 </Card>
355 ) : null}
356
357 {error ? <p className="font-mono text-[10px] text-[var(--color-error)]">{error}</p> : null}
358 </div>
359 );
360}
361
362/* ─── one-time key reveal (copy-once) ────────────────────────────────────── */
363
364function RevealOnce({
365 plaintext,
366 name,
367 onDismiss,
368}: {
369 plaintext: string;
370 name: string;
371 onDismiss: () => void;
372}) {
373 const [copied, setCopied] = useState(false);
374
375 async function copy() {
376 try {
377 await navigator.clipboard.writeText(plaintext);
378 setCopied(true);
379 setTimeout(() => setCopied(false), 2500);
380 } catch {
381 setCopied(false);
382 }
383 }
384
385 return (
386 <div className="flex flex-col gap-2 rounded-md border border-[var(--color-warning)] bg-[var(--color-surface)] p-3">
387 <p className="font-mono text-[10px] text-[var(--color-warning)]">
388 copy &ldquo;{name}&rdquo; now — this is the ONLY time the full key is shown. after you
389 dismiss it, only the prefix…suffix hint remains.
390 </p>
391 <code className="block overflow-x-auto rounded bg-[var(--color-bg)] px-2.5 py-2 font-mono text-xs text-[var(--color-text)]">
392 {plaintext}
393 </code>
394 <div className="flex items-center gap-2">
395 <button
396 type="button"
397 onClick={() => void copy()}
398 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)]"
399 >
400 {copied ? 'copied ✓' : 'copy key'}
401 </button>
402 <button
403 type="button"
404 onClick={onDismiss}
405 className="font-mono text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
406 >
407 done — hide it
408 </button>
409 </div>
410 </div>
411 );
412}
413
414function Card({ children }: { children: React.ReactNode }) {
415 return (
416 <div className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-5">
417 {children}
418 </div>
419 );
420}