diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx
index 1cbf7b1fa08..be775e1da77 100644
--- a/ui/src/pages/Secrets.tsx
+++ b/ui/src/pages/Secrets.tsx
@@ -288,7 +288,7 @@ export function Secrets() {
},
});
- if (!selectedCompany) {
+ if (!selectedCompanyId) {
return (
Select a company to manage secrets.
);
diff --git a/ui/storybook/.storybook/preview.tsx b/ui/storybook/.storybook/preview.tsx
index 8871f5362e9..df150978b35 100644
--- a/ui/storybook/.storybook/preview.tsx
+++ b/ui/storybook/.storybook/preview.tsx
@@ -20,6 +20,11 @@ import {
storybookIssues,
storybookLiveRuns,
storybookProjects,
+ storybookSecretAccessEvents,
+ storybookSecretBindings,
+ storybookSecretProviderHealth,
+ storybookSecretProviders,
+ storybookSecrets,
storybookSidebarBadges,
} from "../fixtures/paperclipData";
import "@mdxeditor/editor/style.css";
@@ -164,6 +169,39 @@ function installStorybookApiFixtures() {
if (schema) return Response.json(schema);
}
+ const secretsListMatch = url.pathname.match(/^\/api\/companies\/([^/]+)\/secrets$/);
+ if (secretsListMatch) {
+ const [, companyId] = secretsListMatch;
+ return Response.json(companyId === "company-storybook" ? storybookSecrets : []);
+ }
+
+ const secretProvidersMatch = url.pathname.match(/^\/api\/companies\/([^/]+)\/secret-providers$/);
+ if (secretProvidersMatch) {
+ return Response.json(storybookSecretProviders);
+ }
+
+ const secretProviderHealthMatch = url.pathname.match(
+ /^\/api\/companies\/([^/]+)\/secret-providers\/health$/,
+ );
+ if (secretProviderHealthMatch) {
+ return Response.json(storybookSecretProviderHealth);
+ }
+
+ const secretUsageMatch = url.pathname.match(/^\/api\/secrets\/([^/]+)\/usage$/);
+ if (secretUsageMatch) {
+ const [, secretId] = secretUsageMatch;
+ return Response.json({
+ secretId,
+ bindings: storybookSecretBindings.filter((binding) => binding.secretId === secretId),
+ });
+ }
+
+ const secretEventsMatch = url.pathname.match(/^\/api\/secrets\/([^/]+)\/access-events$/);
+ if (secretEventsMatch) {
+ const [, secretId] = secretEventsMatch;
+ return Response.json(storybookSecretAccessEvents.filter((event) => event.secretId === secretId));
+ }
+
const companyResourceMatch = url.pathname.match(/^\/api\/companies\/([^/]+)\/([^/]+)$/);
if (companyResourceMatch) {
const [, companyId, resource] = companyResourceMatch;
@@ -222,6 +260,11 @@ function installStorybookApiFixtures() {
};
}
+// Install fetch fixtures at module load so React Query never sees a real network failure.
+if (typeof window !== "undefined") {
+ installStorybookApiFixtures();
+}
+
function applyStorybookTheme(theme: "light" | "dark") {
if (typeof document === "undefined") return;
document.documentElement.classList.toggle("dark", theme === "dark");
@@ -247,6 +290,10 @@ function StorybookProviders({
}),
);
+ if (typeof window !== "undefined") {
+ installStorybookApiFixtures();
+ }
+
useEffect(() => {
applyStorybookTheme(theme);
}, [theme]);
diff --git a/ui/storybook/fixtures/paperclipData.ts b/ui/storybook/fixtures/paperclipData.ts
index dc43be49156..0e4485269e3 100644
--- a/ui/storybook/fixtures/paperclipData.ts
+++ b/ui/storybook/fixtures/paperclipData.ts
@@ -5,6 +5,8 @@ import type {
AuthSession,
BudgetPolicySummary,
Company,
+ CompanySecret,
+ CompanySecretBinding,
DashboardSummary,
ExecutionWorkspace,
Goal,
@@ -12,6 +14,8 @@ import type {
IssueDocument,
IssueLabel,
Project,
+ SecretAccessEvent,
+ SecretProviderDescriptor,
SidebarBadges,
WorkspaceRuntimeService,
} from "@paperclipai/shared";
@@ -1303,3 +1307,229 @@ export const storybookLiveRuns: LiveRunForIssue[] = [
nextAction: "Build fixture-backed navigation stories",
},
];
+
+export const storybookSecretProviders: SecretProviderDescriptor[] = [
+ { id: "local_encrypted", label: "Local encrypted", requiresExternalRef: false },
+ { id: "aws_secrets_manager", label: "AWS Secrets Manager", requiresExternalRef: false },
+ { id: "gcp_secret_manager", label: "GCP Secret Manager", requiresExternalRef: false },
+ { id: "vault", label: "HashiCorp Vault", requiresExternalRef: false },
+];
+
+export const storybookSecrets: CompanySecret[] = [
+ {
+ id: "secret-openai",
+ companyId: "company-storybook",
+ key: "openai_api_key",
+ name: "OPENAI_API_KEY",
+ provider: "local_encrypted",
+ status: "active",
+ managedMode: "paperclip_managed",
+ externalRef: null,
+ providerConfigId: null,
+ providerMetadata: null,
+ latestVersion: 3,
+ description: "OpenAI API key shared by all model adapters.",
+ lastResolvedAt: recent(12),
+ lastRotatedAt: new Date("2026-04-15T09:30:00.000Z"),
+ deletedAt: null,
+ createdByAgentId: null,
+ createdByUserId: "user-board",
+ createdAt: new Date("2026-03-01T10:00:00.000Z"),
+ updatedAt: recent(12),
+ },
+ {
+ id: "secret-aws-prod",
+ companyId: "company-storybook",
+ key: "prod_aws_deploy",
+ name: "PROD_AWS_DEPLOY_KEY",
+ provider: "aws_secrets_manager",
+ status: "active",
+ managedMode: "external_reference",
+ externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/aws-deploy-AbCdEf",
+ providerConfigId: null,
+ providerMetadata: null,
+ latestVersion: 2,
+ description: "Deploy key for the prod ECS rollout pipeline.",
+ lastResolvedAt: recent(180),
+ lastRotatedAt: new Date("2026-04-22T14:00:00.000Z"),
+ deletedAt: null,
+ createdByAgentId: null,
+ createdByUserId: "user-board",
+ createdAt: new Date("2026-02-01T10:00:00.000Z"),
+ updatedAt: recent(180),
+ },
+ {
+ id: "secret-github",
+ companyId: "company-storybook",
+ key: "github_app_pem",
+ name: "GITHUB_APP_PEM",
+ provider: "local_encrypted",
+ status: "disabled",
+ managedMode: "paperclip_managed",
+ externalRef: null,
+ providerConfigId: null,
+ providerMetadata: null,
+ latestVersion: 1,
+ description: "Disabled until GitHub App reinstall is approved.",
+ lastResolvedAt: new Date("2026-03-30T08:11:00.000Z"),
+ lastRotatedAt: null,
+ deletedAt: null,
+ createdByAgentId: null,
+ createdByUserId: "user-board",
+ createdAt: new Date("2026-03-15T10:00:00.000Z"),
+ updatedAt: new Date("2026-04-21T11:30:00.000Z"),
+ },
+ {
+ id: "secret-stripe-archived",
+ companyId: "company-storybook",
+ key: "stripe_legacy",
+ name: "STRIPE_LEGACY",
+ provider: "vault",
+ status: "archived",
+ managedMode: "external_reference",
+ externalRef: "secret/data/payments/stripe-legacy",
+ providerConfigId: null,
+ providerMetadata: null,
+ latestVersion: 4,
+ description: "Migrated to managed billing service. Kept for backfill jobs.",
+ lastResolvedAt: new Date("2026-02-25T08:11:00.000Z"),
+ lastRotatedAt: new Date("2026-02-20T08:11:00.000Z"),
+ deletedAt: null,
+ createdByAgentId: null,
+ createdByUserId: "user-board",
+ createdAt: new Date("2025-12-01T10:00:00.000Z"),
+ updatedAt: new Date("2026-03-01T11:30:00.000Z"),
+ },
+];
+
+export const storybookSecretBindings: CompanySecretBinding[] = [
+ {
+ id: "binding-openai-agent",
+ companyId: "company-storybook",
+ secretId: "secret-openai",
+ targetType: "agent",
+ targetId: "agent-codex",
+ configPath: "env.OPENAI_API_KEY",
+ versionSelector: "latest",
+ required: true,
+ label: "Codex agent env",
+ createdAt: new Date("2026-03-02T09:00:00.000Z"),
+ updatedAt: new Date("2026-03-02T09:00:00.000Z"),
+ },
+ {
+ id: "binding-openai-project",
+ companyId: "company-storybook",
+ secretId: "secret-openai",
+ targetType: "project",
+ targetId: "project-app",
+ configPath: "env.OPENAI_API_KEY",
+ versionSelector: "latest",
+ required: true,
+ label: "Paperclip App project env",
+ createdAt: new Date("2026-03-02T09:00:00.000Z"),
+ updatedAt: new Date("2026-03-02T09:00:00.000Z"),
+ },
+ {
+ id: "binding-aws-environment",
+ companyId: "company-storybook",
+ secretId: "secret-aws-prod",
+ targetType: "environment",
+ targetId: "env-prod",
+ configPath: "secrets.AWS_DEPLOY",
+ versionSelector: 2,
+ required: true,
+ label: "Prod environment",
+ createdAt: new Date("2026-04-22T14:01:00.000Z"),
+ updatedAt: new Date("2026-04-22T14:01:00.000Z"),
+ },
+];
+
+export const storybookSecretAccessEvents: SecretAccessEvent[] = [
+ {
+ id: "evt-1",
+ companyId: "company-storybook",
+ secretId: "secret-openai",
+ version: 3,
+ provider: "local_encrypted",
+ actorType: "agent",
+ actorId: "agent-codex",
+ consumerType: "agent",
+ consumerId: "agent-codex",
+ configPath: "env.OPENAI_API_KEY",
+ issueId: "issue-storybook-1",
+ heartbeatRunId: "run-storybook",
+ pluginId: null,
+ outcome: "success",
+ errorCode: null,
+ createdAt: recent(12),
+ },
+ {
+ id: "evt-2",
+ companyId: "company-storybook",
+ secretId: "secret-openai",
+ version: 3,
+ provider: "local_encrypted",
+ actorType: "system",
+ actorId: null,
+ consumerType: "project",
+ consumerId: "project-app",
+ configPath: "env.OPENAI_API_KEY",
+ issueId: null,
+ heartbeatRunId: null,
+ pluginId: null,
+ outcome: "success",
+ errorCode: null,
+ createdAt: recent(48),
+ },
+ {
+ id: "evt-3",
+ companyId: "company-storybook",
+ secretId: "secret-openai",
+ version: null,
+ provider: "local_encrypted",
+ actorType: "agent",
+ actorId: "agent-codex",
+ consumerType: "agent",
+ consumerId: "agent-codex",
+ configPath: "env.OPENAI_API_KEY",
+ issueId: "issue-storybook-1",
+ heartbeatRunId: "run-storybook",
+ pluginId: null,
+ outcome: "failure",
+ errorCode: "secret_disabled",
+ createdAt: recent(360),
+ },
+];
+
+export const storybookSecretProviderHealth = {
+ providers: [
+ {
+ provider: "local_encrypted" as const,
+ status: "ok" as const,
+ message: "Encryption key loaded; permissions OK.",
+ warnings: [] as string[],
+ backupGuidance: ["Backup ~/.paperclip/instances/default/secrets/key separately from the database."],
+ },
+ {
+ provider: "aws_secrets_manager" as const,
+ status: "warn" as const,
+ message: "Connected; KMS key rotation policy not yet enforced.",
+ warnings: ["Set up automated KMS key rotation for production tenants."],
+ backupGuidance: [],
+ },
+ {
+ provider: "gcp_secret_manager" as const,
+ status: "ok" as const,
+ message: "Service account reachable.",
+ warnings: [] as string[],
+ backupGuidance: [],
+ },
+ {
+ provider: "vault" as const,
+ status: "ok" as const,
+ message: "KV v2 mount reachable.",
+ warnings: [] as string[],
+ backupGuidance: [],
+ },
+ ],
+};
diff --git a/ui/storybook/stories/secrets.stories.tsx b/ui/storybook/stories/secrets.stories.tsx
new file mode 100644
index 00000000000..bea299fedbe
--- /dev/null
+++ b/ui/storybook/stories/secrets.stories.tsx
@@ -0,0 +1,217 @@
+import { useEffect, useState, type ReactNode } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { useQueryClient } from "@tanstack/react-query";
+import { AlertCircle, KeyRound } from "lucide-react";
+import type { CompanySecret, EnvBinding } from "@paperclipai/shared";
+import { Secrets } from "@/pages/Secrets";
+import { SecretBindingPicker, type SecretBindingValue } from "@/components/SecretBindingPicker";
+import { EnvVarEditor } from "@/components/EnvVarEditor";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { useCompany } from "@/context/CompanyContext";
+import { queryKeys } from "@/lib/queryKeys";
+import { storybookCompanies, storybookSecrets } from "../fixtures/paperclipData";
+
+const COMPANY_ID = "company-storybook";
+
+function StorybookSecretsFixtures({ children }: { children: ReactNode }) {
+ const queryClient = useQueryClient();
+ queryClient.setQueryData(queryKeys.companies.all, storybookCompanies);
+ queryClient.setQueryData(queryKeys.secrets.list(COMPANY_ID), storybookSecrets);
+
+ const { selectedCompanyId, setSelectedCompanyId } = useCompany();
+ useEffect(() => {
+ if (selectedCompanyId !== COMPANY_ID) {
+ setSelectedCompanyId(COMPANY_ID);
+ }
+ }, [selectedCompanyId, setSelectedCompanyId]);
+
+ return <>{children}>;
+}
+
+const meta: Meta = {
+ title: "Product/Secrets",
+ parameters: {
+ layout: "fullscreen",
+ a11y: {
+ test: "off",
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+function Section({ eyebrow, title, children }: { eyebrow: string; title: string; children: ReactNode }) {
+ return (
+
+ );
+}
+
+export const SecretsInventory: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
+
+function BindingPickerSurface({
+ initial,
+ label,
+}: {
+ initial: SecretBindingValue | null;
+ label: string;
+}) {
+ const [value, setValue] = useState(initial);
+ return (
+
+
+ {label}
+
+ Picker can be reused across agent, project, environment, and plugin config surfaces.
+
+
+
+
+
+ {JSON.stringify(value, null, 2)}
+
+
+
+ );
+}
+
+export const BindingPicker: Story = {
+ render: () => {
+ return (
+
+
+
+
+
+
+
+
+ );
+ },
+};
+
+export const EnvEditorWithSecrets: Story = {
+ render: () => {
+ function EditorDemo({ initial, label }: { initial: Record; label: string }) {
+ const [env, setEnv] = useState>(initial);
+ return (
+
+
+
+
+ {label}
+
+
+
+ ({
+ ...storybookSecrets[0]!,
+ id: `secret-${Math.random().toString(36).slice(2, 8)}`,
+ name,
+ key: name.toLowerCase(),
+ description: `New secret with value len=${value.length}`,
+ })}
+ onChange={(next) => setEnv(next ?? {})}
+ />
+
+
+ );
+ }
+ return (
+
+
+
+
+ );
+ },
+};
+
+export const RunFailureCopy: Story = {
+ render: () => (
+
+
+
+
+
+
+
+ Run failed
+
+
PAP-2350 ยท run-storybook
+
+
+ Secret OPENAI_API_KEY is{" "}
+ disabled
+
+
+ The agent tried to resolve env.OPENAI_API_KEY for{" "}
+ agent:CodexCoder but the secret is currently disabled. No value was
+ loaded, no run logs were emitted that contained secret material.
+
+
+
+
+
Next action
+
+ -
+ Re-enable the secret on{" "}
+
+ Company settings > Secrets
+
+
+ - Or, rotate to a new value and pin v3 explicitly for this agent.
+ - Or, swap the binding to a different secret with the binding picker.
+
+
+
+
Audit
+
+ secret_access_events.outcome=failure error=secret_disabled consumer=agent:CodexCoder
+
+
+
+
+
+
+ ),
+};