Ship Phases 1–3 of the multi-tenant AI access plan so an AI agent can
act on a Vibn workspace with one bearer token and zero admin reach.
Phase 1 — Gitea bot per workspace
- Add gitea_bot_username / gitea_bot_user_id / gitea_bot_token_encrypted
columns to vibn_workspaces (migrate route).
- New lib/auth/secret-box.ts (AES-256-GCM, VIBN_SECRETS_KEY) for PAT at rest.
- Extend lib/gitea.ts with createUser, createAccessTokenFor (Sudo PAT),
createOrgTeam, addOrgTeamMember, ensureOrgTeamMembership.
- ensureWorkspaceProvisioned now mints a vibn-bot-<slug> user, adds it to
a Writers team (write perms only) on the workspace's org, and stores
its PAT encrypted.
- GET /api/workspaces/[slug]/gitea-credentials returns a workspace-scoped
bot PAT + clone URL template; session or vibn_sk_ bearer auth.
Phase 2 — Tenant-safe Coolify proxy + real MCP
- lib/coolify.ts: projectUuidOf, listApplicationsInProject,
getApplicationInProject, TenantError, env CRUD, deployments list.
- Workspace-scoped REST endpoints (all filtered by coolify_project_uuid):
GET/POST /api/workspaces/[slug]/apps/[uuid](/deploy|/envs|/deployments),
GET /api/workspaces/[slug]/deployments/[deploymentUuid]/logs.
- Full rewrite of /api/mcp off legacy Firebase onto Postgres vibn_sk_
keys, exposing workspace.describe, gitea.credentials, projects.*,
apps.* (list/get/deploy/deployments, envs.list/upsert/delete).
Phase 3 — Settings UI AI bundle
- GET /api/workspaces/[slug]/bootstrap.sh: curl|sh installer that writes
.cursor/rules, .cursor/mcp.json and appends VIBN_* to .env.local.
Embeds the caller's vibn_sk_ token when invoked with bearer auth.
- WorkspaceKeysPanel: single AiAccessBundleCard with system-prompt block,
one-line bootstrap, Reveal-bot-PAT button, collapsible manual-setup
fallback. Minted-key modal also shows the bootstrap one-liner.
Ops prerequisites:
- Set VIBN_SECRETS_KEY (>=16 chars) on the frontend.
- Run /api/admin/migrate to add the three bot columns.
- GITEA_API_TOKEN must be a site-admin token (needed for admin/users
+ Sudo PAT mint); otherwise provision_status lands on 'partial'.
Made-with: Cursor
1061 lines
35 KiB
TypeScript
1061 lines
35 KiB
TypeScript
"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;
|
|
giteaBotUsername?: string | null;
|
|
giteaBotReady?: boolean;
|
|
provisionStatus: "pending" | "partial" | "ready" | "error";
|
|
provisionError: string | null;
|
|
}
|
|
|
|
interface GiteaBotCreds {
|
|
bot: { username: string; token: string };
|
|
gitea: { apiBase: string; host: string; cloneUrlTemplate: string; sshRemoteTemplate: string; webUrlTemplate: string };
|
|
workspace: { slug: string; giteaOrg: string };
|
|
}
|
|
|
|
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<WorkspaceSummary | null>(null);
|
|
const [keys, setKeys] = useState<ApiKey[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
|
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);
|
|
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 (
|
|
<section style={cardStyle}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8, color: "var(--muted)", fontSize: 13 }}>
|
|
<Loader2 className="animate-spin" size={14} /> Loading workspace…
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (!workspace) {
|
|
return (
|
|
<section style={cardStyle}>
|
|
<header style={cardHeaderStyle}>
|
|
<div>
|
|
<h2 style={cardTitleStyle}>Workspace</h2>
|
|
<p style={cardSubtitleStyle}>
|
|
{errorMsg ?? "No workspace yet — this usually means you signed in before AI access was rolled out."}
|
|
</p>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={refresh} disabled={loading}>
|
|
{loading ? <Loader2 className="animate-spin" size={14} /> : <RefreshCw size={14} />}
|
|
Try again
|
|
</Button>
|
|
</header>
|
|
<p style={{ fontSize: 12.5, color: "var(--muted)", margin: 0 }}>
|
|
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.
|
|
</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
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}
|
|
/>
|
|
|
|
<AiAccessBundleCard 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>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// AI access bundle — one card that replaces all the scattered snippets
|
|
// ──────────────────────────────────────────────────
|
|
|
|
function AiAccessBundleCard({ workspace }: { workspace: WorkspaceSummary }) {
|
|
const [botCreds, setBotCreds] = useState<GiteaBotCreds | null>(null);
|
|
const [revealingBot, setRevealingBot] = useState(false);
|
|
const [hideToken, setHideToken] = useState(true);
|
|
|
|
const revealBotCreds = useCallback(async () => {
|
|
setRevealingBot(true);
|
|
try {
|
|
const res = await fetch(`/api/workspaces/${workspace.slug}/gitea-credentials`, {
|
|
credentials: "include",
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.text();
|
|
throw new Error(body || `HTTP ${res.status}`);
|
|
}
|
|
const j = (await res.json()) as GiteaBotCreds;
|
|
setBotCreds(j);
|
|
setHideToken(true);
|
|
} catch (err) {
|
|
toast.error(
|
|
`Couldn't fetch Gitea bot: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
} finally {
|
|
setRevealingBot(false);
|
|
}
|
|
}, [workspace.slug]);
|
|
|
|
const promptBlock = useMemo(() => buildPromptBlock(workspace), [workspace]);
|
|
const cursorRule = useMemo(() => buildCursorRule(workspace), [workspace]);
|
|
const mcpJson = useMemo(
|
|
() => buildMcpJson(workspace, "<paste-your-vibn_sk_-token>"),
|
|
[workspace]
|
|
);
|
|
const envSnippet = useMemo(
|
|
() => buildEnvSnippet(workspace, "<paste-your-vibn_sk_-token>"),
|
|
[workspace]
|
|
);
|
|
const bootstrapCmd = useMemo(
|
|
() =>
|
|
`curl -sSfL -H "Authorization: Bearer $VIBN_API_KEY" ${APP_BASE}/api/workspaces/${workspace.slug}/bootstrap.sh | sh`,
|
|
[workspace.slug]
|
|
);
|
|
|
|
return (
|
|
<section style={cardStyle}>
|
|
<header style={cardHeaderStyle}>
|
|
<div>
|
|
<h2 style={cardTitleStyle}>AI access bundle</h2>
|
|
<p style={cardSubtitleStyle}>
|
|
Everything an AI agent needs to act on this workspace: a
|
|
system prompt, a one-line installer, and the low-level
|
|
snippets if you prefer to wire it up by hand.
|
|
</p>
|
|
</div>
|
|
</header>
|
|
|
|
<FileBlock
|
|
title="1. Paste into your AI's system prompt"
|
|
description="Tells the agent how this workspace works and which endpoints to call. Works in ChatGPT, Claude, Cursor agent, etc."
|
|
filename={`vibn-${workspace.slug}-prompt.md`}
|
|
contents={promptBlock}
|
|
language="markdown"
|
|
/>
|
|
|
|
<FileBlock
|
|
title="2. One-line setup inside any repo"
|
|
description="Mint a key above, export it as VIBN_API_KEY, then run this. It writes .cursor/rules, .cursor/mcp.json, and .env.local."
|
|
filename="vibn-bootstrap.sh"
|
|
contents={bootstrapCmd}
|
|
language="bash"
|
|
/>
|
|
|
|
<GiteaBotReveal
|
|
workspace={workspace}
|
|
creds={botCreds}
|
|
revealing={revealingBot}
|
|
hideToken={hideToken}
|
|
onReveal={revealBotCreds}
|
|
onToggleHide={() => setHideToken(h => !h)}
|
|
/>
|
|
|
|
<details style={{ marginTop: 18 }}>
|
|
<summary style={{ cursor: "pointer", fontSize: 12.5, fontWeight: 600, color: "var(--ink)" }}>
|
|
Manual setup — individual files
|
|
</summary>
|
|
<div style={{ marginTop: 10, display: "flex", flexDirection: "column", gap: 12 }}>
|
|
<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. Paste your minted key in place of the placeholder."
|
|
filename="mcp.json"
|
|
contents={mcpJson}
|
|
language="json"
|
|
/>
|
|
<FileBlock
|
|
title=".env.local"
|
|
description="Expose the key as an env var for shell scripts."
|
|
filename="vibn.env"
|
|
contents={envSnippet}
|
|
language="bash"
|
|
/>
|
|
</div>
|
|
</details>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function GiteaBotReveal({
|
|
workspace,
|
|
creds,
|
|
revealing,
|
|
hideToken,
|
|
onReveal,
|
|
onToggleHide,
|
|
}: {
|
|
workspace: WorkspaceSummary;
|
|
creds: GiteaBotCreds | null;
|
|
revealing: boolean;
|
|
hideToken: boolean;
|
|
onReveal: () => void;
|
|
onToggleHide: () => void;
|
|
}) {
|
|
const cloneExample = creds
|
|
? creds.gitea.cloneUrlTemplate.replace("{{repo}}", "example-repo")
|
|
: "";
|
|
const displayedToken = creds
|
|
? hideToken
|
|
? `${creds.bot.token.slice(0, 6)}${"•".repeat(24)}${creds.bot.token.slice(-4)}`
|
|
: creds.bot.token
|
|
: "";
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
marginTop: 18,
|
|
border: "1px solid var(--border, #e5e7eb)",
|
|
borderRadius: 8,
|
|
background: "#fff",
|
|
padding: 14,
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
|
|
<div>
|
|
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--ink)" }}>
|
|
3. Gitea bot credentials
|
|
</div>
|
|
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 2, lineHeight: 1.5 }}>
|
|
A dedicated Gitea user scoped to the <code>{workspace.giteaOrg ?? "(unprovisioned)"}</code> org.
|
|
Use this to <code>git clone</code> / push without exposing any root token.
|
|
</div>
|
|
</div>
|
|
{creds ? (
|
|
<Button variant="ghost" size="sm" onClick={onToggleHide}>
|
|
{hideToken ? "Show token" : "Hide"}
|
|
</Button>
|
|
) : (
|
|
<Button size="sm" onClick={onReveal} disabled={revealing}>
|
|
{revealing ? <Loader2 className="animate-spin" size={14} /> : <KeyRound size={14} />}
|
|
Reveal bot PAT
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{creds && (
|
|
<div style={{ marginTop: 12, display: "grid", gap: 8, fontSize: 12.5 }}>
|
|
<KV k="Username" v={<code>{creds.bot.username}</code>} />
|
|
<KV
|
|
k="PAT"
|
|
v={
|
|
<code
|
|
style={{ fontFamily: "monospace", wordBreak: "break-all", userSelect: "all" }}
|
|
>
|
|
{displayedToken}
|
|
</code>
|
|
}
|
|
/>
|
|
<KV k="Org" v={<code>{creds.workspace.giteaOrg}</code>} />
|
|
<KV k="API base" v={<code>{creds.gitea.apiBase}</code>} />
|
|
<div style={{ marginTop: 6 }}>
|
|
<div style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--muted)", marginBottom: 4 }}>
|
|
Clone URL (example)
|
|
</div>
|
|
<code style={{ fontFamily: "monospace", fontSize: 11.5, color: "var(--ink)", wordBreak: "break-all", userSelect: "all" }}>
|
|
{hideToken
|
|
? cloneExample.replace(creds.bot.token, `${creds.bot.token.slice(0, 6)}…`)
|
|
: cloneExample}
|
|
</code>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function KV({ k, v }: { k: string; v: React.ReactNode }) {
|
|
return (
|
|
<div style={{ display: "flex", gap: 12, alignItems: "baseline", flexWrap: "wrap" }}>
|
|
<div
|
|
style={{
|
|
fontSize: 10.5,
|
|
fontWeight: 700,
|
|
letterSpacing: "0.06em",
|
|
textTransform: "uppercase",
|
|
color: "var(--muted)",
|
|
minWidth: 80,
|
|
}}
|
|
>
|
|
{k}
|
|
</div>
|
|
<div style={{ color: "var(--ink)" }}>{v}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 bootstrapCmd = `export VIBN_API_KEY=${minted.token}
|
|
curl -sSfL -H "Authorization: Bearer $VIBN_API_KEY" ${APP_BASE}/api/workspaces/${workspace.slug}/bootstrap.sh | sh`;
|
|
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="One-line setup (recommended)"
|
|
description="Paste into a terminal from the root of any repo. It writes .cursor/rules, .cursor/mcp.json, and .env.local for you."
|
|
filename="vibn-bootstrap.sh"
|
|
contents={bootstrapCmd}
|
|
language="bash"
|
|
/>
|
|
<details>
|
|
<summary style={{ cursor: "pointer", fontSize: 12.5, fontWeight: 600, color: "var(--ink)" }}>
|
|
Manual setup — individual files
|
|
</summary>
|
|
<div style={{ marginTop: 10, display: "flex", flexDirection: "column", gap: 14 }}>
|
|
<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>
|
|
</details>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function buildPromptBlock(w: WorkspaceSummary): string {
|
|
return `You are acting on the Vibn workspace "${w.slug}".
|
|
|
|
API base: ${APP_BASE}
|
|
Authorization header: Bearer $VIBN_API_KEY (set in .env.local, never print it)
|
|
|
|
Before doing any git work call:
|
|
GET ${APP_BASE}/api/workspaces/${w.slug}/gitea-credentials
|
|
That returns a workspace-scoped bot username, PAT, and a \`cloneUrlTemplate\`
|
|
with \`{{repo}}\` placeholder. Use that template for all \`git clone\` / push
|
|
remotes. Never use any other git credentials.
|
|
|
|
For deploys, logs, and env vars, use the workspace-scoped Coolify endpoints:
|
|
GET /api/workspaces/${w.slug}/apps
|
|
GET /api/workspaces/${w.slug}/apps/{uuid}
|
|
POST /api/workspaces/${w.slug}/apps/{uuid}/deploy
|
|
GET /api/workspaces/${w.slug}/apps/{uuid}/envs
|
|
PATCH /api/workspaces/${w.slug}/apps/{uuid}/envs body: {"key","value",...}
|
|
DELETE /api/workspaces/${w.slug}/apps/{uuid}/envs?key=FOO
|
|
|
|
All responses are tenant-scoped — a 403 means you're touching another
|
|
workspace's resources, which is not allowed. Stop and ask the user.
|
|
|
|
Workspace identity:
|
|
- Gitea org: ${w.giteaOrg ?? "(unprovisioned)"}
|
|
- Coolify project uuid: ${w.coolifyProjectUuid ?? "(unprovisioned)"}
|
|
- Provision status: ${w.provisionStatus}
|
|
|
|
Rules:
|
|
1. Ask before creating new projects, repos, or deployments.
|
|
2. Prefer PRs over force-pushing main.
|
|
3. Treat VIBN_API_KEY like a password — never echo or commit it.
|
|
`;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
// 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>
|
|
);
|
|
}
|