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 +

+
+
+
+
+
+ ), +};