mcp-key-form.tsx560 lines · main
| 1 | 'use client'; |
| 2 | |
| 3 | import { motion } from 'motion/react'; |
| 4 | import { useRouter } from 'next/navigation'; |
| 5 | import { useState, useTransition } from 'react'; |
| 6 | |
| 7 | import { StepUpPrompt } from '@/components/step-up-prompt'; |
| 8 | |
| 9 | /* ─── shared types (mirror the /v1/admin/mcp payload) ────────────────────── */ |
| 10 | |
| 11 | export type McpScope = 'read' | 'read-write' | 'admin'; |
| 12 | export type PlanTier = 'free' | 'pro' | 'team'; |
| 13 | |
| 14 | export 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 | |
| 26 | export 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 | |
| 37 | type SendResult = |
| 38 | | { kind: 'ok'; data: unknown } |
| 39 | | { kind: 'step_up' } |
| 40 | | { kind: 'error'; message: string }; |
| 41 | |
| 42 | async 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 | */ |
| 73 | function 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 | |
| 90 | export 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 | |
| 221 | export 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 | |
| 497 | function 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 “{name}” 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 | |
| 547 | function 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 | } |