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.
This commit is contained in:
2026-05-02 17:21:10 -04:00
parent 7e2517935c
commit 191491a57f
8 changed files with 574 additions and 1 deletions
+13
View File
@@ -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;
+37
View File
@@ -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<SecretUsageAgent[]> {
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<string, unknown>,
+2
View File
@@ -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() {
<Route path="company/settings/environments" element={<CompanyEnvironments />} />
<Route path="company/settings/access" element={<CompanyAccess />} />
<Route path="company/settings/invites" element={<CompanyInvites />} />
<Route path="company/settings/secrets" element={<CompanySecrets />} />
<Route path="company/export/*" element={<CompanyExport />} />
<Route path="company/import" element={<CompanyImport />} />
<Route path="skills/*" element={<CompanySkills />} />
+2
View File
@@ -22,4 +22,6 @@ export const secretsApi = {
data: { name?: string; description?: string | null; externalRef?: string | null },
) => api.patch<CompanySecret>(`/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`),
};
+2 -1
View File
@@ -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
/>
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
<SidebarNavItem to="/company/settings/secrets" label="Secrets" icon={KeyRound} end />
</div>
</nav>
</aside>
@@ -82,6 +82,7 @@ describe("CompanySettingsNav", () => {
{ value: "environments", label: "Environments" },
{ value: "access", label: "Access" },
{ value: "invites", label: "Invites" },
{ value: "secrets", label: "Secrets" },
],
}),
);
@@ -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";
}
+512
View File
@@ -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<DialogMode>({ 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 (
<div
className="max-w-5xl space-y-6"
data-testid="company-settings-secrets-section"
>
<div className="space-y-2">
<div className="flex items-center gap-2">
<KeyRound className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Company Secrets</h1>
</div>
<p className="max-w-3xl text-sm text-muted-foreground">
Encrypted values that agents can reference from environment variables.
Rotate to change a secret's value; delete to remove it from the company
library.
</p>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
{secrets?.length ?? 0} secret{secrets?.length === 1 ? "" : "s"}
</p>
<Button
size="sm"
onClick={() => setDialog({ kind: "create" })}
disabled={!selectedCompanyId}
>
New secret
</Button>
</div>
<div className="rounded-md border border-border">
{isLoading ? (
<div className="px-4 py-6 text-sm text-muted-foreground">Loading…</div>
) : !secrets || secrets.length === 0 ? (
<div className="px-4 py-6 text-sm text-muted-foreground">
No secrets yet. Use <span className="font-medium">New secret</span> to
create one, or seal a plain env var from the agent configuration page.
</div>
) : (
<ul className="divide-y divide-border">
{secrets.map((secret) => (
<li
key={secret.id}
className="flex items-start gap-4 px-4 py-3 text-sm"
>
<div className="min-w-0 flex-1">
<div className="font-mono font-medium">{secret.name}</div>
{secret.description ? (
<p className="mt-0.5 text-xs text-muted-foreground">
{secret.description}
</p>
) : null}
<p className="mt-0.5 text-xs text-muted-foreground/70">
v{secret.latestVersion} · last rotated{" "}
{relativeTime(secret.updatedAt)}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setDialog({ kind: "rotate", secret })}
title="Rotate value"
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDialog({ kind: "edit", secret })}
title="Edit name and description"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDialog({ kind: "delete", secret })}
title="Delete secret"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</li>
))}
</ul>
)}
</div>
{dialog.kind === "create" && selectedCompanyId ? (
<CreateSecretDialog
companyId={selectedCompanyId}
onClose={() => 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" ? (
<RotateSecretDialog
secret={dialog.secret}
onClose={() => 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" ? (
<EditSecretDialog
secret={dialog.secret}
onClose={() => 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" ? (
<DeleteSecretDialog
secret={dialog.secret}
onClose={() => setDialog({ kind: "closed" })}
onDeleted={() => {
pushToast({
tone: "success",
title: `Secret "${dialog.secret.name}" deleted`,
});
invalidateList();
setDialog({ kind: "closed" });
}}
/>
) : null}
</div>
);
}
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 (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New secret</DialogTitle>
<DialogDescription>
Stored encrypted; visible to agents only when referenced from a
secret-typed environment variable.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<label className="text-xs font-medium">Name</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="API_TOKEN"
autoFocus
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium">Value</label>
<Textarea
rows={4}
className="font-mono"
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="paste secret value"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium">Description (optional)</label>
<Input
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="What this secret is used for"
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
onClick={() => create.mutate()}
disabled={!name.trim() || !value || create.isPending}
>
{create.isPending ? "Creating…" : "Create secret"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function RotateSecretDialog({
secret,
onClose,
onRotated,
onError,
}: {
secret: CompanySecret;
onClose: () => void;
onRotated: () => void;
onError: (error: unknown) => void;
}) {
const [value, setValue] = useState("");
const rotate = useMutation({
mutationFn: () => secretsApi.rotate(secret.id, { value }),
onSuccess: onRotated,
onError,
});
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Rotate "{secret.name}"</DialogTitle>
<DialogDescription>
Stores a new version. Agents that reference this secret will pick up
the new value on their next run.
</DialogDescription>
</DialogHeader>
<div className="space-y-1">
<label className="text-xs font-medium">New value</label>
<Textarea
rows={5}
className="font-mono"
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="paste new value"
autoFocus
/>
<p className="text-xs text-muted-foreground">
Current version: v{secret.latestVersion}
</p>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
onClick={() => rotate.mutate()}
disabled={!value || rotate.isPending}
>
{rotate.isPending ? "Rotating…" : "Rotate"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function EditSecretDialog({
secret,
onClose,
onSaved,
onError,
}: {
secret: CompanySecret;
onClose: () => void;
onSaved: (name: string) => void;
onError: (error: unknown) => void;
}) {
const [name, setName] = useState(secret.name);
const [description, setDescription] = useState(secret.description ?? "");
const save = useMutation({
mutationFn: () =>
secretsApi.update(secret.id, {
name: name.trim(),
description: description.trim() || null,
}),
onSuccess: () => onSaved(name.trim()),
onError,
});
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit secret</DialogTitle>
<DialogDescription>
Changes the metadata. The stored value is unchanged — use Rotate to
replace the value.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<label className="text-xs font-medium">Name</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
autoFocus
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium">Description</label>
<Input
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
onClick={() => save.mutate()}
disabled={!name.trim() || save.isPending}
>
{save.isPending ? "Saving…" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
type DeleteUsage = { id: string; name: string; envKeys: string[] };
function DeleteSecretDialog({
secret,
onClose,
onDeleted,
}: {
secret: CompanySecret;
onClose: () => void;
onDeleted: () => void;
}) {
const [blockedBy, setBlockedBy] = useState<DeleteUsage[] | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const remove = useMutation({
mutationFn: () => secretsApi.remove(secret.id),
onSuccess: onDeleted,
onError: (error: unknown) => {
if (error instanceof ApiError) {
const body = error.body as
| { details?: { usedByAgents?: DeleteUsage[] } }
| null
| undefined;
const usedByAgents = body?.details?.usedByAgents;
if (Array.isArray(usedByAgents) && usedByAgents.length > 0) {
setBlockedBy(usedByAgents);
setErrorMessage(error.message);
return;
}
}
setErrorMessage(
error instanceof Error ? error.message : "Failed to delete secret",
);
},
});
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete "{secret.name}"</DialogTitle>
<DialogDescription>
This permanently deletes the secret and all of its versions. Agents
that reference it will fail until the binding is removed.
</DialogDescription>
</DialogHeader>
{blockedBy && blockedBy.length > 0 ? (
<div className="space-y-2 rounded-md border border-destructive/50 bg-destructive/5 px-3 py-3 text-xs">
<p className="font-medium text-destructive">
Cannot delete — still referenced by:
</p>
<ul className="space-y-1 text-muted-foreground">
{blockedBy.map((agent) => (
<li key={agent.id}>
<span className="font-medium text-foreground">{agent.name}</span>{" "}
({agent.envKeys.join(", ")})
</li>
))}
</ul>
<p className="text-muted-foreground">
Detach this secret from those agents' environment variables first.
</p>
</div>
) : errorMessage ? (
<p className={cn("text-xs text-destructive")}>{errorMessage}</p>
) : null}
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
{blockedBy ? "Close" : "Cancel"}
</Button>
{!blockedBy ? (
<Button
variant="destructive"
onClick={() => {
setErrorMessage(null);
remove.mutate();
}}
disabled={remove.isPending}
>
{remove.isPending ? "Deleting…" : "Delete"}
</Button>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
);
}