From e34ed9801be5a8911a327b57ec13e87c8334ccaa Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 27 Aug 2026 11:28:15 -0700 Subject: [PATCH] Let operators hide the Provider vaults and Proposals tabs (#12284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Hosting operators (a managed cloud, an internal shared server) tune the settings surface with `PAPERCLIP_HIDDEN_SETTINGS`, which today hides whole pages > - The Secrets page bundles four tabs, and two of them — Provider vaults and Proposals — do not apply to deployments where the operator provisions provider credentials itself > - Hiding the whole Secrets page is too coarse: the Secrets and My secrets tabs stay essential everywhere > - This pull request adds per-tab visibility keys (`company.secrets.vaults`, `company.secrets.proposals`) as a new company-section registry group > - The benefit is that any hosting operator can trim the Secrets page to what fits their deployment, with self-hosted behavior unchanged by default ## Linked Issues or Issue Description No public issue exists; following the enhancement template: **What existing behavior does this improve?** `PAPERCLIP_HIDDEN_SETTINGS` can hide the whole Secrets page (`company.secrets`) but not individual tabs. Operators of managed deployments need to hide the Provider vaults and Proposals tabs while keeping the rest of the page. **Subsystem affected** Settings visibility (`packages/shared/src/settings-visibility.ts`) and the Secrets page UI (`ui/src/pages/Secrets.tsx`). **Current behavior** The Secrets page always renders all four tabs (Secrets, My secrets, Provider vaults, Proposals), polls pending proposals for the badge, and offers "manage vaults" affordances that jump to the vaults tab. **Proposed behavior** Two new registry keys, `company.secrets.vaults` and `company.secrets.proposals`, hide the corresponding tab: the tab-bar entry disappears, an active hidden tab snaps back to Secrets, the manage-vaults affordances are suppressed, and the pending-proposals poll stops. UI visibility only — the provider-config and proposal APIs stay live for agents and integrations, matching the existing `company.*` precedent. Nothing changes when the variable is unset. **Reason and benefit** Any hosting operator (a managed cloud, an internal shared server) can trim the Secrets page to what fits their deployment — for example when the operator provisions provider credentials itself, so the vault and proposal flows do not apply — without losing the Secrets and My secrets tabs, which stay essential everywhere. **Breaking changes** None. With `PAPERCLIP_HIDDEN_SETTINGS` unset (or set to existing keys only) nothing changes; older app versions receiving the new keys ignore them with a warning by design. ## What Changed - `packages/shared/src/settings-visibility.ts`: new `HIDEABLE_COMPANY_SECTIONS` group (`company.secrets.vaults`, `company.secrets.proposals`), `HideableCompanySection` type, `hidesCompanySection()` helper, wired into `HideableSettingKey` / `HIDEABLE_SETTING_KEYS`, re-exported from the package index. - `ui/src/pages/Secrets.tsx`: tab-bar filtering, hidden-tab snap-back effect, gated pending-proposals query, conditional `onManageVaults` on both the import button and dialog (the button's "AWS vault disabled — manage" affordance renders nothing when vaults are hidden), hidden `TabsContent` blocks. - Docs: new bullet in `docs/deploy/environment-variables.md` under "Hiding settings surfaces". - Tests: registry membership/parse cases in `settings-visibility.test.ts`; new render cases in `Secrets.render.test.tsx` (hidden tabs absent + proposals poll skipped; default render keeps both tabs and the poll). ## Verification - `npx vitest run packages/shared/src/settings-visibility.test.ts ui/src/pages/Secrets.render.test.tsx` — 43 tests passing. - `pnpm --filter @paperclipai/shared typecheck` and `pnpm --filter @paperclipai/ui typecheck` — clean. ## Risks - Low. Nothing changes with `PAPERCLIP_HIDDEN_SETTINGS` unset (covered by the default-render test). The keys are UI-visibility only, so agent/integration API access is unaffected. Older app versions receiving the new keys ignore them with a warning by design. ## Model Used Claude (Anthropic), model id `claude-fable-5`, extended thinking, agentic tool use via Claude Code. ## 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) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- docs/deploy/environment-variables.md | 4 + packages/shared/src/index.ts | 3 + .../shared/src/settings-visibility.test.ts | 16 ++++ packages/shared/src/settings-visibility.ts | 22 ++++++ ui/src/pages/Secrets.render.test.tsx | 75 +++++++++++++++++++ ui/src/pages/Secrets.tsx | 75 ++++++++++++------- 6 files changed, 170 insertions(+), 25 deletions(-) diff --git a/docs/deploy/environment-variables.md b/docs/deploy/environment-variables.md index dc0bcbe8f3..134f372f28 100644 --- a/docs/deploy/environment-variables.md +++ b/docs/deploy/environment-variables.md @@ -50,6 +50,10 @@ All environment variables that Paperclip uses for server configuration. every company-import route with `403 settings_operator_managed`. On cloud-managed instances import is floored unconditionally with `403 cloud_managed`, independent of this variable. +- A single tab of the Secrets page: `company.secrets.vaults` (Provider + vaults) and `company.secrets.proposals` (Proposals) — the tab disappears + while the rest of the page stays up. UI-visibility only; the secret + provider-config and proposal APIs stay live for agents and integrations. Unknown keys are logged and ignored, so one list can be rolled across a fleet of mixed app versions. With the variable unset nothing is hidden and behavior diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index fd40b579af..709acf468c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2506,6 +2506,7 @@ export { } from "./feature-catalog.js"; export { HIDEABLE_COMPANY_PAGES, + HIDEABLE_COMPANY_SECTIONS, HIDEABLE_GENERAL_SECTIONS, HIDEABLE_INSTANCE_PAGES, HIDEABLE_SETTING_KEYS, @@ -2513,11 +2514,13 @@ export { UI_ONLY_GENERAL_SECTIONS, experimentalSettingKey, hidesCompanyPage, + hidesCompanySection, hidesExperimentalSetting, hidesGeneralSection, hidesInstancePage, parseHiddenSettingsList, type HideableCompanyPage, + type HideableCompanySection, type HideableExperimentalSetting, type HideableGeneralSection, type HideableInstancePage, diff --git a/packages/shared/src/settings-visibility.test.ts b/packages/shared/src/settings-visibility.test.ts index ece26654ee..0184922a9d 100644 --- a/packages/shared/src/settings-visibility.test.ts +++ b/packages/shared/src/settings-visibility.test.ts @@ -2,11 +2,13 @@ import { describe, expect, it } from "vitest"; import { INSTANCE_FEATURE_KEYS } from "./feature-catalog.js"; import { HIDEABLE_COMPANY_PAGES, + HIDEABLE_COMPANY_SECTIONS, HIDEABLE_GENERAL_SECTIONS, HIDEABLE_SETTING_KEYS, UI_ONLY_GENERAL_SECTIONS, experimentalSettingKey, hidesCompanyPage, + hidesCompanySection, hidesExperimentalSetting, hidesGeneralSection, hidesInstancePage, @@ -79,6 +81,20 @@ describe("membership helpers", () => { expect(hidesCompanyPage(companyHidden, "company.export")).toBe(false); }); + it("answers company-section membership independently of the parent page", () => { + const sectionHidden = new Set(parseHiddenSettingsList("company.secrets.vaults").hidden); + expect(hidesCompanySection(sectionHidden, "company.secrets.vaults")).toBe(true); + expect(hidesCompanySection(sectionHidden, "company.secrets.proposals")).toBe(false); + expect(hidesCompanyPage(sectionHidden, "company.secrets")).toBe(false); + }); + + it("keeps every company section key parseable and prefixed by its page", () => { + for (const key of HIDEABLE_COMPANY_SECTIONS) { + expect(parseHiddenSettingsList(key).hidden).toEqual([key]); + expect(key.startsWith("company.")).toBe(true); + } + }); + it("answers page, section, and experimental membership", () => { expect(hidesInstancePage(hidden, "instance.plugins")).toBe(true); expect(hidesInstancePage(hidden, "instance.adapters")).toBe(false); diff --git a/packages/shared/src/settings-visibility.ts b/packages/shared/src/settings-visibility.ts index f7fa75c583..40588531e9 100644 --- a/packages/shared/src/settings-visibility.ts +++ b/packages/shared/src/settings-visibility.ts @@ -58,6 +58,19 @@ export const HIDEABLE_COMPANY_PAGES = [ export type HideableCompanyPage = (typeof HIDEABLE_COMPANY_PAGES)[number]; +/** + * Sub-surfaces of company settings pages that can be hidden individually. + * UI-visibility keys only: the backing APIs stay live for agents and + * integrations. Hiding the whole page (`company.secrets`) already removes + * everything inside it; these keys hide one tab while the page stays up. + */ +export const HIDEABLE_COMPANY_SECTIONS = [ + "company.secrets.vaults", + "company.secrets.proposals", +] as const; + +export type HideableCompanySection = (typeof HIDEABLE_COMPANY_SECTIONS)[number]; + /** * Sections of Instance → General that can be hidden. Field-backed sections * (their suffix names a general-settings field) also floor writes to that @@ -90,6 +103,7 @@ export function experimentalSettingKey(key: InstanceFeatureKey): HideableExperim export type HideableSettingKey = | HideableInstancePage | HideableCompanyPage + | HideableCompanySection | HideableGeneralSection | HideableExperimentalSetting; @@ -97,6 +111,7 @@ export type HideableSettingKey = export const HIDEABLE_SETTING_KEYS: readonly HideableSettingKey[] = [ ...HIDEABLE_INSTANCE_PAGES, ...HIDEABLE_COMPANY_PAGES, + ...HIDEABLE_COMPANY_SECTIONS, ...HIDEABLE_GENERAL_SECTIONS, ...INSTANCE_FEATURE_KEYS.map(experimentalSettingKey), ]; @@ -145,6 +160,13 @@ export function hidesCompanyPage( return hidden.has(page); } +export function hidesCompanySection( + hidden: ReadonlySet, + section: HideableCompanySection, +): boolean { + return hidden.has(section); +} + export function hidesGeneralSection( hidden: ReadonlySet, section: HideableGeneralSection, diff --git a/ui/src/pages/Secrets.render.test.tsx b/ui/src/pages/Secrets.render.test.tsx index 9e945ba3ba..a14c5ea1e6 100644 --- a/ui/src/pages/Secrets.render.test.tsx +++ b/ui/src/pages/Secrets.render.test.tsx @@ -16,6 +16,7 @@ import type { import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ProviderVaultsTab, Secrets } from "./Secrets"; import { ApiError } from "../api/client"; +import { queryKeys } from "../lib/queryKeys"; const mockSecretsApi = vi.hoisted(() => ({ list: vi.fn(), @@ -1982,3 +1983,77 @@ describe("Secrets folder view (PAP-14698)", () => { await act(async () => root.unmount()); }); }); + +describe("Secrets operator-hidden tabs", () => { + let container: HTMLDivElement; + + function seedSecrets() { + mockSecretsApi.list.mockResolvedValue([makeCompanySecret({ id: "s1", key: "api_key", name: "api_key" })]); + mockSecretsApi.providers.mockResolvedValue(providers); + mockSecretsApi.providerHealth.mockResolvedValue({ providers: [] }); + mockSecretsApi.providerConfigs.mockResolvedValue(providerConfigs); + mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([]); + mockSecretsApi.userSecretDefinitionCoverage.mockResolvedValue(userSecretCoverage); + mockSecretsApi.listMyUserSecrets.mockResolvedValue([]); + mockSecretsApi.listProposals.mockResolvedValue([]); + mockAgentsApi.list.mockResolvedValue([]); + } + + async function renderWithHiddenSettings(hiddenSettings: string[]) { + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + queryClient.setQueryData(queryKeys.health, { hiddenSettings } as never); + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + return root; + } + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + seedSecrets(); + }); + + afterEach(() => { + unmountActiveRoots(); + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("hides the Provider vaults and Proposals tabs and skips the proposals poll", async () => { + const root = await renderWithHiddenSettings([ + "company.secrets.vaults", + "company.secrets.proposals", + ]); + + const tabLabels = [...container.querySelectorAll('[role="tab"]')].map((tab) => tab.textContent); + expect(tabLabels.join(" ")).toContain("Secrets"); + expect(tabLabels.join(" ")).toContain("My secrets"); + expect(tabLabels.join(" ")).not.toContain("Provider vaults"); + expect(tabLabels.join(" ")).not.toContain("Proposals"); + expect(mockSecretsApi.listProposals).not.toHaveBeenCalled(); + + await act(async () => root.unmount()); + }); + + it("keeps both tabs when nothing is hidden", async () => { + const root = await renderWithHiddenSettings([]); + + const tabLabels = [...container.querySelectorAll('[role="tab"]')].map((tab) => tab.textContent); + expect(tabLabels.join(" ")).toContain("Provider vaults"); + expect(tabLabels.join(" ")).toContain("Proposals"); + expect(mockSecretsApi.listProposals).toHaveBeenCalled(); + + await act(async () => root.unmount()); + }); +}); diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx index 7d4698d79a..9ac9c98605 100644 --- a/ui/src/pages/Secrets.tsx +++ b/ui/src/pages/Secrets.tsx @@ -51,7 +51,9 @@ import type { UserSecretCoverageSummary, UserSecretDefinition, } from "@paperclipai/shared"; +import { hidesCompanySection } from "@paperclipai/shared"; import { useCompany } from "../context/CompanyContext"; +import { useHiddenSettings } from "../hooks/useHiddenSettings"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToastActions } from "../context/ToastContext"; import { @@ -652,6 +654,15 @@ export function Secrets() { const { setBreadcrumbs } = useBreadcrumbs(); const { pushToast } = useToastActions(); const [activeTab, setActiveTab] = useState("secrets"); + // Operator-hidden sub-tabs (UI-only; the secrets APIs stay live for agents). + const { hidden: hiddenSettings } = useHiddenSettings(); + const hideVaultsTab = hidesCompanySection(hiddenSettings, "company.secrets.vaults"); + const hideProposalsTab = hidesCompanySection(hiddenSettings, "company.secrets.proposals"); + useEffect(() => { + if ((activeTab === "vaults" && hideVaultsTab) || (activeTab === "proposals" && hideProposalsTab)) { + setActiveTab("secrets"); + } + }, [activeTab, hideVaultsTab, hideProposalsTab]); const [secretDetailTab, setSecretDetailTab] = useState("details"); const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("active"); @@ -779,7 +790,7 @@ export function Secrets() { ? queryKeys.secrets.proposals(selectedCompanyId, "pending") : ["secret-proposals", "__disabled__"], queryFn: () => secretsApi.listProposals(selectedCompanyId!, "pending"), - enabled: Boolean(selectedCompanyId), + enabled: Boolean(selectedCompanyId) && !hideProposalsTab, }); const secrets = secretsQuery.data ?? EMPTY_SECRETS; @@ -1808,23 +1819,27 @@ export function Secrets() { items={[ { value: "secrets", label: "Secrets" }, { value: "my-secrets", label: "My secrets" }, - { value: "vaults", label: "Provider vaults" }, - { - value: "proposals", - label: ( - - Proposals - {pendingProposalCount > 0 ? ( - - {pendingProposalCount} - - ) : null} - - ), - }, + ...(hideVaultsTab ? [] : [{ value: "vaults", label: "Provider vaults" }]), + ...(hideProposalsTab + ? [] + : [ + { + value: "proposals", + label: ( + + Proposals + {pendingProposalCount > 0 ? ( + + {pendingProposalCount} + + ) : null} + + ), + }, + ]), ]} align="start" value={activeTab} @@ -1884,7 +1899,7 @@ export function Secrets() { openImportFromVault()} - onManageVaults={() => setActiveTab("vaults")} + onManageVaults={hideVaultsTab ? undefined : () => setActiveTab("vaults")} className="ml-auto" /> {showFolderView ? ( @@ -2187,6 +2202,7 @@ export function Secrets() { > + {!hideVaultsTab && ( + )} + {!hideProposalsTab && ( {selectedCompanyId ? ( ) : null} + )} { - setImportOpen(false); - setImportInitialVaultId(null); - setActiveTab("vaults"); - }} + onManageVaults={ + hideVaultsTab + ? undefined + : () => { + setImportOpen(false); + setImportInitialVaultId(null); + setActiveTab("vaults"); + } + } onImportComplete={() => { void secretsQuery.refetch(); }} @@ -3445,7 +3468,8 @@ function ProviderVaultInlineWarning({ config }: { config: CompanySecretProviderC interface ImportFromVaultButtonProps { providerConfigs: CompanySecretProviderConfig[]; onClick: () => void; - onManageVaults: () => void; + /** Absent when the operator hides the Provider vaults tab. */ + onManageVaults?: () => void; className?: string; } @@ -3465,6 +3489,7 @@ function ImportFromVaultButton({ if (awsConfigs.length === 0) return null; if (eligible.length === 0) { + if (!onManageVaults) return null; return (