mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
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:
1 parent
af5451711d
commit
90670ea4eb
4 files changed
+495
-1
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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 > 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>
|
||||
),
|
||||
};
|
||||
Reference in new issue
Block a user