diff --git a/ui/src/pages/secrets/MyUserSecretsTab.tsx b/ui/src/pages/secrets/MyUserSecretsTab.tsx index 52312c8ff45..526743160a3 100644 --- a/ui/src/pages/secrets/MyUserSecretsTab.tsx +++ b/ui/src/pages/secrets/MyUserSecretsTab.tsx @@ -61,7 +61,8 @@ export function MyUserSecretsTab({ companyId }: { companyId: string }) { {missingCount > 0 ? ( {" "} - {missingCount} required secret{missingCount === 1 ? "" : "s"} still need your value. + {missingCount} required secret{missingCount === 1 ? " still needs" : "s still need"} your + value. ) : null}

diff --git a/ui/storybook/stories/user-secrets.stories.tsx b/ui/storybook/stories/user-secrets.stories.tsx new file mode 100644 index 00000000000..ff674194da9 --- /dev/null +++ b/ui/storybook/stories/user-secrets.stories.tsx @@ -0,0 +1,209 @@ +import { useEffect, type ReactNode } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useQueryClient } from "@tanstack/react-query"; +import type { + CompanySecret, + EnvBinding, + UserSecretCoverageSummary, + UserSecretDefinition, +} from "@paperclipai/shared"; +import { MemoryRouter } from "react-router-dom"; +import { MyUserSecretsTab } from "@/pages/secrets/MyUserSecretsTab"; +import { UserSecretDefinitionsTab } from "@/pages/secrets/UserSecretDefinitionsTab"; +import { MissingUserSecretsBanner } from "@/pages/secrets/MissingUserSecretsBanner"; +import { EnvVarEditor } from "@/components/EnvVarEditor"; +import type { MyUserSecretEntry } from "@/api/secrets"; +import { useCompany } from "@/context/CompanyContext"; +import { queryKeys } from "@/lib/queryKeys"; + +const COMPANY_ID = "company-storybook"; + +if (typeof window !== "undefined") { + window.localStorage.setItem("paperclip.selectedCompanyId", COMPANY_ID); +} + +function makeDefinition(overrides: Partial): UserSecretDefinition { + return { + id: "def-x", + companyId: COMPANY_ID, + key: "USER_SECRET", + name: "User secret", + description: null, + status: "active", + provider: "local_encrypted", + managedMode: "paperclip_managed", + providerConfigId: null, + providerMetadata: null, + usageGuidance: null, + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: null, + updatedByUserId: null, + deletedAt: null, + createdAt: new Date("2026-06-01T00:00:00Z"), + updatedAt: new Date("2026-06-01T00:00:00Z"), + ...overrides, + }; +} + +function makeValue(definitionId: string): CompanySecret { + return { + id: `sec-${definitionId}`, + companyId: COMPANY_ID, + scope: "user", + ownerUserId: "user-me", + userSecretDefinitionId: definitionId, + key: "USER_SECRET", + name: "User secret", + provider: "local_encrypted", + status: "active", + managedMode: "paperclip_managed", + externalRef: null, + providerConfigId: null, + providerMetadata: null, + latestVersion: 1, + description: null, + lastResolvedAt: null, + lastRotatedAt: null, + deletedAt: null, + createdByAgentId: null, + createdByUserId: "user-me", + createdAt: new Date("2026-06-02T00:00:00Z"), + updatedAt: new Date("2026-06-02T00:00:00Z"), + }; +} + +const ghToken = makeDefinition({ + id: "def-gh", + key: "PERSONAL_GH_TOKEN", + name: "Personal GitHub token", + description: "Used when the responsible user's own repos must be reached.", + usageGuidance: "Create a fine-grained PAT with repo:read scope.", +}); +const openai = makeDefinition({ + id: "def-openai", + key: "OPENAI_API_KEY", + name: "OpenAI API key", + description: "Each member bills to their own OpenAI account.", +}); +const slack = makeDefinition({ + id: "def-slack", + key: "SLACK_USER_TOKEN", + name: "Slack user token", + status: "disabled", +}); + +const definitions: UserSecretDefinition[] = [ghToken, openai, slack]; + +const coverage: Record = { + "def-gh": { definitionId: "def-gh", configuredCount: 5, missingCount: 2, inactiveCount: 0 }, + "def-openai": { definitionId: "def-openai", configuredCount: 7, missingCount: 0, inactiveCount: 0 }, + "def-slack": { definitionId: "def-slack", configuredCount: 1, missingCount: 5, inactiveCount: 1 }, +}; + +const myEntries: MyUserSecretEntry[] = [ + { definition: ghToken, secret: null }, + { definition: openai, secret: makeValue("def-openai") }, + { definition: slack, secret: null }, +]; + +function SeedFixtures({ children }: { children: ReactNode }) { + const queryClient = useQueryClient(); + queryClient.setQueryData(queryKeys.secrets.userDefinitions(COMPANY_ID), definitions); + queryClient.setQueryData(queryKeys.secrets.myUserSecrets(COMPANY_ID), myEntries); + for (const [definitionId, summary] of Object.entries(coverage)) { + queryClient.setQueryData( + queryKeys.secrets.userDefinitionCoverage(COMPANY_ID, definitionId), + summary, + ); + } + + const { selectedCompanyId, setSelectedCompanyId } = useCompany(); + useEffect(() => { + if (selectedCompanyId !== COMPANY_ID) setSelectedCompanyId(COMPANY_ID); + }, [selectedCompanyId, setSelectedCompanyId]); + if (selectedCompanyId !== COMPANY_ID) return null; + + return {children}; +} + +function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +const meta: Meta = { + title: "Product/User secrets", + parameters: { layout: "fullscreen", a11y: { test: "off" } }, +}; +export default meta; +type Story = StoryObj; + +export const AdminDefinitions: Story = { + render: () => ( + +
+
+ +
+
+
+ ), +}; + +export const MySecrets: Story = { + render: () => ( + +
+
+ +
+
+
+ ), +}; + +export const MissingWarning: Story = { + render: () => ( + +
+
+ +
+
+
+ ), +}; + +export const EnvPicker: Story = { + render: () => { + const value: Record = { + GH_TOKEN: { type: "user_secret_ref", key: "PERSONAL_GH_TOKEN", required: true }, + OPENAI_API_KEY: { type: "user_secret_ref", key: "OPENAI_API_KEY", required: false }, + }; + return ( + +
+
+ { + throw new Error("noop"); + }} + onChange={() => {}} + /> +
+
+
+ ); + }, +};