diff --git a/server/src/routes/secrets.ts b/server/src/routes/secrets.ts index 1ea99ee1..c7748079 100644 --- a/server/src/routes/secrets.ts +++ b/server/src/routes/secrets.ts @@ -132,6 +132,19 @@ export function secretRoutes(db: Db) { res.json(updated); }); + router.get("/secrets/:id/usages", async (req, res) => { + assertBoard(req); + const id = req.params.id as string; + const existing = await svc.getById(id); + if (!existing) { + res.status(404).json({ error: "Secret not found" }); + return; + } + assertCompanyAccess(req, existing.companyId); + const agents = await svc.usages(existing.companyId, id); + res.json({ agents }); + }); + router.delete("/secrets/:id", async (req, res) => { assertBoard(req); const id = req.params.id as string; diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index d288d4b3..77155de7 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -5,6 +5,7 @@ import type { AgentEnvConfig, EnvBinding, SecretProvider } from "@paperclipai/sh import { envBindingSchema } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; import { getSecretProvider, listSecretProviders } from "../secrets/provider-registry.js"; +import { agentService } from "./agents.js"; const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; const SENSITIVE_ENV_KEY_RE = @@ -44,6 +45,8 @@ export function secretService(db: Db) { fieldPath?: string; }; + type SecretUsageAgent = { id: string; name: string; envKeys: string[] }; + async function getById(id: string) { return db .select() @@ -52,6 +55,30 @@ export function secretService(db: Db) { .then((rows) => rows[0] ?? null); } + async function usages(companyId: string, secretId: string): Promise { + const agents = agentService(db); + const allAgents = await agents.list(companyId); + const out: SecretUsageAgent[] = []; + for (const agent of allAgents) { + const config = asRecord(agent.adapterConfig); + if (!config) continue; + const env = asRecord(config.env); + if (!env) continue; + const matchingKeys: string[] = []; + for (const [key, rawBinding] of Object.entries(env)) { + const binding = asRecord(rawBinding); + if (!binding) continue; + if (binding.type === "secret_ref" && binding.secretId === secretId) { + matchingKeys.push(key); + } + } + if (matchingKeys.length > 0) { + out.push({ id: agent.id, name: agent.name, envKeys: matchingKeys }); + } + } + return out; + } + async function getByName(companyId: string, name: string) { return db .select() @@ -287,10 +314,20 @@ export function secretService(db: Db) { remove: async (secretId: string) => { const secret = await getById(secretId); if (!secret) return null; + const used = await usages(secret.companyId, secretId); + if (used.length > 0) { + const names = used.map((agent) => agent.name).sort((left, right) => left.localeCompare(right)); + throw unprocessable( + `Cannot delete secret "${secret.name}" while it is still used by ${names.join(", ")}. Detach it from those agents first.`, + { secretId, usedByAgents: used }, + ); + } await db.delete(companySecrets).where(eq(companySecrets.id, secretId)); return secret; }, + usages, + normalizeAdapterConfigForPersistence: async ( companyId: string, adapterConfig: Record, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index e29e6efe..9b720075 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -30,6 +30,7 @@ import { CompanySettings } from "./pages/CompanySettings"; import { CompanyEnvironments } from "./pages/CompanyEnvironments"; import { CompanyAccess } from "./pages/CompanyAccess"; import { CompanyInvites } from "./pages/CompanyInvites"; +import { CompanySecrets } from "./pages/CompanySecrets"; import { CompanySkills } from "./pages/CompanySkills"; import { CompanyExport } from "./pages/CompanyExport"; import { CompanyImport } from "./pages/CompanyImport"; @@ -68,6 +69,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/secrets.ts b/ui/src/api/secrets.ts index b39aa560..397bfd5f 100644 --- a/ui/src/api/secrets.ts +++ b/ui/src/api/secrets.ts @@ -22,4 +22,6 @@ export const secretsApi = { data: { name?: string; description?: string | null; externalRef?: string | null }, ) => api.patch(`/secrets/${id}`, data), remove: (id: string) => api.delete<{ ok: true }>(`/secrets/${id}`), + usages: (id: string) => + api.get<{ agents: { id: string; name: string; envKeys: string[] }[] }>(`/secrets/${id}/usages`), }; diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index f0a2b378..57ddcf06 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { ChevronLeft, MailPlus, MonitorCog, Settings, Shield, SlidersHorizontal } from "lucide-react"; +import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Settings, Shield, SlidersHorizontal } from "lucide-react"; import { sidebarBadgesApi } from "@/api/sidebarBadges"; import { ApiError } from "@/api/client"; import { Link } from "@/lib/router"; @@ -60,6 +60,7 @@ export function CompanySettingsSidebar() { icon={MonitorCog} end /> + { items: [ { value: "general", label: "General" }, { value: "environments", label: "Environments" }, + { value: "secrets", label: "Secrets" }, { value: "access", label: "Access" }, { value: "invites", label: "Invites" }, ], diff --git a/ui/src/components/access/CompanySettingsNav.tsx b/ui/src/components/access/CompanySettingsNav.tsx index 82fb2245..01ebc22e 100644 --- a/ui/src/components/access/CompanySettingsNav.tsx +++ b/ui/src/components/access/CompanySettingsNav.tsx @@ -5,6 +5,7 @@ import { useLocation, useNavigate } from "@/lib/router"; const items = [ { value: "general", label: "General", href: "/company/settings" }, { value: "environments", label: "Environments", href: "/company/settings/environments" }, + { value: "secrets", label: "Secrets", href: "/company/settings/secrets" }, { value: "access", label: "Access", href: "/company/settings/access" }, { value: "invites", label: "Invites", href: "/company/settings/invites" }, ] as const; @@ -24,6 +25,10 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab { return "invites"; } + if (pathname.includes("/company/settings/secrets")) { + return "secrets"; + } + return "general"; } diff --git a/ui/src/pages/CompanySecrets.tsx b/ui/src/pages/CompanySecrets.tsx new file mode 100644 index 00000000..56ec4354 --- /dev/null +++ b/ui/src/pages/CompanySecrets.tsx @@ -0,0 +1,512 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { CompanySecret } from "@paperclipai/shared"; +import { KeyRound, Pencil, RefreshCw, Trash2 } from "lucide-react"; +import { ApiError } from "@/api/client"; +import { secretsApi } from "@/api/secrets"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { useBreadcrumbs } from "@/context/BreadcrumbContext"; +import { useCompany } from "@/context/CompanyContext"; +import { useToast } from "@/context/ToastContext"; +import { cn, relativeTime } from "@/lib/utils"; + +type DialogMode = + | { kind: "closed" } + | { kind: "create" } + | { kind: "rotate"; secret: CompanySecret } + | { kind: "edit"; secret: CompanySecret } + | { kind: "delete"; secret: CompanySecret }; + +const COMPANY_SECRETS_QUERY_KEY = "company-secrets"; + +export function CompanySecrets() { + const { selectedCompany, selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const { pushToast } = useToast(); + const queryClient = useQueryClient(); + const [dialog, setDialog] = useState({ kind: "closed" }); + + useEffect(() => { + setBreadcrumbs([ + { label: selectedCompany?.name ?? "Company", href: "/dashboard" }, + { label: "Settings", href: "/company/settings" }, + { label: "Secrets" }, + ]); + }, [selectedCompany?.name, setBreadcrumbs]); + + const { data: secrets, isLoading } = useQuery({ + queryKey: selectedCompanyId + ? [COMPANY_SECRETS_QUERY_KEY, selectedCompanyId] + : [COMPANY_SECRETS_QUERY_KEY, "none"], + queryFn: () => secretsApi.list(selectedCompanyId!), + enabled: Boolean(selectedCompanyId), + }); + + function invalidateList() { + if (!selectedCompanyId) return; + queryClient.invalidateQueries({ + queryKey: [COMPANY_SECRETS_QUERY_KEY, selectedCompanyId], + }); + } + + function handleApiError(error: unknown, fallback: string) { + const message = error instanceof Error ? error.message : fallback; + pushToast({ tone: "error", title: fallback, body: message }); + } + + return ( +
+
+
+ +

Company Secrets

+
+

+ Encrypted values that agents can reference from environment variables. + Rotate to change a secret's value; delete to remove it from the company + library. +

+
+ +
+

+ {secrets?.length ?? 0} secret{secrets?.length === 1 ? "" : "s"} +

+ +
+ +
+ {isLoading ? ( +
Loading…
+ ) : !secrets || secrets.length === 0 ? ( +
+ No secrets yet. Use New secret to + create one, or seal a plain env var from the agent configuration page. +
+ ) : ( +
    + {secrets.map((secret) => ( +
  • +
    +
    {secret.name}
    + {secret.description ? ( +

    + {secret.description} +

    + ) : null} +

    + v{secret.latestVersion} · last rotated{" "} + {relativeTime(secret.updatedAt)} +

    +
    +
    + + + +
    +
  • + ))} +
+ )} +
+ + {dialog.kind === "create" && selectedCompanyId ? ( + setDialog({ kind: "closed" })} + onCreated={(name) => { + pushToast({ tone: "success", title: `Secret "${name}" created` }); + invalidateList(); + setDialog({ kind: "closed" }); + }} + onError={(error) => handleApiError(error, "Failed to create secret")} + /> + ) : null} + + {dialog.kind === "rotate" ? ( + setDialog({ kind: "closed" })} + onRotated={() => { + pushToast({ + tone: "success", + title: `Secret "${dialog.secret.name}" rotated`, + }); + invalidateList(); + setDialog({ kind: "closed" }); + }} + onError={(error) => handleApiError(error, "Failed to rotate secret")} + /> + ) : null} + + {dialog.kind === "edit" ? ( + setDialog({ kind: "closed" })} + onSaved={(name) => { + pushToast({ tone: "success", title: `Secret "${name}" updated` }); + invalidateList(); + setDialog({ kind: "closed" }); + }} + onError={(error) => handleApiError(error, "Failed to update secret")} + /> + ) : null} + + {dialog.kind === "delete" ? ( + setDialog({ kind: "closed" })} + onDeleted={() => { + pushToast({ + tone: "success", + title: `Secret "${dialog.secret.name}" deleted`, + }); + invalidateList(); + setDialog({ kind: "closed" }); + }} + /> + ) : null} +
+ ); +} + +function CreateSecretDialog({ + companyId, + onClose, + onCreated, + onError, +}: { + companyId: string; + onClose: () => void; + onCreated: (name: string) => void; + onError: (error: unknown) => void; +}) { + const [name, setName] = useState(""); + const [value, setValue] = useState(""); + const [description, setDescription] = useState(""); + + const create = useMutation({ + mutationFn: () => + secretsApi.create(companyId, { + name: name.trim(), + value, + description: description.trim() || null, + }), + onSuccess: () => onCreated(name.trim()), + onError, + }); + + return ( + !open && onClose()}> + + + New secret + + Stored encrypted; visible to agents only when referenced from a + secret-typed environment variable. + + +
+
+ + setName(event.target.value)} + placeholder="API_TOKEN" + autoFocus + /> +
+
+ +