diff --git a/ui/src/pages/Secrets.render.test.tsx b/ui/src/pages/Secrets.render.test.tsx index c42a0d6680..c4e3aef013 100644 --- a/ui/src/pages/Secrets.render.test.tsx +++ b/ui/src/pages/Secrets.render.test.tsx @@ -174,6 +174,14 @@ async function flushReact() { }); } +async function waitForReact(predicate: () => boolean, attempts = 20) { + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (predicate()) return; + await flushReact(); + } + throw new Error("Timed out waiting for React state to settle"); +} + function makeDiscoveryPreview( overrides: Partial = {}, ): SecretProviderConfigDiscoveryPreviewResult { @@ -373,9 +381,11 @@ describe("Secrets page layout", () => { await act(async () => { root.render( - - - , + + + + + , ); }); await flushReact(); @@ -1440,3 +1450,281 @@ describe("Secrets page layout", () => { }); }); }); + +describe("Secrets folder view (PAP-14698)", () => { + let container: HTMLDivElement; + + function seedFolderSecrets() { + mockSecretsApi.list.mockResolvedValue([ + makeCompanySecret({ id: "s1", key: "dev_github_oauth_clientid", name: "dev/github/oauth/clientid" }), + makeCompanySecret({ id: "s2", key: "dev_github_oauth_clientsecret", name: "dev/github/oauth/clientsecret" }), + makeCompanySecret({ id: "s3", key: "prod_api_token", name: "prod/api/token" }), + makeCompanySecret({ id: "s4", key: "standalone", name: "standalone" }), + ]); + mockSecretsApi.providers.mockResolvedValue(providers); + mockSecretsApi.providerHealth.mockResolvedValue({ providers: [] }); + mockSecretsApi.providerConfigs.mockResolvedValue(providerConfigs); + mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([]); + mockSecretsApi.userSecretDefinitionCoverage.mockResolvedValue(userSecretCoverage); + mockSecretsApi.listMyUserSecrets.mockResolvedValue([]); + mockAgentsApi.list.mockResolvedValue([]); + } + + async function renderAt(path: string) { + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + return root; + } + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + try { + window.localStorage.clear(); + } catch { + /* ignore */ + } + seedFolderSecrets(); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("derives folders at the root with filtered counts and a flat standalone secret", async () => { + const root = await renderAt("/"); + + const table = container.querySelector('[data-testid="secrets-table-view"]')!; + expect(table.textContent).toContain("dev"); + expect(table.textContent).toContain("prod"); + expect(table.textContent).toContain("standalone"); + // dev groups both oauth secrets recursively; github and oauth are descendant folders. + expect(table.textContent).toContain("2 secrets · 2 folders"); + expect(table.textContent).toContain("1 secret · 1 folder"); + // Folder rows are real links carrying ?path=. + const links = [...container.querySelectorAll("a")].map((a) => a.getAttribute("href") ?? ""); + expect(links.some((href) => href.includes("path=dev"))).toBe(true); + + await act(async () => root.unmount()); + }); + + it("opens a deep ?path= link into the folder with breadcrumb, leaves, and an up affordance", async () => { + const root = await renderAt("/?path=dev/github/oauth"); + + const breadcrumb = container.querySelector('nav[aria-label="Breadcrumb"]'); + expect(breadcrumb).not.toBeNull(); + const current = container.querySelector('[aria-current="page"]'); + expect(current?.textContent).toContain("oauth"); + + const table = container.querySelector('[data-testid="secrets-table-view"]')!; + expect(table.textContent).toContain("clientid"); + expect(table.textContent).toContain("clientsecret"); + expect(table.textContent).toContain("Up to github"); + // Sibling trees are not shown while drilled in. + expect(table.textContent).not.toContain("standalone"); + + await act(async () => root.unmount()); + }); + + it("renders the empty-folder state (breadcrumb intact) for an unknown path", async () => { + const root = await renderAt("/?path=does/not/exist"); + + expect(container.querySelector('nav[aria-label="Breadcrumb"]')).not.toBeNull(); + expect(container.textContent).toContain("No secrets in this folder yet."); + const cta = [...container.querySelectorAll("button")].find((b) => + b.textContent?.includes("New secret here"), + ) as HTMLButtonElement; + expect(cta).toBeDefined(); + await act(async () => cta.click()); + await flushReact(); + + expect(document.body.textContent).toContain("does/not/exist/"); + expect((document.getElementById("new-secret-name") as HTMLInputElement).value).toBe(""); + expect(document.querySelector('button[aria-label="Remove folder prefix"]')).not.toBeNull(); + + await act(async () => root.unmount()); + }); + + it("distinguishes a filtered-empty folder from a genuinely empty folder", async () => { + const root = await renderAt("/?path=dev/github/oauth"); + const filterButton = document.querySelector('button[title="Filter"]') as HTMLButtonElement; + await act(async () => filterButton.click()); + await flushReact(); + + const archivedLabel = [...document.querySelectorAll("label")].find( + (label) => label.textContent?.trim() === "Archived", + ) as HTMLLabelElement; + await act(async () => archivedLabel.click()); + await waitForReact(() => container.textContent?.includes("No secrets match your filters.") ?? false); + + expect(container.textContent).toContain("No secrets match your filters."); + expect(container.textContent).not.toContain("New secret here"); + + await act(async () => root.unmount()); + }); + + it("creates a company secret from a folder prefix and derives the key from the full name", async () => { + mockSecretsApi.create.mockResolvedValue( + makeCompanySecret({ id: "created", name: "dev/github/oauth/clientsecret/deeper" }), + ); + const root = await renderAt("/?path=dev/github/oauth"); + + const newSecretButton = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "New secret", + ) as HTMLButtonElement; + await act(async () => newSecretButton.click()); + await flushReact(); + + expect(document.body.textContent).toContain("dev/github/oauth/"); + const nameInput = document.getElementById("new-secret-name") as HTMLInputElement; + expect(nameInput.placeholder).toBe("clientsecret"); + expect(nameInput.value).toBe(""); + await act(async () => setInputValue(nameInput, "clientsecret/deeper")); + await flushReact(); + + expect((document.getElementById("new-secret-key") as HTMLInputElement).value).toBe( + "dev-github-oauth-clientsecret-deeper", + ); + await act(async () => + setTextareaValue(document.getElementById("new-secret-value") as HTMLTextAreaElement, "secret-value"), + ); + const createButton = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Create secret", + ) as HTMLButtonElement; + await act(async () => createButton.click()); + await flushReact(); + + expect(mockSecretsApi.create).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ name: "dev/github/oauth/clientsecret/deeper" }), + ); + + await act(async () => root.unmount()); + }); + + it("keeps the folder prefix for Each user and exposes the full name when the chip is removed", async () => { + const root = await renderAt("/?path=dev/github/oauth"); + const newSecretButton = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "New secret", + ) as HTMLButtonElement; + await act(async () => newSecretButton.click()); + await flushReact(); + + const eachUserTab = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Each user", + ) as HTMLButtonElement; + await act(async () => { + eachUserTab.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + eachUserTab.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })); + eachUserTab.click(); + }); + await flushReact(); + + const nameInput = document.getElementById("new-secret-name") as HTMLInputElement; + await act(async () => setInputValue(nameInput, "personal-token")); + await flushReact(); + expect((document.getElementById("new-secret-key") as HTMLInputElement).value).toBe( + "DEV_GITHUB_OAUTH_PERSONAL_TOKEN", + ); + + const removePrefix = document.querySelector( + 'button[aria-label="Remove folder prefix"]', + ) as HTMLButtonElement; + await act(async () => removePrefix.click()); + await flushReact(); + expect((document.getElementById("new-secret-name") as HTMLInputElement).value).toBe( + "dev/github/oauth/personal-token", + ); + expect(document.querySelector('button[aria-label="Remove folder prefix"]')).toBeNull(); + + await act(async () => root.unmount()); + }); + + it("validates New folder inline and stages the trimmed segment in the URL-backed folder view", async () => { + const root = await renderAt("/?path=dev/github/oauth"); + const newFolderButton = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "New folder", + ) as HTMLButtonElement; + await act(async () => newFolderButton.click()); + await flushReact(); + + const folderInput = container.querySelector('input[aria-label="Folder name"]') as HTMLInputElement; + await act(async () => setInputValue(folderInput, "bad/name")); + const createFolderButton = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Create folder", + ) as HTMLButtonElement; + await act(async () => createFolderButton.click()); + await flushReact(); + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + "Folder name cannot contain slashes.", + ); + + await act(async () => setInputValue(folderInput, " staged ")); + await flushReact(); + await act(async () => createFolderButton.click()); + await waitForReact(() => + [...container.querySelectorAll('[aria-current="page"]')].some((node) => + node.textContent?.includes("staged"), + ), + ); + + expect( + [...container.querySelectorAll('[aria-current="page"]')].some((node) => + node.textContent?.includes("staged"), + ), + ).toBe(true); + expect(container.textContent).toContain("No secrets in this folder yet."); + expect(container.querySelector('input[aria-label="Folder name"]')).toBeNull(); + + await act(async () => root.unmount()); + }); + + it("Flat toggle reproduces the raw, ungrouped list", async () => { + const root = await renderAt("/"); + + const flatButton = [...container.querySelectorAll("button")].find( + (b) => b.textContent?.trim().toLowerCase() === "flat", + ) as HTMLButtonElement | undefined; + expect(flatButton).toBeDefined(); + await act(async () => flatButton!.click()); + await flushReact(); + + const table = container.querySelector('[data-testid="secrets-table-view"]')!; + expect(table.textContent).toContain("dev/github/oauth/clientid"); + expect(table.textContent).not.toContain("2 secrets · 1 folder"); + + await act(async () => root.unmount()); + }); + + it("search is global across folders and shows full muted-path names", async () => { + const root = await renderAt("/?path=dev/github/oauth"); + + const input = container.querySelector( + 'input[aria-label="Search secrets"]', + ) as HTMLInputElement; + await act(async () => setInputValue(input, "token")); + await flushReact(); + + expect(container.textContent).toContain("Search results"); + expect(container.textContent).toContain("across all folders"); + const table = container.querySelector('[data-testid="secrets-table-view"]')!; + // prod/api/token lives outside the current folder yet still matches. + expect(table.textContent).toContain("prod/api/"); + expect(table.textContent).toContain("token"); + + await act(async () => root.unmount()); + }); +}); diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx index 7463862d57..4cf2153da4 100644 --- a/ui/src/pages/Secrets.tsx +++ b/ui/src/pages/Secrets.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, @@ -7,11 +7,16 @@ import { Archive, Ban, CheckCircle2, + ChevronLeft, + ChevronRight, Cloud, + CornerLeftUp, Copy, Database, Edit3, ExternalLink, + Folder, + FolderOpen, KeyRound, Link2, Lock, @@ -30,7 +35,7 @@ import { UserRound, Users, } from "lucide-react"; -import { Link } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import type { CompanySecret, CompanySecretUsageBinding, @@ -97,6 +102,15 @@ import { copyTextToClipboard } from "../lib/clipboard"; import { PageTabBar } from "../components/PageTabBar"; import { ImportFromVaultDialog } from "./secrets/ImportFromVaultDialog"; import { MyUserSecretsTab } from "./secrets/MyUserSecretsTab"; +import { SecretPathName } from "./secrets/SecretPathName"; +import { + buildSecretPathBreadcrumbs, + buildSecretPathListing, + getSecretPathRowName, + normalizeSecretPath, + validateSecretFolderSegment, + type SecretPathFolder, +} from "./secrets/secret-path"; import { SetMyUserSecretDialog } from "./secrets/SetMyUserSecretDialog"; import { coverageSummaryLabel, @@ -108,6 +122,27 @@ type CreateMode = "managed" | "external"; type SecretValueProvider = "company" | "user"; type ProvidedByFilter = "all" | SecretValueProvider; type SecretsTab = "secrets" | "my-secrets" | "vaults"; +type SecretsViewMode = "folders" | "flat"; + +const SECRETS_VIEW_MODE_STORAGE_KEY = "paperclip.secrets.viewMode"; + +function readStoredViewMode(): SecretsViewMode | null { + try { + const stored = window.localStorage.getItem(SECRETS_VIEW_MODE_STORAGE_KEY); + return stored === "folders" || stored === "flat" ? stored : null; + } catch { + return null; + } +} + +/** "12 secrets · 3 folders" — folder part omitted when there are no subfolders. */ +function formatSecretPathCounts(secretCount: number, folderCount: number): string { + const parts = [`${secretCount} ${secretCount === 1 ? "secret" : "secrets"}`]; + if (folderCount > 0) { + parts.push(`${folderCount} ${folderCount === 1 ? "folder" : "folders"}`); + } + return parts.join(" · "); +} type UnifiedSecretRow = | { id: string; kind: "company"; secret: CompanySecret } @@ -616,6 +651,7 @@ export function Secrets() { const [secretValueProvider, setSecretValueProvider] = useState("company"); const [createMode, setCreateMode] = useState("managed"); const [editingDefinition, setEditingDefinition] = useState(null); + const [createNamePrefix, setCreateNamePrefix] = useState(null); const [createKeyDirty, setCreateKeyDirty] = useState(false); const [createKeyEditable, setCreateKeyEditable] = useState(false); const [createForm, setCreateForm] = useState({ @@ -645,6 +681,9 @@ export function Secrets() { const [vaultDiscovery, setVaultDiscovery] = useState(null); const [vaultDiscoveryError, setVaultDiscoveryError] = useState(null); + const [newFolderOpen, setNewFolderOpen] = useState(false); + const [newFolderName, setNewFolderName] = useState(""); + const [newFolderError, setNewFolderError] = useState(null); useEffect(() => { setBreadcrumbs([{ label: "Secrets" }]); @@ -817,6 +856,90 @@ export function Secrets() { (providerFilter === "all" ? 0 : 1) + (providedByFilter === "all" ? 0 : 1); + // --- Folder view (PAP-14698) -------------------------------------------- + // Folders are derived purely from slash-delimited secret names; there is no + // server-side folder record. `?path=` holds the normalized current folder + // and is only meaningful on the main Secrets tab (inert on the others). + const [searchParams, setSearchParams] = useSearchParams(); + const pathParam = normalizeSecretPath(searchParams.get("path") ?? ""); + const folderPath = activeTab === "secrets" ? pathParam : ""; + const searching = search.trim().length > 0; + + const [storedViewMode, setStoredViewMode] = useState(readStoredViewMode); + const hasSlashNames = useMemo( + () => unifiedRows.some((row) => getSecretPathRowName(row).includes("/")), + [unifiedRows], + ); + // No explicit preference → default to Folders once any name has a slash. + const resolvedViewMode: SecretsViewMode = storedViewMode ?? (hasSlashNames ? "folders" : "flat"); + // A `?path=` deep link forces folder view for the visit even if the stored + // preference is Flat. Search always renders a flat global result set. + const effectiveViewMode: SecretsViewMode = folderPath ? "folders" : resolvedViewMode; + const showFolderView = effectiveViewMode === "folders" && !searching; + + const goToFolder = useCallback( + (path: string) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + const normalized = normalizeSecretPath(path); + if (normalized) next.set("path", normalized); + else next.delete("path"); + return next; + }, + { replace: false }, + ); + }, + [setSearchParams], + ); + + function closeNewFolder() { + setNewFolderOpen(false); + setNewFolderName(""); + setNewFolderError(null); + } + + function stageNewFolder() { + const segment = newFolderName.trim(); + const error = validateSecretFolderSegment(segment); + if (error) { + setNewFolderError(error); + return; + } + goToFolder(folderPath ? `${folderPath}/${segment}` : segment); + closeNewFolder(); + } + + const setViewMode = useCallback( + (mode: SecretsViewMode) => { + setStoredViewMode(mode); + try { + window.localStorage.setItem(SECRETS_VIEW_MODE_STORAGE_KEY, mode); + } catch { + // Ignore storage failures (private mode / disabled); view still works. + } + // Flat has no notion of a current folder — leaving it out of the URL. + if (mode === "flat") goToFolder(""); + }, + [goToFolder], + ); + + const folderListing = useMemo( + () => buildSecretPathListing(filteredRows, folderPath), + [filteredRows, folderPath], + ); + const breadcrumbs = useMemo(() => buildSecretPathBreadcrumbs(folderPath), [folderPath]); + const parentFolderPath = useMemo(() => { + const segments = folderPath ? folderPath.split("/") : []; + return segments.slice(0, -1).join("/"); + }, [folderPath]); + const currentFolderSecretCount = + folderListing.secrets.length + + folderListing.folders.reduce((total, folder) => total + folder.secretCount, 0); + const folderRows = showFolderView ? folderListing.folders : []; + const secretRows = showFolderView ? folderListing.secrets : filteredRows; + const showUpRow = showFolderView && folderPath.length > 0; + const usageQuery = useQuery({ queryKey: selectedSecret ? queryKeys.secrets.usage(selectedSecret.id) : ["secrets", "usage", "__disabled__"], queryFn: () => secretsApi.usage(selectedSecret!.id), @@ -852,14 +975,16 @@ export function Secrets() { } function openCreateSecret() { + const prefix = folderPath ? `${folderPath}/` : null; setEditingDefinition(null); + setCreateNamePrefix(prefix); setSecretValueProvider("company"); setCreateMode("managed"); setCreateKeyDirty(false); setCreateKeyEditable(false); setCreateError(null); setCreateForm({ - name: "", + name: prefix ?? "", key: "", value: "", description: "", @@ -873,6 +998,7 @@ export function Secrets() { function openEditDefinition(definition: UserSecretDefinition) { setEditingDefinition(definition); + setCreateNamePrefix(null); setSecretValueProvider("user"); setCreateMode("managed"); setCreateKeyDirty(true); @@ -944,6 +1070,7 @@ export function Secrets() { }); setCreateOpen(false); setEditingDefinition(null); + setCreateNamePrefix(null); setSecretValueProvider("company"); setCreateKeyDirty(false); setCreateKeyEditable(false); @@ -1438,6 +1565,158 @@ export function Secrets() { ); } + function folderLinkTo(path: string) { + const params = new URLSearchParams(searchParams); + const normalized = normalizeSecretPath(path); + if (normalized) params.set("path", normalized); + else params.delete("path"); + const qs = params.toString(); + return { search: qs ? `?${qs}` : "" }; + } + + /** Secret-name treatment: raw in flat view, muted-path/bold-leaf otherwise. */ + function renderSecretName(name: string) { + if (searching) return ; + if (showFolderView) return ; + return {name}; + } + + function renderFolderTableRow(folder: SecretPathFolder) { + return ( + +
+
+ + {folder.name} +
+
+ {formatSecretPathCounts(folder.secretCount, folder.folderCount)} +
+
+