feat(ui): navigate slash-named secrets as folders (#9913)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Operators store company and user secrets under human-readable names > - Slash-delimited names already express useful hierarchy, but the Secrets page previously rendered them as one flat list > - Large secret collections therefore became harder to scan, navigate, and create within consistently named groups > - A client-derived folder model preserves the existing server contract while making those names navigable > - This pull request adds folder browsing, URL-addressable paths, global search, and create-in-folder behavior without adding folder records > - The benefit is a more scalable secrets workflow with no migration or API compatibility risk ## Linked Issues or Issue Description Slash-delimited secret names such as `dev/github/oauth/clientid` currently appear as raw flat rows. This change treats name prefixes as client-side navigation folders on the main Secrets tab while leaving stored names, API contracts, validation, and the database unchanged. - Wireframes and interaction specification: https://pages.paperclip.ing/pap-14698-secrets-folders/ - Folder paths are derived only from secret names; there is no server-side folder entity or data-model change. ## What Changed - Added pure secret-path utilities for normalized segments, breadcrumbs, nested folder listings, counts, and leaf/path rendering. - Added a Folders/Flat view to the main Secrets tab with folder-first sorting, breadcrumbs, empty-folder states, filters, and global search results. - Added URL navigation through `?path=<normalized/path>` so deep links, reload, browser Back, and new-tab folder navigation work. - Persisted the preferred view in `localStorage` under `paperclip.secrets.viewMode`; an explicit `?path=` deep link takes precedence for that visit. - Added create-in-folder behavior with a removable prefix chip, staged New folder paths, inline segment validation, and full-name key derivation. - Kept My secrets flat while rendering slash-delimited names with muted paths and emphasized leaves. - Added unit/render coverage for path helpers, folder navigation, and create-in-folder behavior. ## Verification - `pnpm exec vitest run ui/src/pages/secrets/secret-path.test.ts ui/src/pages/Secrets.render.test.tsx` — 35 tests passed. - `pnpm -r typecheck` — passed. - `pnpm test:run` — server suite passed 2,707 tests and UI suite passed 3,022 tests; one unrelated CLI doctor test was environment-sensitive because this agent inherited AWS variables. - `env -u AWS_PROFILE -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN -u AWS_REGION -u AWS_DEFAULT_REGION pnpm exec vitest run cli/src/__tests__/secrets.test.ts` — 8 tests passed. - `pnpm build` — passed. - `pnpm check:token-gates` — feature files are clean; the command reports five existing `#9627` color literals outside this diff. ## Risks - Low data risk: folders are derived client-side from existing names, with no schema, migration, API, or stored-name changes. - URL behavior changes only the main Secrets tab and uses the additive `?path=` contract. - View preference is browser-local and scoped to the `paperclip.secrets.viewMode` key. - Renaming or deleting the last secret under an open prefix intentionally leaves the user on an empty-folder state instead of redirecting. - “Move to folder…” bulk prefix rename remains deferred because it is a multi-secret mutation with separate conflict and partial-failure semantics. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI `gpt-5.5` via Codex CLI for PR preparation, verification, GitHub/Paperclip tool use, and codebase analysis; the managed harness does not expose the active context-window size. - Anthropic Claude Opus 4.8 (1M context) and Claude Fable 5 assisted earlier implementation/design commits, as recorded in their commit trailers; both used repository and code-editing tools. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details — the assigned shared execution branch name is fixed for this work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
2f42a4968d
commit
b94907f8af
|
|
@ -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> = {},
|
||||
): SecretProviderConfigDiscoveryPreviewResult {
|
||||
|
|
@ -373,9 +381,11 @@ describe("Secrets page layout", () => {
|
|||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Secrets />
|
||||
</QueryClientProvider>,
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Secrets />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
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(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Secrets />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
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());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<SecretValueProvider>("company");
|
||||
const [createMode, setCreateMode] = useState<CreateMode>("managed");
|
||||
const [editingDefinition, setEditingDefinition] = useState<UserSecretDefinition | null>(null);
|
||||
const [createNamePrefix, setCreateNamePrefix] = useState<string | null>(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<SecretProviderConfigDiscoveryPreviewResult | null>(null);
|
||||
const [vaultDiscoveryError, setVaultDiscoveryError] = useState<unknown | null>(null);
|
||||
const [newFolderOpen, setNewFolderOpen] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [newFolderError, setNewFolderError] = useState<string | null>(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<SecretsViewMode | null>(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 <SecretPathName name={name} className="text-sm" />;
|
||||
if (showFolderView) return <SecretPathName name={name} basePath={folderPath} className="text-sm" />;
|
||||
return <span className="truncate font-medium text-foreground">{name}</span>;
|
||||
}
|
||||
|
||||
function renderFolderTableRow(folder: SecretPathFolder) {
|
||||
return (
|
||||
<Link
|
||||
key={`folder:${folder.path}`}
|
||||
to={folderLinkTo(folder.path)}
|
||||
role="row"
|
||||
className="grid grid-cols-(--gtc-54) items-center gap-3 border-b border-border/60 px-3 py-3 hover:bg-accent/40"
|
||||
>
|
||||
<div role="cell" className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-medium text-foreground">{folder.name}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 pl-6 text-xs text-muted-foreground">
|
||||
{formatSecretPathCounts(folder.secretCount, folder.folderCount)}
|
||||
</div>
|
||||
</div>
|
||||
<div role="cell" aria-hidden="true" />
|
||||
<div role="cell" aria-hidden="true" />
|
||||
<div role="cell" aria-hidden="true" />
|
||||
<div role="cell" className="flex justify-end">
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFolderCard(folder: SecretPathFolder) {
|
||||
return (
|
||||
<Link
|
||||
key={`folder:${folder.path}`}
|
||||
to={folderLinkTo(folder.path)}
|
||||
className="flex items-center justify-between gap-2 rounded-md border border-border bg-background p-3 hover:bg-accent/30"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-foreground">{folder.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatSecretPathCounts(folder.secretCount, folder.folderCount)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function renderUpRow(variant: "table" | "card") {
|
||||
const parentLabel = parentFolderPath ? parentFolderPath.split("/").pop()! : "All secrets";
|
||||
return (
|
||||
<Link
|
||||
to={folderLinkTo(parentFolderPath)}
|
||||
role={variant === "table" ? "row" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-xs text-muted-foreground hover:bg-accent/40",
|
||||
variant === "table"
|
||||
? "border-b border-border/60 px-3 py-2.5"
|
||||
: "rounded-md border border-border bg-background px-3 py-2.5",
|
||||
)}
|
||||
>
|
||||
<CornerLeftUp className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">Up to {parentLabel}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSecretsBreadcrumb() {
|
||||
const currentName = breadcrumbs.length > 0 ? breadcrumbs[breadcrumbs.length - 1].name : "All secrets";
|
||||
const parentLabel = parentFolderPath ? parentFolderPath.split("/").pop()! : "All secrets";
|
||||
const fullTrail: { name: string; path: string }[] = [
|
||||
{ name: "All secrets", path: "" },
|
||||
...breadcrumbs,
|
||||
];
|
||||
// Middle-truncate deep paths: root · … · last two.
|
||||
const collapsed =
|
||||
fullTrail.length > 4
|
||||
? [fullTrail[0], { name: "…", path: "" }, ...fullTrail.slice(-2)]
|
||||
: fullTrail;
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="min-w-0">
|
||||
{/* Wide: full trail */}
|
||||
<ol className="hidden min-w-0 items-center gap-1 text-sm @min-[40rem]:flex">
|
||||
{collapsed.map((crumb, index) => {
|
||||
const isLast = index === collapsed.length - 1;
|
||||
const isEllipsis = crumb.name === "…" && crumb.path === "" && index > 0 && !isLast;
|
||||
return (
|
||||
<li key={`${crumb.path}:${index}`} className="flex min-w-0 items-center gap-1">
|
||||
{index > 0 ? <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" /> : null}
|
||||
{isEllipsis ? (
|
||||
<span className="px-0.5 text-muted-foreground">…</span>
|
||||
) : isLast ? (
|
||||
<span aria-current="page" className="truncate font-medium text-foreground">
|
||||
{crumb.name}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
to={folderLinkTo(crumb.path)}
|
||||
className="max-w-40 truncate text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{crumb.name}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{/* Narrow: back-chevron + parent/current */}
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-sm @min-[40rem]:hidden">
|
||||
{folderPath ? (
|
||||
<>
|
||||
<Link
|
||||
to={folderLinkTo(parentFolderPath)}
|
||||
aria-label="Up one folder"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
{parentLabel !== "All secrets" ? (
|
||||
<span className="shrink-0 text-muted-foreground">{parentLabel} /</span>
|
||||
) : null}
|
||||
<span aria-current="page" className="truncate font-medium text-foreground">
|
||||
{currentName}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span aria-current="page" className="truncate font-medium text-foreground">
|
||||
All secrets
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return (
|
||||
<div className="p-6 text-sm text-muted-foreground">Select a company to manage secrets.</div>
|
||||
|
|
@ -1492,16 +1771,87 @@ export function Secrets() {
|
|||
onProviderChange={setProviderFilter}
|
||||
onProvidedByChange={setProvidedByFilter}
|
||||
/>
|
||||
<div
|
||||
role="group"
|
||||
aria-label="View mode"
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-md border border-border p-0.5",
|
||||
searching && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{(["folders", "flat"] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
aria-pressed={effectiveViewMode === mode}
|
||||
disabled={searching}
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={cn(
|
||||
"rounded-sm px-2.5 py-1 text-xs font-medium capitalize transition-colors disabled:cursor-not-allowed",
|
||||
effectiveViewMode === mode
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ImportFromVaultButton
|
||||
providerConfigs={providerConfigs}
|
||||
onClick={() => openImportFromVault()}
|
||||
onManageVaults={() => setActiveTab("vaults")}
|
||||
className="ml-auto"
|
||||
/>
|
||||
{showFolderView ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setNewFolderOpen(true);
|
||||
setNewFolderError(null);
|
||||
}}
|
||||
>
|
||||
<Folder className="mr-1 h-3.5 w-3.5" /> New folder
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={openCreateSecret} size="sm">
|
||||
<Plus className="h-3.5 w-3.5 mr-1" /> New secret
|
||||
</Button>
|
||||
</div>
|
||||
{newFolderOpen && showFolderView ? (
|
||||
<div className="flex flex-wrap items-start gap-2" role="group" aria-label="Create folder">
|
||||
<div className="min-w-48 flex-1 sm:max-w-80">
|
||||
<Input
|
||||
value={newFolderName}
|
||||
onChange={(event) => {
|
||||
setNewFolderName(event.target.value);
|
||||
if (newFolderError) setNewFolderError(null);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") stageNewFolder();
|
||||
if (event.key === "Escape") closeNewFolder();
|
||||
}}
|
||||
placeholder="Folder name"
|
||||
aria-label="Folder name"
|
||||
aria-invalid={Boolean(newFolderError)}
|
||||
autoFocus
|
||||
/>
|
||||
{newFolderError ? (
|
||||
<p className="mt-1 text-xs text-destructive" role="alert">
|
||||
{newFolderError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={stageNewFolder}>
|
||||
Create folder
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={closeNewFolder}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{secretsQuery.isError || userDefinitionsQuery.isError ? (
|
||||
<div className="text-sm text-destructive flex items-center gap-2 py-4">
|
||||
|
|
@ -1518,17 +1868,57 @@ export function Secrets() {
|
|||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : unifiedRows.length === 0 && !secretsQuery.isPending && !userDefinitionsQuery.isPending ? (
|
||||
) : unifiedRows.length === 0 &&
|
||||
!secretsQuery.isPending &&
|
||||
!userDefinitionsQuery.isPending &&
|
||||
!(showFolderView && folderPath) ? (
|
||||
<EmptyState
|
||||
icon={KeyRound}
|
||||
message="No secrets yet. Create a shared company secret or one that each user supplies."
|
||||
action="New secret"
|
||||
onAction={openCreateSecret}
|
||||
/>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<EmptyState icon={Search} message="No secrets match your filters." />
|
||||
) : (
|
||||
<div className="@container min-w-0 overflow-x-hidden text-sm" data-testid="secrets-list-container">
|
||||
{showFolderView ? (
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
|
||||
{renderSecretsBreadcrumb()}
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatSecretPathCounts(currentFolderSecretCount, folderListing.folders.length)}
|
||||
</span>
|
||||
</div>
|
||||
) : searching ? (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm font-medium text-foreground">Search results</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{filteredRows.length} {filteredRows.length === 1 ? "match" : "matches"} across all
|
||||
folders{folderPath ? ` · searching everywhere, not just ${folderPath}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{folderRows.length === 0 && secretRows.length === 0 ? (
|
||||
secretsQuery.isPending || userDefinitionsQuery.isPending ? (
|
||||
<div className="space-y-2 py-2" aria-hidden="true" data-testid="secrets-loading-skeleton">
|
||||
{[0, 1, 2, 3].map((index) => (
|
||||
<div key={index} className="h-14 animate-pulse rounded-md bg-muted/40" />
|
||||
))}
|
||||
</div>
|
||||
) : showFolderView && folderPath && activeSecretFilterCount === 0 ? (
|
||||
<EmptyState
|
||||
icon={FolderOpen}
|
||||
message="No secrets in this folder yet."
|
||||
action="New secret here"
|
||||
onAction={openCreateSecret}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
message={searching ? "No secrets match your search." : "No secrets match your filters."}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
role="table"
|
||||
aria-label="Secrets"
|
||||
|
|
@ -1546,7 +1936,9 @@ export function Secrets() {
|
|||
<div role="columnheader" className="sr-only">Actions</div>
|
||||
</div>
|
||||
<div role="rowgroup">
|
||||
{filteredRows.map((row) => {
|
||||
{showUpRow ? renderUpRow("table") : null}
|
||||
{folderRows.map(renderFolderTableRow)}
|
||||
{secretRows.map((row) => {
|
||||
const status = row.kind === "company" ? row.secret.status : row.definition.status;
|
||||
const updatedAt = row.kind === "company" ? row.secret.updatedAt : row.definition.updatedAt;
|
||||
const updatedTooltip =
|
||||
|
|
@ -1573,9 +1965,7 @@ export function Secrets() {
|
|||
>
|
||||
<div role="cell" className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{row.kind === "company" ? row.secret.name : row.definition.name}
|
||||
</span>
|
||||
{renderSecretName(row.kind === "company" ? row.secret.name : row.definition.name)}
|
||||
{row.kind === "company" ? (
|
||||
<SecretProviderIndicator
|
||||
secret={row.secret}
|
||||
|
|
@ -1635,7 +2025,9 @@ export function Secrets() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-2 @min-[40rem]:hidden" data-testid="secrets-card-view">
|
||||
{filteredRows.map((row) => {
|
||||
{showUpRow ? renderUpRow("card") : null}
|
||||
{folderRows.map(renderFolderCard)}
|
||||
{secretRows.map((row) => {
|
||||
const status = row.kind === "company" ? row.secret.status : row.definition.status;
|
||||
return (
|
||||
<div
|
||||
|
|
@ -1652,8 +2044,8 @@ export function Secrets() {
|
|||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-foreground">
|
||||
{row.kind === "company" ? row.secret.name : row.definition.name}
|
||||
<div className="min-w-0 truncate">
|
||||
{renderSecretName(row.kind === "company" ? row.secret.name : row.definition.name)}
|
||||
</div>
|
||||
<code className="mt-0.5 block truncate text-(length:--text-micro) text-muted-foreground">
|
||||
{row.kind === "company" ? row.secret.key : row.definition.key}
|
||||
|
|
@ -1699,6 +2091,8 @@ export function Secrets() {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -2063,7 +2457,13 @@ export function Secrets() {
|
|||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
if (!open) setCreateNamePrefix(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-(--sz-calc-18) overflow-y-auto p-4 sm:max-w-lg sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingDefinition ? "Edit user-provided secret" : "Create secret"}</DialogTitle>
|
||||
|
|
@ -2113,24 +2513,67 @@ export function Secrets() {
|
|||
|
||||
<div>
|
||||
<label className="text-xs font-medium" htmlFor="new-secret-name">Name</label>
|
||||
<Input
|
||||
id="new-secret-name"
|
||||
value={createForm.name}
|
||||
onChange={(event) => {
|
||||
const name = event.target.value;
|
||||
setCreateForm((current) => ({
|
||||
...current,
|
||||
name,
|
||||
key: createKeyDirty
|
||||
? current.key
|
||||
: secretValueProvider === "user"
|
||||
? normalizeUserSecretKeyForPreview(name)
|
||||
: normalizeSecretKeyForPreview(name),
|
||||
}));
|
||||
}}
|
||||
placeholder={secretValueProvider === "user" ? "Personal GitHub token" : "/dev/foo/bar"}
|
||||
autoFocus
|
||||
/>
|
||||
{createNamePrefix && !editingDefinition ? (
|
||||
<div className="flex h-9 w-full min-w-0 items-center gap-1.5 rounded-md border border-input bg-transparent px-2 shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-3">
|
||||
<span
|
||||
className="inline-flex min-w-0 shrink items-center gap-1 rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground"
|
||||
title={createNamePrefix}
|
||||
>
|
||||
<span className="truncate">{createNamePrefix}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Remove folder prefix"
|
||||
onClick={() => setCreateNamePrefix(null)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
id="new-secret-name"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
value={createForm.name.slice(createNamePrefix.length)}
|
||||
onChange={(event) => {
|
||||
const name = createNamePrefix + event.target.value;
|
||||
setCreateForm((current) => ({
|
||||
...current,
|
||||
name,
|
||||
key: createKeyDirty
|
||||
? current.key
|
||||
: secretValueProvider === "user"
|
||||
? normalizeUserSecretKeyForPreview(name)
|
||||
: normalizeSecretKeyForPreview(name),
|
||||
}));
|
||||
}}
|
||||
placeholder="clientsecret"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
id="new-secret-name"
|
||||
value={createForm.name}
|
||||
onChange={(event) => {
|
||||
const name = event.target.value;
|
||||
setCreateForm((current) => ({
|
||||
...current,
|
||||
name,
|
||||
key: createKeyDirty
|
||||
? current.key
|
||||
: secretValueProvider === "user"
|
||||
? normalizeUserSecretKeyForPreview(name)
|
||||
: normalizeSecretKeyForPreview(name),
|
||||
}));
|
||||
}}
|
||||
placeholder={secretValueProvider === "user" ? "Personal GitHub token" : "/dev/foo/bar"}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
{createNamePrefix && !editingDefinition ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Creating in {folderPath} — remove the chip to type a different path.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{secretValueProvider === "company" && createMode === "managed" ? (
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { queryKeys } from "../../lib/queryKeys";
|
|||
import { cn } from "../../lib/utils";
|
||||
import { useToastActions } from "../../context/ToastContext";
|
||||
import { SetMyUserSecretDialog } from "./SetMyUserSecretDialog";
|
||||
import { SecretPathName } from "./SecretPathName";
|
||||
import {
|
||||
myValueLabel,
|
||||
myValueState,
|
||||
|
|
@ -136,7 +137,7 @@ function MyUserSecretRow({
|
|||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="font-medium text-foreground">{definition.name}</span>
|
||||
<SecretPathName name={definition.name} />
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-(length:--text-micro) text-muted-foreground">
|
||||
{definition.key}
|
||||
</code>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { splitSecretPath } from "./secret-path";
|
||||
|
||||
interface SecretPathNameProps {
|
||||
/** Full stored secret name, e.g. `dev/github/oauth/clientid`. */
|
||||
name: string;
|
||||
/**
|
||||
* Folder path currently being viewed. Segments shared with `name` are
|
||||
* stripped so folder-view rows show only the part below the open folder.
|
||||
* Omit (or leave empty) to render the full path — used for global search
|
||||
* results and the My secrets tab.
|
||||
*/
|
||||
basePath?: string;
|
||||
className?: string;
|
||||
/** Extra classes for the bold leaf segment. */
|
||||
leafClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a slash-delimited secret name with the directory portion muted and
|
||||
* the trailing leaf bold. Shared across folder-view rows, global search
|
||||
* results, and (rendering only) the My secrets tab so paths read the same way
|
||||
* everywhere. See PAP-14698 plan §Search.
|
||||
*/
|
||||
export function SecretPathName({ name, basePath = "", className, leafClassName }: SecretPathNameProps) {
|
||||
const segments = splitSecretPath(name);
|
||||
const baseSegments = splitSecretPath(basePath);
|
||||
const withinBase =
|
||||
baseSegments.length > 0 &&
|
||||
baseSegments.every((segment, index) => segments[index] === segment) &&
|
||||
segments.length > baseSegments.length;
|
||||
const relative = withinBase ? segments.slice(baseSegments.length) : segments;
|
||||
const effective = relative.length > 0 ? relative : [name];
|
||||
const leaf = effective[effective.length - 1];
|
||||
const directory = effective.slice(0, -1).join("/");
|
||||
|
||||
return (
|
||||
<span className={cn("min-w-0 truncate", className)}>
|
||||
{directory ? <span className="text-muted-foreground">{directory}/</span> : null}
|
||||
<span className={cn("font-medium text-foreground", leafClassName)}>{leaf}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildSecretPathBreadcrumbs,
|
||||
buildSecretPathListing,
|
||||
getSecretPathRowName,
|
||||
normalizeSecretPath,
|
||||
validateSecretFolderSegment,
|
||||
type SecretPathRow,
|
||||
} from "./secret-path";
|
||||
|
||||
type TestRow = SecretPathRow & { id: string };
|
||||
|
||||
function companyRow(id: string, name: string): TestRow {
|
||||
return { id, kind: "company", secret: { name } };
|
||||
}
|
||||
|
||||
function userRow(id: string, name: string): TestRow {
|
||||
return { id, kind: "user", definition: { name } };
|
||||
}
|
||||
|
||||
describe("secret path normalization", () => {
|
||||
it("ignores leading, duplicate, and trailing slashes without changing stored names", () => {
|
||||
const rows = [
|
||||
companyRow("github", "/dev//github/"),
|
||||
companyRow("token", "//prod///api//token/"),
|
||||
userRow("standalone", "/standalone/"),
|
||||
];
|
||||
|
||||
expect(normalizeSecretPath("//dev///github/")).toBe("dev/github");
|
||||
expect(buildSecretPathListing(rows, "/")).toEqual({
|
||||
folders: [
|
||||
{ name: "dev", path: "dev", secretCount: 1, folderCount: 0 },
|
||||
{ name: "prod", path: "prod", secretCount: 1, folderCount: 1 },
|
||||
],
|
||||
secrets: [rows[2]],
|
||||
});
|
||||
expect(getSecretPathRowName(rows[0])).toBe("/dev//github/");
|
||||
});
|
||||
|
||||
it("keeps no-slash names at the root", () => {
|
||||
const rows = [companyRow("one", "alpha"), userRow("two", "beta")];
|
||||
|
||||
expect(buildSecretPathListing(rows, "")).toEqual({ folders: [], secrets: rows });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSecretPathListing", () => {
|
||||
it("shows a name as both a direct secret and a folder when it is also a prefix", () => {
|
||||
const exact = companyRow("exact", "dev");
|
||||
const nested = companyRow("nested", "dev/oauth/token");
|
||||
|
||||
expect(buildSecretPathListing([exact, nested], "")).toEqual({
|
||||
folders: [{ name: "dev", path: "dev", secretCount: 1, folderCount: 1 }],
|
||||
secrets: [exact],
|
||||
});
|
||||
expect(buildSecretPathListing([exact, nested], "dev")).toEqual({
|
||||
folders: [{ name: "oauth", path: "dev/oauth", secretCount: 1, folderCount: 0 }],
|
||||
secrets: [exact],
|
||||
});
|
||||
});
|
||||
|
||||
it("groups case-sensitively while sorting case-insensitively", () => {
|
||||
const rows = [
|
||||
companyRow("lower-child", "dev/token"),
|
||||
companyRow("upper-child", "Dev/token"),
|
||||
companyRow("zebra", "zebra"),
|
||||
companyRow("alpha-upper", "Alpha"),
|
||||
companyRow("alpha-lower", "alpha"),
|
||||
];
|
||||
const listing = buildSecretPathListing(rows, "");
|
||||
|
||||
expect(listing.folders.map((folder) => folder.name)).toEqual(["dev", "Dev"]);
|
||||
expect(listing.secrets.map(getSecretPathRowName)).toEqual(["Alpha", "alpha", "zebra"]);
|
||||
});
|
||||
|
||||
it("naturally sorts folders and secrets", () => {
|
||||
const rows = [
|
||||
companyRow("env10", "env10/token"),
|
||||
companyRow("env2", "env2/token"),
|
||||
companyRow("key10", "key10"),
|
||||
companyRow("key2", "key2"),
|
||||
];
|
||||
const listing = buildSecretPathListing(rows, "");
|
||||
|
||||
expect(listing.folders.map((folder) => folder.name)).toEqual(["env2", "env10"]);
|
||||
expect(listing.secrets.map(getSecretPathRowName)).toEqual(["key2", "key10"]);
|
||||
});
|
||||
|
||||
it("preserves encoded and special characters in segments and breadcrumbs", () => {
|
||||
const row = companyRow("special", "team & ops/100%/api?key=a%2Fb");
|
||||
|
||||
expect(buildSecretPathListing([row], "team & ops").folders).toEqual([
|
||||
{
|
||||
name: "100%",
|
||||
path: "team & ops/100%",
|
||||
secretCount: 1,
|
||||
folderCount: 0,
|
||||
},
|
||||
]);
|
||||
expect(buildSecretPathBreadcrumbs("/team & ops//100%/api?key=a%2Fb/")).toEqual([
|
||||
{ name: "team & ops", path: "team & ops" },
|
||||
{ name: "100%", path: "team & ops/100%" },
|
||||
{ name: "api?key=a%2Fb", path: "team & ops/100%/api?key=a%2Fb" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("computes recursive secret and folder counts from the provided rows", () => {
|
||||
const allRows = [
|
||||
companyRow("direct", "dev/direct"),
|
||||
companyRow("a", "dev/a/token"),
|
||||
companyRow("b", "dev/b/token"),
|
||||
companyRow("deep", "dev/b/deep/token"),
|
||||
companyRow("filtered", "dev/c/hidden"),
|
||||
];
|
||||
const filteredRows = allRows.filter((row) => row.id !== "filtered");
|
||||
|
||||
expect(buildSecretPathListing(filteredRows, "").folders).toEqual([
|
||||
{ name: "dev", path: "dev", secretCount: 4, folderCount: 3 },
|
||||
]);
|
||||
expect(buildSecretPathListing(filteredRows, "dev")).toEqual({
|
||||
folders: [
|
||||
{ name: "a", path: "dev/a", secretCount: 1, folderCount: 0 },
|
||||
{ name: "b", path: "dev/b", secretCount: 2, folderCount: 1 },
|
||||
],
|
||||
secrets: [allRows[0]],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateSecretFolderSegment", () => {
|
||||
it("rejects empty and slash-containing folder segments", () => {
|
||||
expect(validateSecretFolderSegment(" ")).toBe("Folder name is required.");
|
||||
expect(validateSecretFolderSegment("dev/prod")).toBe(
|
||||
"Folder name cannot contain slashes.",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts trimmed and special-character segment names", () => {
|
||||
expect(validateSecretFolderSegment(" dev tools ")).toBeNull();
|
||||
expect(validateSecretFolderSegment("100% & encoded%2Fvalue")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
export type SecretPathRow =
|
||||
| { kind: "company"; secret: { name: string } }
|
||||
| { kind: "user"; definition: { name: string } };
|
||||
|
||||
export interface SecretPathFolder {
|
||||
name: string;
|
||||
path: string;
|
||||
secretCount: number;
|
||||
folderCount: number;
|
||||
}
|
||||
|
||||
export interface SecretPathBreadcrumb {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface SecretPathListing<Row extends SecretPathRow> {
|
||||
folders: SecretPathFolder[];
|
||||
secrets: Row[];
|
||||
}
|
||||
|
||||
const NATURAL_NAME_SORT_OPTIONS: Intl.CollatorOptions = {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
};
|
||||
|
||||
export function getSecretPathRowName(row: SecretPathRow): string {
|
||||
return row.kind === "company" ? row.secret.name : row.definition.name;
|
||||
}
|
||||
|
||||
export function splitSecretPath(path: string): string[] {
|
||||
return path.split("/").filter((segment) => segment.length > 0);
|
||||
}
|
||||
|
||||
export function normalizeSecretPath(path: string): string {
|
||||
return splitSecretPath(path).join("/");
|
||||
}
|
||||
|
||||
function startsWithSegments(segments: readonly string[], prefix: readonly string[]): boolean {
|
||||
return prefix.every((segment, index) => segments[index] === segment);
|
||||
}
|
||||
|
||||
function compareNames(left: string, right: string): number {
|
||||
return left.localeCompare(right, undefined, NATURAL_NAME_SORT_OPTIONS);
|
||||
}
|
||||
|
||||
export function buildSecretPathListing<Row extends SecretPathRow>(
|
||||
rows: readonly Row[],
|
||||
path: string,
|
||||
): SecretPathListing<Row> {
|
||||
const pathSegments = splitSecretPath(path);
|
||||
const entries = rows.map((row) => ({
|
||||
row,
|
||||
name: getSecretPathRowName(row),
|
||||
segments: splitSecretPath(getSecretPathRowName(row)),
|
||||
}));
|
||||
const folderNames = new Set<string>();
|
||||
const secrets: Row[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!startsWithSegments(entry.segments, pathSegments)) continue;
|
||||
const relativeDepth = entry.segments.length - pathSegments.length;
|
||||
if (relativeDepth === 0 || relativeDepth === 1) secrets.push(entry.row);
|
||||
if (relativeDepth >= 2) folderNames.add(entry.segments[pathSegments.length]);
|
||||
}
|
||||
|
||||
const folders = [...folderNames].map((name): SecretPathFolder => {
|
||||
const folderSegments = [...pathSegments, name];
|
||||
const descendantFolderPaths = new Set<string>();
|
||||
let secretCount = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!startsWithSegments(entry.segments, folderSegments)) continue;
|
||||
const relativeDepth = entry.segments.length - folderSegments.length;
|
||||
if (relativeDepth < 1) continue;
|
||||
secretCount += 1;
|
||||
for (let depth = 1; depth < relativeDepth; depth += 1) {
|
||||
descendantFolderPaths.add(
|
||||
entry.segments.slice(folderSegments.length, folderSegments.length + depth).join("/"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
path: folderSegments.join("/"),
|
||||
secretCount,
|
||||
folderCount: descendantFolderPaths.size,
|
||||
};
|
||||
});
|
||||
|
||||
folders.sort((left, right) => compareNames(left.name, right.name));
|
||||
secrets.sort((left, right) =>
|
||||
compareNames(getSecretPathRowName(left), getSecretPathRowName(right)),
|
||||
);
|
||||
|
||||
return { folders, secrets };
|
||||
}
|
||||
|
||||
export function buildSecretPathBreadcrumbs(path: string): SecretPathBreadcrumb[] {
|
||||
const segments = splitSecretPath(path);
|
||||
return segments.map((name, index) => ({
|
||||
name,
|
||||
path: segments.slice(0, index + 1).join("/"),
|
||||
}));
|
||||
}
|
||||
|
||||
export function validateSecretFolderSegment(value: string): string | null {
|
||||
if (!value.trim()) return "Folder name is required.";
|
||||
if (value.includes("/")) return "Folder name cannot contain slashes.";
|
||||
return null;
|
||||
}
|
||||
Loading…
Reference in New Issue