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>,