feat(workspaces): per-account tenancy + AI access keys + Cursor integration
Adds logical multi-tenancy on top of Coolify + Gitea so every Vibn
account gets its own isolated tenant boundary, and exposes that
boundary to AI agents (Cursor, Claude Code, scripts) through
per-workspace bearer tokens.
Schema (additive, idempotent — run /api/admin/migrate once after deploy)
- vibn_workspaces: slug, name, owner, coolify_project_uuid,
coolify_team_id (reserved for when Coolify ships POST /teams),
gitea_org, provision_status
- vibn_workspace_members: room for multi-user workspaces later
- vibn_workspace_api_keys: sha256-hashed bearer tokens
- fs_projects.vibn_workspace_id: nullable FK linking projects
to their workspace
Provisioning
- On first sign-in, ensureWorkspaceForUser() inserts the row
(no network calls — keeps signin fast).
- On first project create, ensureWorkspaceProvisioned() lazily
creates a Coolify Project (vibn-ws-{slug}) and a Gitea org
(vibn-{slug}). Failures are recorded on the row, not thrown,
and POST /api/workspaces/{slug}/provision retries.
Auth surface
- lib/auth/workspace-auth.ts: requireWorkspacePrincipal() accepts
either a NextAuth session or "Authorization: Bearer vibn_sk_...".
The bearer key is hard-pinned to one workspace — it cannot
reach any other tenant.
- mintWorkspaceApiKey / listWorkspaceApiKeys / revokeWorkspaceApiKey
Routes
- GET /api/workspaces list
- GET /api/workspaces/[slug] details
- POST /api/workspaces/[slug]/provision retry provisioning
- GET /api/workspaces/[slug]/keys list keys
- POST /api/workspaces/[slug]/keys mint key (token shown once)
- DELETE /api/workspaces/[slug]/keys/[keyId] revoke
UI
- components/workspace/WorkspaceKeysPanel.tsx: identity card,
keys CRUD with one-time secret reveal, and a "Connect Cursor"
block with copy/download for:
.cursor/rules/vibn-workspace.mdc — rule telling the agent
about the API + workspace IDs + house rules
~/.cursor/mcp.json — MCP server registration with key
embedded (server URL is /api/mcp; HTTP MCP route lands next)
.env.local — VIBN_API_KEY + smoke-test curl
- Slotted into existing /[workspace]/settings between Workspace
and Notifications cards (no other layout changes).
projects/create
- Resolves the user's workspace (creating + provisioning lazily).
- Repos go under workspace.gitea_org (falls back to GITEA_ADMIN_USER
for backwards compat).
- Coolify services are created inside workspace.coolify_project_uuid
(renamed {slug}-{appName} to stay unique within the namespace) —
no more per-Vibn-project Coolify Project sprawl.
- Stamps vibn_workspace_id on fs_projects.
lib/gitea
- createOrg, getOrg, addOrgOwner, getUser
- createRepo now routes /orgs/{owner}/repos when owner != admin
Also includes prior-turn auth hardening that was already in
authOptions.ts (CredentialsProvider for dev-local, isLocalNextAuth
cookie config) bundled in to keep the auth layer in one consistent
state.
.env.example
- Documents GITEA_API_URL / GITEA_API_TOKEN / GITEA_ADMIN_USER /
GITEA_WEBHOOK_SECRET and COOLIFY_URL / COOLIFY_API_TOKEN /
COOLIFY_SERVER_UUID, with the canonical hostnames
(git.vibnai.com, coolify.vibnai.com).
Post-deploy
- Run once: curl -X POST https://vibnai.com/api/admin/migrate \\
-H "x-admin-secret: \$ADMIN_MIGRATE_SECRET"
- Existing users get a workspace row on next sign-in.
- Existing fs_projects keep working (legacy gitea owner + their
own per-project Coolify Projects); new projects use the
workspace-scoped path.
Not in this commit (follow-ups)
- Wiring requireWorkspacePrincipal into the rest of /api/projects/*
so API keys can drive existing routes
- HTTP MCP server at /api/mcp (the mcp.json snippet already
points at the right URL — no client re-setup when it lands)
- Backfill script to assign legacy fs_projects to a workspace
Made-with: Cursor
This commit is contained in:
778
components/workspace/WorkspaceKeysPanel.tsx
Normal file
778
components/workspace/WorkspaceKeysPanel.tsx
Normal file
@@ -0,0 +1,778 @@
|
||||
"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";
|
||||
|
||||
export function WorkspaceKeysPanel({ workspaceSlug }: { workspaceSlug: string }) {
|
||||
const [workspace, setWorkspace] = useState<WorkspaceSummary | null>(null);
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [provisioning, setProvisioning] = useState(false);
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const [minted, setMinted] = useState<MintedKey | null>(null);
|
||||
const [keyToRevoke, setKeyToRevoke] = useState<ApiKey | null>(null);
|
||||
const [revoking, setRevoking] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [wsRes, keysRes] = await Promise.all([
|
||||
fetch(`/api/workspaces/${workspaceSlug}`),
|
||||
fetch(`/api/workspaces/${workspaceSlug}/keys`),
|
||||
]);
|
||||
if (wsRes.ok) setWorkspace(await wsRes.json());
|
||||
if (keysRes.ok) {
|
||||
const j = (await keysRes.json()) as { keys: ApiKey[] };
|
||||
setKeys(j.keys ?? []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[workspace-keys] refresh failed", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const provision = useCallback(async () => {
|
||||
setProvisioning(true);
|
||||
try {
|
||||
const res = await fetch(`/api/workspaces/${workspaceSlug}/provision`, { method: "POST" });
|
||||
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);
|
||||
}
|
||||
}, [workspaceSlug, refresh]);
|
||||
|
||||
const createKey = useCallback(async () => {
|
||||
if (!newName.trim()) {
|
||||
toast.error("Give the key a name");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch(`/api/workspaces/${workspaceSlug}/keys`, {
|
||||
method: "POST",
|
||||
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);
|
||||
}
|
||||
}, [workspaceSlug, newName, refresh]);
|
||||
|
||||
const revokeKey = useCallback(async () => {
|
||||
if (!keyToRevoke) return;
|
||||
setRevoking(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/workspaces/${workspaceSlug}/keys/${keyToRevoke.id}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
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);
|
||||
}
|
||||
}, [workspaceSlug, keyToRevoke, refresh]);
|
||||
|
||||
if (loading && !workspace) {
|
||||
return (
|
||||
<div style={{ padding: 24, color: "var(--muted)", fontSize: 13 }}>
|
||||
<Loader2 className="inline animate-spin" size={14} /> Loading workspace…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!workspace) {
|
||||
return (
|
||||
<div style={{ padding: 24, color: "var(--muted)", fontSize: 13 }}>
|
||||
Workspace not found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 28 }}>
|
||||
<WorkspaceIdentityCard workspace={workspace} onProvision={provision} provisioning={provisioning} />
|
||||
|
||||
<KeysCard
|
||||
workspace={workspace}
|
||||
keys={keys}
|
||||
onCreateClick={() => setShowCreate(true)}
|
||||
onRevokeClick={k => setKeyToRevoke(k)}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
|
||||
<CursorIntegrationCard workspace={workspace} />
|
||||
|
||||
{/* ── Create key modal ─────────────────────────────────────── */}
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create workspace API key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Used by AI agents (Cursor, Claude, scripts) to act on
|
||||
behalf of <code>{workspace.slug}</code>. The token is shown
|
||||
once — save it somewhere safe.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
<Label htmlFor="key-name">Key name</Label>
|
||||
<Input
|
||||
id="key-name"
|
||||
placeholder="e.g. Cursor on my MacBook"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
autoFocus
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter") createKey();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setShowCreate(false)} disabled={creating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={createKey} disabled={creating || !newName.trim()}>
|
||||
{creating && <Loader2 className="animate-spin" size={14} />}
|
||||
Mint key
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Show minted secret ONCE ──────────────────────────────── */}
|
||||
<Dialog open={!!minted} onOpenChange={open => !open && setMinted(null)}>
|
||||
<DialogContent style={{ maxWidth: 640 }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save your API key</DialogTitle>
|
||||
<DialogDescription>
|
||||
This is the only time the full key is shown. Store it in a
|
||||
password manager or paste it into the Cursor config below.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{minted && <MintedKeyView workspace={workspace} minted={minted} />}
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setMinted(null)}>I've saved it</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Revoke confirm ───────────────────────────────────────── */}
|
||||
<AlertDialog open={!!keyToRevoke} onOpenChange={open => !open && setKeyToRevoke(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revoke this key?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Any agent using <code>{keyToRevoke?.prefix}…</code> will
|
||||
immediately lose access to {workspace.slug}. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={revoking}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={revokeKey} disabled={revoking}>
|
||||
{revoking && <Loader2 className="animate-spin" size={14} />}
|
||||
Revoke
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 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 (
|
||||
<section style={cardStyle}>
|
||||
<header style={cardHeaderStyle}>
|
||||
<div>
|
||||
<h2 style={cardTitleStyle}>Workspace</h2>
|
||||
<p style={cardSubtitleStyle}>
|
||||
Your tenant boundary on Coolify and Gitea. All AI access is scoped to this workspace.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onProvision}
|
||||
disabled={provisioning}
|
||||
>
|
||||
{provisioning ? <Loader2 className="animate-spin" size={14} /> : <RefreshCw size={14} />}
|
||||
Re-provision
|
||||
</Button>
|
||||
</header>
|
||||
<dl style={kvGrid}>
|
||||
<Kv label="Slug" value={<code>{workspace.slug}</code>} />
|
||||
<Kv
|
||||
label="Status"
|
||||
value={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: "50%", background: statusColor }} />
|
||||
{status}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Kv
|
||||
label="Coolify Project"
|
||||
value={
|
||||
workspace.coolifyProjectUuid ? (
|
||||
<code style={{ fontSize: 11 }}>{workspace.coolifyProjectUuid}</code>
|
||||
) : (
|
||||
<span style={{ color: "var(--muted)" }}>not provisioned</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Kv
|
||||
label="Gitea Org"
|
||||
value={
|
||||
workspace.giteaOrg ? (
|
||||
<code>{workspace.giteaOrg}</code>
|
||||
) : (
|
||||
<span style={{ color: "var(--muted)" }}>not provisioned</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
{workspace.provisionError && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: "10px 12px",
|
||||
background: "#fef2f2",
|
||||
border: "1px solid #fecaca",
|
||||
borderRadius: 8,
|
||||
color: "#991b1b",
|
||||
fontSize: 12,
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{workspace.provisionError}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section style={cardStyle}>
|
||||
<header style={cardHeaderStyle}>
|
||||
<div>
|
||||
<h2 style={cardTitleStyle}>API keys</h2>
|
||||
<p style={cardSubtitleStyle}>
|
||||
Tokens scoped to <code>{workspace.slug}</code>. Use them in Cursor,
|
||||
Claude Code, the CLI, or any HTTP client.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Button variant="ghost" size="sm" onClick={onRefresh}>
|
||||
<RefreshCw size={14} />
|
||||
</Button>
|
||||
<Button size="sm" onClick={onCreateClick}>
|
||||
<Plus size={14} />
|
||||
New key
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{active.length === 0 ? (
|
||||
<EmptyKeysState onCreateClick={onCreateClick} />
|
||||
) : (
|
||||
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{active.map(k => (
|
||||
<KeyRow key={k.id} k={k} onRevoke={() => onRevokeClick(k)} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{revoked.length > 0 && (
|
||||
<details style={{ marginTop: 16 }}>
|
||||
<summary style={{ fontSize: 12, color: "var(--muted)", cursor: "pointer" }}>
|
||||
{revoked.length} revoked
|
||||
</summary>
|
||||
<ul style={{ listStyle: "none", margin: "8px 0 0", padding: 0, display: "flex", flexDirection: "column", gap: 6, opacity: 0.6 }}>
|
||||
{revoked.map(k => (
|
||||
<KeyRow key={k.id} k={k} />
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyRow({ k, onRevoke }: { k: ApiKey; onRevoke?: () => void }) {
|
||||
return (
|
||||
<li
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "10px 12px",
|
||||
background: "#fff",
|
||||
border: "1px solid var(--border, #e5e7eb)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<KeyRound size={16} style={{ color: "var(--muted)" }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--ink)" }}>{k.name}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--muted)", fontFamily: "monospace" }}>
|
||||
{k.prefix}…
|
||||
{k.last_used_at
|
||||
? ` · last used ${new Date(k.last_used_at).toLocaleString()}`
|
||||
: " · never used"}
|
||||
</div>
|
||||
</div>
|
||||
{onRevoke && (
|
||||
<Button variant="ghost" size="sm" onClick={onRevoke} aria-label="Revoke">
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyKeysState({ onCreateClick }: { onCreateClick: () => void }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "24px 16px",
|
||||
textAlign: "center",
|
||||
background: "#f9fafb",
|
||||
border: "1px dashed var(--border, #e5e7eb)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<KeyRound size={20} style={{ margin: "0 auto 8px", color: "var(--muted)" }} />
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--ink)" }}>No API keys yet</div>
|
||||
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 4 }}>
|
||||
Mint one to let Cursor or any AI agent push code and deploy on your behalf.
|
||||
</div>
|
||||
<Button size="sm" onClick={onCreateClick} style={{ marginTop: 12 }}>
|
||||
<Plus size={14} />
|
||||
Create first key
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Cursor / VS Code integration block
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
function CursorIntegrationCard({ workspace }: { workspace: WorkspaceSummary }) {
|
||||
const cursorRule = buildCursorRule(workspace);
|
||||
const mcpJson = buildMcpJson(workspace, "<paste-your-vibn_sk_-token>");
|
||||
const envSnippet = buildEnvSnippet(workspace, "<paste-your-vibn_sk_-token>");
|
||||
|
||||
return (
|
||||
<section style={cardStyle}>
|
||||
<header style={cardHeaderStyle}>
|
||||
<div>
|
||||
<h2 style={cardTitleStyle}>Connect Cursor</h2>
|
||||
<p style={cardSubtitleStyle}>
|
||||
Drop these into your repo (or <code>~/.cursor/</code>) so any
|
||||
agent inside Cursor knows how to talk to this workspace.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<FileBlock
|
||||
title=".cursor/rules/vibn-workspace.mdc"
|
||||
description="Tells the agent it can use the Vibn API and which workspace it's bound to."
|
||||
filename="vibn-workspace.mdc"
|
||||
contents={cursorRule}
|
||||
language="markdown"
|
||||
/>
|
||||
|
||||
<FileBlock
|
||||
title="~/.cursor/mcp.json"
|
||||
description="Registers Vibn as an MCP server so the agent can call workspace endpoints natively. Paste your minted key in place of the placeholder."
|
||||
filename="mcp.json"
|
||||
contents={mcpJson}
|
||||
language="json"
|
||||
/>
|
||||
|
||||
<FileBlock
|
||||
title=".env.local (for shell / scripts)"
|
||||
description="If the agent shells out (curl, gh, scripts), expose the key as an env var."
|
||||
filename="vibn.env"
|
||||
contents={envSnippet}
|
||||
language="bash"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ marginTop: 14, border: "1px solid var(--border, #e5e7eb)", borderRadius: 8, overflow: "hidden" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 12px",
|
||||
background: "#f9fafb",
|
||||
borderBottom: "1px solid var(--border, #e5e7eb)",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 600, color: "var(--ink)", fontFamily: "monospace" }}>{title}</div>
|
||||
<div style={{ fontSize: 11.5, color: "var(--muted)", marginTop: 2 }}>{description}</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
|
||||
<Button variant="ghost" size="sm" onClick={copy} aria-label={`Copy ${filename}`}>
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={download} aria-label={`Download ${filename}`}>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 12,
|
||||
background: "#0f172a",
|
||||
color: "#e2e8f0",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.55,
|
||||
overflowX: "auto",
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
}}
|
||||
>
|
||||
<code data-language={language}>{contents}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<FileBlock
|
||||
title="Your key"
|
||||
description="Copy this now — the full value is never shown again."
|
||||
filename={`${workspace.slug}-${minted.name.replace(/\s+/g, "-")}.txt`}
|
||||
contents={minted.token}
|
||||
language="text"
|
||||
/>
|
||||
<FileBlock
|
||||
title=".cursor/rules/vibn-workspace.mdc"
|
||||
description="Drop into your repo so Cursor agents know about the Vibn API."
|
||||
filename="vibn-workspace.mdc"
|
||||
contents={cursorRule}
|
||||
language="markdown"
|
||||
/>
|
||||
<FileBlock
|
||||
title="~/.cursor/mcp.json (key embedded)"
|
||||
description="Paste into Cursor's MCP config to register Vibn as a tool source."
|
||||
filename="mcp.json"
|
||||
contents={mcpJson}
|
||||
language="json"
|
||||
/>
|
||||
<FileBlock
|
||||
title=".env.local"
|
||||
description="For shell / script use."
|
||||
filename="vibn.env"
|
||||
contents={envSnippet}
|
||||
language="bash"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 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 ?? "<org>"}/<repo>\` 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 (
|
||||
<div>
|
||||
<dt style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--muted)" }}>
|
||||
{label}
|
||||
</dt>
|
||||
<dd style={{ margin: "4px 0 0", fontSize: 13, color: "var(--ink)", wordBreak: "break-all" }}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user