From 58dc2fe2997c7f4aaf8c9dc83107fb98570a623b Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 6 May 2026 10:21:44 -0500 Subject: [PATCH] Polish secrets settings layout Co-Authored-By: Paperclip --- ui/src/pages/Secrets.render.test.tsx | 217 ++++++++++++++++++++ ui/src/pages/Secrets.tsx | 288 +++++++++++++++------------ 2 files changed, 373 insertions(+), 132 deletions(-) create mode 100644 ui/src/pages/Secrets.render.test.tsx diff --git a/ui/src/pages/Secrets.render.test.tsx b/ui/src/pages/Secrets.render.test.tsx new file mode 100644 index 00000000000..12d53e5bd25 --- /dev/null +++ b/ui/src/pages/Secrets.render.test.tsx @@ -0,0 +1,217 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { CompanySecretProviderConfig, SecretProviderDescriptor } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ProviderVaultsTab, Secrets } from "./Secrets"; + +const mockSecretsApi = vi.hoisted(() => ({ + list: vi.fn(), + providers: vi.fn(), + providerHealth: vi.fn(), + providerConfigs: vi.fn(), + createProviderConfig: vi.fn(), + updateProviderConfig: vi.fn(), + disableProviderConfig: vi.fn(), + setDefaultProviderConfig: vi.fn(), + checkProviderConfigHealth: vi.fn(), + create: vi.fn(), + update: vi.fn(), + rotate: vi.fn(), + disable: vi.fn(), + enable: vi.fn(), + archive: vi.fn(), + remove: vi.fn(), + usage: vi.fn(), + accessEvents: vi.fn(), +})); + +const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); +const mockPushToast = vi.hoisted(() => vi.fn()); + +vi.mock("../api/secrets", () => ({ + secretsApi: mockSecretsApi, +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ + selectedCompanyId: "company-1", + }), +})); + +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ + setBreadcrumbs: mockSetBreadcrumbs, + }), +})); + +vi.mock("../context/ToastContext", () => ({ + useToast: () => ({ + pushToast: mockPushToast, + }), + useToastActions: () => ({ + pushToast: mockPushToast, + }), +})); + +vi.mock("../context/SidebarContext", () => ({ + useSidebar: () => ({ + isMobile: false, + }), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const providers: SecretProviderDescriptor[] = [ + { + id: "local_encrypted", + label: "Local encrypted", + requiresExternalRef: false, + supportsManagedValues: true, + supportsExternalReferences: false, + configured: true, + }, + { + id: "aws_secrets_manager", + label: "AWS Secrets Manager", + requiresExternalRef: false, + supportsManagedValues: true, + supportsExternalReferences: true, + configured: true, + }, + { + id: "gcp_secret_manager", + label: "GCP Secret Manager", + requiresExternalRef: false, + supportsManagedValues: false, + supportsExternalReferences: true, + configured: false, + }, + { + id: "vault", + label: "Vault", + requiresExternalRef: false, + supportsManagedValues: false, + supportsExternalReferences: true, + configured: false, + }, +]; + +const providerConfigs = [ + { + id: "vault-local", + provider: "local_encrypted", + displayName: "Local default", + status: "ready", + isDefault: true, + healthStatus: "ready", + healthCheckedAt: null, + healthMessage: null, + healthDetails: null, + }, + { + id: "vault-aws", + provider: "aws_secrets_manager", + displayName: "AWS production", + status: "ready", + isDefault: false, + healthStatus: null, + healthCheckedAt: null, + healthMessage: null, + healthDetails: null, + }, +] satisfies Partial[]; + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +describe("Secrets page layout", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + + mockSecretsApi.list.mockResolvedValue([]); + mockSecretsApi.providers.mockResolvedValue(providers); + mockSecretsApi.providerHealth.mockResolvedValue({ + providers: [ + { + provider: "local_encrypted", + status: "warn", + message: "Local encrypted provider has a warning.", + warnings: ["Backup reminder"], + }, + ], + }); + mockSecretsApi.providerConfigs.mockResolvedValue(providerConfigs); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("uses the shared search/filter/tab affordances and keeps vault sections quiet", async () => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(container.querySelector('input[data-page-search-target="true"][aria-label="Search secrets"]')).not.toBeNull(); + expect(container.querySelectorAll("select")).toHaveLength(0); + expect(container.textContent).not.toContain("Provider warnings detected"); + expect(container.textContent).not.toContain("2/2 active"); + + await act(async () => { + root.unmount(); + }); + + const vaultRoot = createRoot(container); + await act(async () => { + vaultRoot.render( + , + ); + }); + await flushReact(); + + expect(container.querySelector('a[href="#provider-vaults-local_encrypted"]')).not.toBeNull(); + expect(container.textContent).toContain("AWS production"); + expect(container.textContent).not.toContain("Managed writes"); + expect(container.textContent).not.toContain("External refs"); + + await act(async () => { + vaultRoot.unmount(); + }); + }); +}); diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx index 196219b05aa..d74438f3cb0 100644 --- a/ui/src/pages/Secrets.tsx +++ b/ui/src/pages/Secrets.tsx @@ -20,7 +20,8 @@ import { ShieldCheck, Star, Trash2, - Wifi, + X, + Filter, } from "lucide-react"; import type { CompanySecret, @@ -49,6 +50,8 @@ import { EmptyState } from "../components/EmptyState"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Sheet, SheetContent, @@ -67,6 +70,7 @@ import { import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; import { cn } from "../lib/utils"; +import { PageTabBar } from "../components/PageTabBar"; type CreateMode = "managed" | "external"; type SecretsTab = "secrets" | "vaults"; @@ -468,6 +472,7 @@ export function Secrets() { ); }); }, [secrets, search, statusFilter, providerFilter]); + const activeSecretFilterCount = (statusFilter === "active" ? 0 : 1) + (providerFilter === "all" ? 0 : 1); const usageQuery = useQuery({ queryKey: selectedSecret ? queryKeys.secrets.usage(selectedSecret.id) : ["secrets", "usage", "__disabled__"], @@ -733,52 +738,35 @@ export function Secrets() { ); } - const activeSecrets = secrets.filter((s) => s.status === "active").length; - return (

Secrets

- - {activeSecrets}/{secrets.length} active · {providerConfigs.length} vault{providerConfigs.length === 1 ? "" : "s"} -
{activeTab === "secrets" ? ( <> -
- +
+ setSearch(event.target.value)} placeholder="Search by name, key, ref" - className="pl-7 h-8 w-64" + className="pl-7 text-xs sm:text-sm" + aria-label="Search secrets" + data-page-search-target="true" />
- - + @@ -791,19 +779,17 @@ export function Secrets() {
- - setActiveTab(value as SecretsTab)} className="flex min-h-0 flex-1 flex-col">
- - Secrets - Provider vaults - + setActiveTab(value as SecretsTab)} + />
{secretsQuery.isError ? ( @@ -1464,75 +1450,110 @@ export function Secrets() { ); } -function ProviderHealthBar({ +function SecretsFiltersPopover({ + statusFilter, + providerFilter, providers, - health, - loading, - error, + activeFilterCount, + onStatusChange, + onProviderChange, }: { + statusFilter: SecretStatus | "all"; + providerFilter: SecretProvider | "all"; providers: SecretProviderDescriptor[]; - health: SecretProviderHealthResponse | null; - loading: boolean; - error: unknown; + activeFilterCount: number; + onStatusChange: (value: SecretStatus | "all") => void; + onProviderChange: (value: SecretProvider | "all") => void; }) { - if (loading || providers.length === 0) return null; + const resetFilters = () => { + onStatusChange("active"); + onProviderChange("all"); + }; - const healthMap = new Map(health?.providers.map((entry) => [entry.provider, entry]) ?? []); - const worstStatus = (() => { - if (error) return "error" as const; - let worst: "ok" | "warn" | "error" = "ok"; - for (const entry of healthMap.values()) { - if (entry.status === "error") return "error" as const; - if (entry.status === "warn") worst = "warn"; - } - return worst; - })(); - - const tone = - worstStatus === "error" - ? "border-destructive/40 bg-destructive/5 text-destructive" - : worstStatus === "warn" - ? "border-amber-500/40 bg-amber-500/5 text-amber-700 dark:text-amber-300" - : "border-border bg-muted/30 text-muted-foreground"; - const dot = (status: "ok" | "warn" | "error") => - status === "error" ? "bg-destructive" : status === "warn" ? "bg-amber-500" : "bg-emerald-500"; - const message = error - ? "Provider health check failed." - : worstStatus === "error" - ? "One or more providers reported errors." - : worstStatus === "warn" - ? "Provider warnings detected — review setup." - : `Connected to ${providers.length} provider${providers.length === 1 ? "" : "s"}`; + const statusOptions: Array<{ value: SecretStatus | "all"; label: string }> = [ + { value: "active", label: "Active" }, + { value: "all", label: "All statuses" }, + { value: "disabled", label: "Disabled" }, + { value: "archived", label: "Archived" }, + ]; return ( -
- - {message} - · - - {providers.map((provider) => { - const entry = healthMap.get(provider.id); - const status = entry?.status ?? "ok"; - const warningCount = entry?.warnings?.length ?? 0; - return ( - - - {provider.label} - {warningCount > 0 ? ( - - - {warningCount} warning{warningCount === 1 ? "" : "s"} - - ) : null} + + +
+ ) : null} + + + +
+
+ Filters + {activeFilterCount > 0 ? ( + + ) : null} +
+ +
+
+ Status +
+ {statusOptions.map((option) => ( + + ))} +
+
+ +
+ Provider +
+ + {providers.map((provider) => ( + + ))} +
+
+
+
+
+ ); } @@ -1585,7 +1606,7 @@ function ProviderVaultInlineWarning({ config }: { config: CompanySecretProviderC ); } -function ProviderVaultsTab({ +export function ProviderVaultsTab({ providers, providerConfigs, loading, @@ -1631,27 +1652,38 @@ function ProviderVaultsTab({ } const providerMap = new Map(providers.map((provider) => [provider.id, provider])); + const providerRows = PROVIDER_ORDER.map((providerId) => ({ + id: providerId, + provider: providerMap.get(providerId), + Icon: providerFamilyIcon(providerId), + isComingSoonFamily: providerId === "gcp_secret_manager" || providerId === "vault", + configs: providerConfigs.filter((config) => config.provider === providerId), + })); return ( -
- {PROVIDER_ORDER.map((providerId) => { - const provider = providerMap.get(providerId); - const configs = providerConfigs.filter((config) => config.provider === providerId); - const Icon = providerFamilyIcon(providerId); - const isComingSoonFamily = providerId === "gcp_secret_manager" || providerId === "vault"; - return ( -
+
+ + +
+ {providerRows.map(({ id, provider, Icon, isComingSoonFamily, configs }) => ( +
-

{provider?.label ?? providerId.replaceAll("_", " ")}

- {provider?.supportsManagedValues ? : null} - {provider?.supportsExternalReferences ? : null} - {isComingSoonFamily ? ( - - coming soon - - ) : null} - @@ -1663,7 +1695,7 @@ function ProviderVaultsTab({ : "No company-specific vaults yet. Secrets can still use the deployment default provider settings."}
) : ( -
+
{configs.map((config) => ( )}
- ); - })} + ))} +
); } -function CapabilityBadge({ label }: { label: string }) { - return ( - - {label} - - ); -} - function ProviderVaultCard({ config, pending,