"use client"; /** * Workspace settings panel: shows workspace identity + API keys * (mint / list / revoke), and renders ready-to-paste config for * Cursor and other VS Code-style IDEs so an AI agent can act on * behalf of this workspace. * * The full plaintext token is shown ONCE in the post-create modal * and never again — `key_hash` is all we persist server-side. */ import { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { toast } from "sonner"; import { Copy, Download, KeyRound, Loader2, Plus, RefreshCw, Trash2 } from "lucide-react"; interface WorkspaceSummary { id: string; slug: string; name: string; coolifyProjectUuid: string | null; coolifyTeamId: number | null; giteaOrg: string | null; provisionStatus: "pending" | "partial" | "ready" | "error"; provisionError: string | null; } interface ApiKey { id: string; name: string; prefix: string; scopes: string[]; created_by: string; last_used_at: string | null; revoked_at: string | null; created_at: string; } interface MintedKey { id: string; name: string; prefix: string; token: string; createdAt: string; } const APP_BASE = typeof window !== "undefined" ? window.location.origin : "https://vibnai.com"; /** * The URL param (e.g. `/mark-account/settings`) is treated as a hint — * the actual workspace is resolved via `GET /api/workspaces`, which is * keyed off the signed-in user. That endpoint also lazy-creates the * workspace row for users who signed in before the workspace hook * landed, so this panel never gets stuck on a missing row. */ export function WorkspaceKeysPanel({ workspaceSlug: _urlHint }: { workspaceSlug?: string }) { const [workspace, setWorkspace] = useState(null); const [keys, setKeys] = useState([]); const [loading, setLoading] = useState(true); const [errorMsg, setErrorMsg] = useState(null); const [provisioning, setProvisioning] = useState(false); const [showCreate, setShowCreate] = useState(false); const [newName, setNewName] = useState(""); const [creating, setCreating] = useState(false); const [minted, setMinted] = useState(null); const [keyToRevoke, setKeyToRevoke] = useState(null); const [revoking, setRevoking] = useState(false); const refresh = useCallback(async () => { setLoading(true); setErrorMsg(null); try { const listRes = await fetch(`/api/workspaces`, { credentials: "include" }); if (!listRes.ok) { const body = listRes.status === 401 ? "Sign in to view your workspace." : `Couldn't load workspace (HTTP ${listRes.status}).`; setErrorMsg(body); setWorkspace(null); setKeys([]); return; } const list = (await listRes.json()) as { workspaces: WorkspaceSummary[] }; const ws = list.workspaces?.[0] ?? null; setWorkspace(ws); if (!ws) { setKeys([]); return; } const keysRes = await fetch(`/api/workspaces/${ws.slug}/keys`, { credentials: "include" }); if (keysRes.ok) { const j = (await keysRes.json()) as { keys: ApiKey[] }; setKeys(j.keys ?? []); } } catch (err) { console.error("[workspace-keys] refresh failed", err); setErrorMsg(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } }, []); useEffect(() => { refresh(); }, [refresh]); const provision = useCallback(async () => { if (!workspace) return; setProvisioning(true); try { const res = await fetch(`/api/workspaces/${workspace.slug}/provision`, { method: "POST", credentials: "include", }); if (!res.ok) throw new Error(await res.text()); toast.success("Provisioning re-run"); await refresh(); } catch (err) { toast.error(`Provisioning failed: ${err instanceof Error ? err.message : String(err)}`); } finally { setProvisioning(false); } }, [workspace, refresh]); const createKey = useCallback(async () => { if (!workspace) return; if (!newName.trim()) { toast.error("Give the key a name"); return; } setCreating(true); try { const res = await fetch(`/api/workspaces/${workspace.slug}/keys`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newName.trim() }), }); const j = await res.json(); if (!res.ok) throw new Error(j.error ?? "Failed"); setShowCreate(false); setNewName(""); setMinted(j as MintedKey); await refresh(); } catch (err) { toast.error(`Could not mint key: ${err instanceof Error ? err.message : String(err)}`); } finally { setCreating(false); } }, [workspace, newName, refresh]); const revokeKey = useCallback(async () => { if (!workspace || !keyToRevoke) return; setRevoking(true); try { const res = await fetch( `/api/workspaces/${workspace.slug}/keys/${keyToRevoke.id}`, { method: "DELETE", credentials: "include" } ); if (!res.ok) throw new Error(await res.text()); toast.success("Key revoked"); setKeyToRevoke(null); await refresh(); } catch (err) { toast.error(`Revoke failed: ${err instanceof Error ? err.message : String(err)}`); } finally { setRevoking(false); } }, [workspace, keyToRevoke, refresh]); if (loading && !workspace) { return (
Loading workspace…
); } if (!workspace) { return (

Workspace

{errorMsg ?? "No workspace yet — this usually means you signed in before AI access was rolled out."}

Try signing out and back in, then refresh this page. If the problem persists, the API may be down or your account may need to be provisioned manually.

); } return (
setShowCreate(true)} onRevokeClick={k => setKeyToRevoke(k)} onRefresh={refresh} /> {/* ── Create key modal ─────────────────────────────────────── */} Create workspace API key Used by AI agents (Cursor, Claude, scripts) to act on behalf of {workspace.slug}. The token is shown once — save it somewhere safe.
setNewName(e.target.value)} autoFocus onKeyDown={e => { if (e.key === "Enter") createKey(); }} />
{/* ── Show minted secret ONCE ──────────────────────────────── */} !open && setMinted(null)}> Save your API key This is the only time the full key is shown. Store it in a password manager or paste it into the Cursor config below. {minted && } {/* ── Revoke confirm ───────────────────────────────────────── */} !open && setKeyToRevoke(null)}> Revoke this key? Any agent using {keyToRevoke?.prefix}… will immediately lose access to {workspace.slug}. This cannot be undone. Cancel {revoking && } Revoke
); } // ────────────────────────────────────────────────── // Sub-components // ────────────────────────────────────────────────── function WorkspaceIdentityCard({ workspace, onProvision, provisioning, }: { workspace: WorkspaceSummary; onProvision: () => void; provisioning: boolean; }) { const status = workspace.provisionStatus; const statusColor = status === "ready" ? "#10b981" : status === "partial" ? "#f59e0b" : status === "error" ? "#ef4444" : "#9ca3af"; return (

