mcp-access-panel.tsx420 lines · main
| 1 | 'use client'; |
| 2 | |
| 3 | import Link from 'next/link'; |
| 4 | import { useRouter } from 'next/navigation'; |
| 5 | import { useState, useTransition } from 'react'; |
| 6 | |
| 7 | /* ─── shared types (mirror the GET /v1/projects/:id/mcp payload) ─────────── */ |
| 8 | |
| 9 | export type McpScope = 'read' | 'read-write' | 'admin'; |
| 10 | export type PlanTier = 'free' | 'pro' | 'team'; |
| 11 | |
| 12 | export 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 | |
| 24 | export 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. */ |
| 33 | const MCP_ENDPOINT = 'https://api.briven.tech/mcp'; |
| 34 | |
| 35 | /* ─── small fetch helper ─────────────────────────────────────────────────── */ |
| 36 | |
| 37 | type SendResult = |
| 38 | | { kind: 'ok'; data: unknown } |
| 39 | | { kind: 'error'; message: string }; |
| 40 | |
| 41 | async 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 | |
| 70 | export 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'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'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 | |
| 364 | function 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 “{name}” 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 | |
| 414 | function 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 | } |