From 90670ea4eb62e1d89ebf301227a53f20c2539fc3 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 26 Apr 2026 17:09:30 -0500 Subject: [PATCH] Add Secrets storybook story and fixtures Adds product/secrets story coverage for the inventory, binding picker (empty/active/disabled/missing variants), env editor with secret bindings, and the run-failure copy. Wires fetch fixtures and provider health response into the storybook preview so the page renders against canned data. Fall through to selectedCompanyId so the page shows when the company is selected even before the companies list resolves. Co-Authored-By: Paperclip --- ui/src/pages/Secrets.tsx | 2 +- ui/storybook/.storybook/preview.tsx | 47 +++++ ui/storybook/fixtures/paperclipData.ts | 230 +++++++++++++++++++++++ ui/storybook/stories/secrets.stories.tsx | 217 +++++++++++++++++++++ 4 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 ui/storybook/stories/secrets.stories.tsx 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 ( +
+
+

{eyebrow}

+

{title}

+
+
{children}
+
+ ); +} + +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 +

+
+
+
+
+
+ ), +};