Let operators hide the Provider vaults and Proposals tabs (#12284)

## 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
This commit is contained in:
Devin Foley 2026-08-27 11:28:15 -07:00 committed by GitHub
parent 325041cb00
commit e34ed9801b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 170 additions and 25 deletions

View File

@ -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

View File

@ -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,

View File

@ -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);

View File

@ -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<string>,
section: HideableCompanySection,
): boolean {
return hidden.has(section);
}
export function hidesGeneralSection(
hidden: ReadonlySet<string>,
section: HideableGeneralSection,

View File

@ -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(
<MemoryRouter initialEntries={["/"]}>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
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());
});
});

View File

@ -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<SecretsTab>("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<SecretStatus | "all">("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: (
<span className="inline-flex items-center gap-1.5">
Proposals
{pendingProposalCount > 0 ? (
<Badge
variant="outline"
className="h-4 min-w-4 justify-center rounded-full border-amber-500/40 bg-amber-500/10 px-1 text-(length:--text-nano) font-medium text-amber-700 dark:text-amber-300"
>
{pendingProposalCount}
</Badge>
) : null}
</span>
),
},
...(hideVaultsTab ? [] : [{ value: "vaults", label: "Provider vaults" }]),
...(hideProposalsTab
? []
: [
{
value: "proposals",
label: (
<span className="inline-flex items-center gap-1.5">
Proposals
{pendingProposalCount > 0 ? (
<Badge
variant="outline"
className="h-4 min-w-4 justify-center rounded-full border-amber-500/40 bg-amber-500/10 px-1 text-(length:--text-nano) font-medium text-amber-700 dark:text-amber-300"
>
{pendingProposalCount}
</Badge>
) : null}
</span>
),
},
]),
]}
align="start"
value={activeTab}
@ -1884,7 +1899,7 @@ export function Secrets() {
<ImportFromVaultButton
providerConfigs={providerConfigs}
onClick={() => openImportFromVault()}
onManageVaults={() => setActiveTab("vaults")}
onManageVaults={hideVaultsTab ? undefined : () => setActiveTab("vaults")}
className="ml-auto"
/>
{showFolderView ? (
@ -2187,6 +2202,7 @@ export function Secrets() {
>
<MyUserSecretsTab companyId={selectedCompanyId} />
</TabsContent>
{!hideVaultsTab && (
<TabsContent value="vaults">
<ProviderVaultsTab
providers={providers}
@ -2210,11 +2226,14 @@ export function Secrets() {
}
/>
</TabsContent>
)}
{!hideProposalsTab && (
<TabsContent value="proposals">
{selectedCompanyId ? (
<ProposalsTab companyId={selectedCompanyId} providerConfigs={providerConfigs} />
) : null}
</TabsContent>
)}
</Tabs>
<Sheet
@ -2535,11 +2554,15 @@ export function Secrets() {
providerConfigs={providerConfigs}
existingSecrets={secrets}
initialProviderConfigId={importInitialVaultId}
onManageVaults={() => {
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 (
<Button
variant="ghost"