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:
parent
325041cb00
commit
e34ed9801b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue