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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-07 18:59:05 -05:00
1 parent af5451711d
commit 90670ea4eb
4 files changed
+495 -1

No files matched your search

+1 -1
View File
@@ -288,7 +288,7 @@ export function Secrets() {
},
});
if (!selectedCompany) {
if (!selectedCompanyId) {
return (
<div className="p-6 text-sm text-muted-foreground">Select a company to manage secrets.</div>
);
+47
View File
@@ -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]);
+230
View File
@@ -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: [],
},
],
};
+217
View File
@@ -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 (
<section className="border-b border-border pb-8 last:border-b-0">
<header className="mb-3 px-6 pt-6">
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">{eyebrow}</p>
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
</header>
<div className="px-6">{children}</div>
</section>
);
}
export const SecretsInventory: Story = {
render: () => (
<StorybookSecretsFixtures>
<div className="h-screen w-full bg-background">
<Secrets />
</div>
</StorybookSecretsFixtures>
),
};
function BindingPickerSurface({
initial,
label,
}: {
initial: SecretBindingValue | null;
label: string;
}) {
const [value, setValue] = useState<SecretBindingValue | null>(initial);
return (
<Card className="w-96">
<CardHeader>
<CardTitle className="text-sm">{label}</CardTitle>
<CardDescription className="text-xs">
Picker can be reused across agent, project, environment, and plugin config surfaces.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<SecretBindingPicker value={value} onChange={setValue} />
<pre className="rounded bg-muted/40 p-2 text-[11px] font-mono">
{JSON.stringify(value, null, 2)}
</pre>
</CardContent>
</Card>
);
}
export const BindingPicker: Story = {
render: () => {
return (
<StorybookSecretsFixtures>
<div className="grid grid-cols-1 gap-6 p-6 md:grid-cols-2">
<BindingPickerSurface initial={null} label="Empty state" />
<BindingPickerSurface
initial={{ secretId: storybookSecrets[0]!.id, version: "latest" }}
label="Bound to active secret"
/>
<BindingPickerSurface
initial={{ secretId: storybookSecrets[2]!.id, version: "latest" }}
label="Bound but disabled"
/>
<BindingPickerSurface
initial={{ secretId: "missing-id", version: "latest" }}
label="Bound to missing secret"
/>
</div>
</StorybookSecretsFixtures>
);
},
};
export const EnvEditorWithSecrets: Story = {
render: () => {
function EditorDemo({ initial, label }: { initial: Record<string, EnvBinding>; label: string }) {
const [env, setEnv] = useState<Record<string, EnvBinding>>(initial);
return (
<Card className="w-full max-w-2xl">
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
<KeyRound className="h-3.5 w-3.5" />
{label}
</CardTitle>
</CardHeader>
<CardContent>
<EnvVarEditor
value={env}
secrets={storybookSecrets as CompanySecret[]}
onCreateSecret={async (name, value) => ({
...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 ?? {})}
/>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6 p-6">
<EditorDemo
label="Healthy bindings"
initial={{
OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai", version: "latest" },
STAGE: { type: "plain", value: "production" },
}}
/>
<EditorDemo
label="Bindings with attention required"
initial={{
OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai", version: 2 },
GITHUB_APP_PEM: { type: "secret_ref", secretId: "secret-github", version: "latest" },
ABANDONED: { type: "secret_ref", secretId: "missing-id", version: "latest" },
}}
/>
</div>
);
},
};
export const RunFailureCopy: Story = {
render: () => (
<div className="space-y-4 p-6">
<Section eyebrow="Run failure" title="Missing or disabled secret blocks the run">
<Card className="border-destructive/40 bg-destructive/5">
<CardHeader className="space-y-1">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-destructive" />
<Badge variant="outline" className="border-destructive/40 text-destructive">
Run failed
</Badge>
<span className="text-xs font-mono text-muted-foreground">PAP-2350 · run-storybook</span>
</div>
<CardTitle className="text-sm">
Secret <span className="font-mono">OPENAI_API_KEY</span> is{" "}
<span className="font-medium text-destructive">disabled</span>
</CardTitle>
<CardDescription className="text-xs">
The agent tried to resolve <span className="font-mono">env.OPENAI_API_KEY</span> for{" "}
<span className="font-mono">agent:CodexCoder</span> but the secret is currently disabled. No value was
loaded, no run logs were emitted that contained secret material.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-xs">
<div>
<p className="text-muted-foreground">Next action</p>
<ul className="list-disc pl-4 space-y-0.5">
<li>
Re-enable the secret on{" "}
<a className="text-primary underline" href="/PAP/company/settings/secrets">
Company settings &gt; Secrets
</a>
</li>
<li>Or, rotate to a new value and pin v3 explicitly for this agent.</li>
<li>Or, swap the binding to a different secret with the binding picker.</li>
</ul>
</div>
<div>
<p className="text-muted-foreground">Audit</p>
<p className="font-mono text-[11px]">
secret_access_events.outcome=failure error=secret_disabled consumer=agent:CodexCoder
</p>
</div>
</CardContent>
</Card>
</Section>
</div>
),
};