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 (