From 191491a57f13ed87afbefa58c05c86d14c3daac4 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Sat, 2 May 2026 17:21:10 -0400 Subject: [PATCH 1/2] feat(secrets): company secrets management UI New /company/settings/secrets page with create, rotate, edit, and delete flows. Adds a Secrets entry to the company settings sidebar (and tab nav). Each row shows name, description, version, and time since last rotation; per-row actions open dialogs for rotate (textarea for new value), edit (name + description), and delete (confirmation). Server-side adds secretService.usages() to enumerate agents that reference a secret via env bindings, and rejects deletion when any usage exists. The delete dialog reads the blocking usage list from the error body and renders it inline so the user knows which agents to detach first. --- server/src/routes/secrets.ts | 13 + server/src/services/secrets.ts | 37 ++ ui/src/App.tsx | 2 + ui/src/api/secrets.ts | 2 + ui/src/components/CompanySettingsSidebar.tsx | 3 +- .../access/CompanySettingsNav.test.tsx | 1 + .../components/access/CompanySettingsNav.tsx | 5 + ui/src/pages/CompanySecrets.tsx | 512 ++++++++++++++++++ 8 files changed, 574 insertions(+), 1 deletion(-) create mode 100644 ui/src/pages/CompanySecrets.tsx 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..8d752d4d 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"; @@ -68,6 +68,7 @@ export function CompanySettingsSidebar() { end /> + diff --git a/ui/src/components/access/CompanySettingsNav.test.tsx b/ui/src/components/access/CompanySettingsNav.test.tsx index be3daa81..224237a7 100644 --- a/ui/src/components/access/CompanySettingsNav.test.tsx +++ b/ui/src/components/access/CompanySettingsNav.test.tsx @@ -82,6 +82,7 @@ describe("CompanySettingsNav", () => { { value: "environments", label: "Environments" }, { value: "access", label: "Access" }, { value: "invites", label: "Invites" }, + { value: "secrets", label: "Secrets" }, ], }), ); diff --git a/ui/src/components/access/CompanySettingsNav.tsx b/ui/src/components/access/CompanySettingsNav.tsx index 82fb2245..a050f078 100644 --- a/ui/src/components/access/CompanySettingsNav.tsx +++ b/ui/src/components/access/CompanySettingsNav.tsx @@ -7,6 +7,7 @@ const items = [ { value: "environments", label: "Environments", href: "/company/settings/environments" }, { value: "access", label: "Access", href: "/company/settings/access" }, { value: "invites", label: "Invites", href: "/company/settings/invites" }, + { value: "secrets", label: "Secrets", href: "/company/settings/secrets" }, ] as const; type CompanySettingsTab = (typeof items)[number]["value"]; @@ -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 + /> +
+
+ +