Workspace

Your tenant boundary on Coolify and Gitea. All AI access is scoped to this workspace.

{workspace.slug}} /> {status} } /> {workspace.coolifyProjectUuid} ) : ( not provisioned ) } /> {workspace.giteaOrg} ) : ( not provisioned ) } />
{workspace.provisionError && (
{workspace.provisionError}
)}
); } function KeysCard({ workspace, keys, onCreateClick, onRevokeClick, onRefresh, }: { workspace: WorkspaceSummary; keys: ApiKey[]; onCreateClick: () => void; onRevokeClick: (k: ApiKey) => void; onRefresh: () => void; }) { const active = useMemo(() => keys.filter(k => !k.revoked_at), [keys]); const revoked = useMemo(() => keys.filter(k => k.revoked_at), [keys]); return (

API keys

Tokens scoped to {workspace.slug}. Use them in Cursor, Claude Code, the CLI, or any HTTP client.

{active.length === 0 ? ( ) : (
    {active.map(k => ( onRevokeClick(k)} /> ))}
)} {revoked.length > 0 && (
{revoked.length} revoked
    {revoked.map(k => ( ))}
)}
); } function KeyRow({ k, onRevoke }: { k: ApiKey; onRevoke?: () => void }) { return (
  • {k.name}
    {k.prefix}… {k.last_used_at ? ` · last used ${new Date(k.last_used_at).toLocaleString()}` : " · never used"}
    {onRevoke && ( )}
  • ); } function EmptyKeysState({ onCreateClick }: { onCreateClick: () => void }) { return (
    No API keys yet
    Mint one to let Cursor or any AI agent push code and deploy on your behalf.
    ); } // ────────────────────────────────────────────────── // Cursor / VS Code integration block // ────────────────────────────────────────────────── function CursorIntegrationCard({ workspace }: { workspace: WorkspaceSummary }) { const cursorRule = buildCursorRule(workspace); const mcpJson = buildMcpJson(workspace, ""); const envSnippet = buildEnvSnippet(workspace, ""); return (

    Connect Cursor

    Drop these into your repo (or ~/.cursor/) so any agent inside Cursor knows how to talk to this workspace.

    ); } function FileBlock({ title, description, filename, contents, language, }: { title: string; description: string; filename: string; contents: string; language: string; }) { const copy = useCallback(() => { navigator.clipboard.writeText(contents).then( () => toast.success(`Copied ${title}`), () => toast.error("Copy failed") ); }, [contents, title]); const download = useCallback(() => { const blob = new Blob([contents], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }, [contents, filename]); return (
    {title}
    {description}
            {contents}
          
    ); } function MintedKeyView({ workspace, minted }: { workspace: WorkspaceSummary; minted: MintedKey }) { const cursorRule = buildCursorRule(workspace); const mcpJson = buildMcpJson(workspace, minted.token); const envSnippet = buildEnvSnippet(workspace, minted.token); return (
    ); } // ────────────────────────────────────────────────── // File generators // ────────────────────────────────────────────────── function buildCursorRule(w: WorkspaceSummary): string { return `--- description: Vibn workspace "${w.slug}" — REST API for git/deploy via Coolify + Gitea alwaysApply: true --- # Vibn workspace: ${w.slug} You have access to the Vibn workspace **${w.slug}** through the REST API at \`${APP_BASE}\`. The API is scoped to this workspace only — the bearer token cannot reach any other tenant. ## Auth All requests must include: \`\`\` Authorization: Bearer $VIBN_API_KEY \`\`\` The token lives in \`.env.local\` as \`VIBN_API_KEY\`. Never print it. ## Workspace identity - Coolify Project UUID: \`${w.coolifyProjectUuid ?? "(not provisioned)"}\` - Gitea organization: \`${w.giteaOrg ?? "(not provisioned)"}\` - Provision status: \`${w.provisionStatus}\` All git operations should target the \`${w.giteaOrg ?? "(unset)"}\` org on \`git.vibnai.com\`. All deployments live under the Coolify Project above. ## Useful endpoints (all under ${APP_BASE}) | Method | Path | Purpose | |-------:|--------------------------------------------|--------------------------| | GET | /api/workspaces/${w.slug} | Workspace details | | GET | /api/workspaces/${w.slug}/keys | List API keys | | POST | /api/workspaces/${w.slug}/provision | Re-run Coolify+Gitea provisioning | | GET | /api/projects | Projects in this workspace | | POST | /api/projects/create | Create a new project (provisions repo + Coolify apps) | | GET | /api/projects/{projectId} | Project details | | GET | /api/projects/{projectId}/preview-url | Live deploy URLs | | GET | /api/projects/{projectId}/file?path=... | Read a file from the repo | ## House rules for AI agents 1. Confirm with the user before creating new projects, repos, or deployments. 2. Use \`git.vibnai.com/${w.giteaOrg ?? ""}/\` for git remotes. 3. Prefer issuing PRs over force-pushing to \`main\`. 4. If a request is rejected with 403, re-check that the key is for workspace \`${w.slug}\` (it cannot act on others). 5. Treat \`VIBN_API_KEY\` like a password — never echo, log, or commit it. `; } function buildMcpJson(w: WorkspaceSummary, token: string): string { const config = { mcpServers: { [`vibn-${w.slug}`]: { url: `${APP_BASE}/api/mcp`, headers: { Authorization: `Bearer ${token}`, }, }, }, }; return JSON.stringify(config, null, 2); } function buildEnvSnippet(w: WorkspaceSummary, token: string): string { return `# Vibn workspace: ${w.slug} # Generated ${new Date().toISOString()} VIBN_API_BASE=${APP_BASE} VIBN_WORKSPACE=${w.slug} VIBN_API_KEY=${token} # Quick smoke test: # curl -H "Authorization: Bearer $VIBN_API_KEY" $VIBN_API_BASE/api/workspaces/$VIBN_WORKSPACE `; } // ────────────────────────────────────────────────── // Inline styles (matches the rest of the dashboard's plain-CSS look) // ────────────────────────────────────────────────── const cardStyle: React.CSSProperties = { background: "var(--card-bg, #fff)", border: "1px solid var(--border, #e5e7eb)", borderRadius: 12, padding: 20, }; const cardHeaderStyle: React.CSSProperties = { display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 16, marginBottom: 16, }; const cardTitleStyle: React.CSSProperties = { fontSize: 16, fontWeight: 700, color: "var(--ink)", margin: 0, letterSpacing: "-0.01em", }; const cardSubtitleStyle: React.CSSProperties = { fontSize: 12.5, color: "var(--muted)", margin: "4px 0 0", lineHeight: 1.5, maxWidth: 520, }; const kvGrid: React.CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 12, margin: 0, }; function Kv({ label, value }: { label: string; value: React.ReactNode }) { return (
    {label}
    {value}
    ); }