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 (
+
+ );
+}
+
+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={() => {}}
+ />
+
+
+
+ );
+ },
+};