fix(ui): honor PAPERCLIP_HIDDEN_SETTINGS in the production switcher menu (#12788)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Operators control which settings surfaces appear via the
`PAPERCLIP_HIDDEN_SETTINGS` env var (keys like `company.invites`,
`company.members`)
> - The sidebar organization switcher has an "Invite people" shortcut
that points at the company Invites surface
> - The streamlined switcher menu already hides that shortcut when the
Invites/Members surface is hidden, but the production-shell menu
(rendered when the streamlined UI is disabled) renders the invite row
unconditionally
> - So an operator that hides the Invites surface still sees the
shortcut in the production shell — the hide is not fully honored
> - This pull request gates the production menu's invite shortcut on the
same hide keys, so `PAPERCLIP_HIDDEN_SETTINGS` controls it in both
shells
> - The benefit is one consistent, per-deployment knob: a hoster that
wants the shortcut gone (e.g. Paperclip Cloud, whose managed stacks set
`company.invites`) drops it by setting the env var, and every other
hoster keeps it by leaving the key unset

## Linked Issues or Issue Description

No existing issue. Description follows the enhancement template:

**What existing behavior does this improve?**
`PAPERCLIP_HIDDEN_SETTINGS` coverage for the organization switcher's
"Invite people" shortcut in the production shell.

**Subsystem affected**
UI — `ui/src/components/SidebarCompanyMenu.production.tsx`.

**Current behavior**
The streamlined switcher menu hides the "Invite people" shortcut when
`company.invites` or `company.members` is hidden. The production-shell
menu renders the invite row unconditionally, so the hide keys have no
effect there.

**Proposed behavior**
The production menu computes `showInvitePeople` from the same hide keys
and gates the invite row on it. With no hidden settings (the default)
the shortcut still shows; hiding either surface removes it in both
shells.

**Reason and benefit**
This is the per-deployment knob operators already use for the Invites
surface. Making the production shell honor it gives one consistent
mechanism: Paperclip Cloud drops the shortcut on its managed stacks
(which set `company.invites`, because the managed invite accept flow is
being overhauled), while other hosters keep it by leaving the key unset
— no cloud-specific branching in the app.

**Breaking changes**
None. Default behavior (no hidden settings) is unchanged; this only
makes an existing env var take effect where it previously did not.

## What Changed

- `SidebarCompanyMenu.production.tsx` imports `useHiddenSettings` +
`hidesCompanyPage`, computes `showInvitePeople` exactly as the
streamlined menu does, and renders the invite row only when it is true.
- Tests: the production shell shows the shortcut by default and hides it
when `company.invites` is hidden.
- The streamlined menu is unchanged (it already honored the keys).

## Verification

- `cd ui && npx vitest run src/components/SidebarCompanyMenu.test.tsx` —
20 tests pass.
- `cd ui && npx tsc -p tsconfig.json --noEmit` — clean.
- Manual: with `PAPERCLIP_HIDDEN_SETTINGS=company.invites`, the
switcher's "Invite people" row is absent in both the streamlined and
production shells; with the key unset it is present in both.

## Risks

Low risk. UI-only visibility change; default (no hidden settings) is
unchanged, and it only extends an existing, documented env var to a
shell that was missing it.

## Model Used

Claude Fable 5 (`claude-fable-5`, Anthropic), 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-09-03 17:39:56 -07:00 committed by GitHub
parent a661caf74e
commit 446577d174
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 84 additions and 18 deletions

View File

@ -20,7 +20,7 @@ import {
} from "@dnd-kit/core";
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { Company } from "@paperclipai/shared";
import { hidesCompanyPage, type Company } from "@paperclipai/shared";
import { Link, useLocation, useNavigate } from "@/lib/router";
import { authApi } from "@/api/auth";
import { cloudApi, type CloudStackSummary } from "@/api/cloud";
@ -36,6 +36,7 @@ import {
import { useCompany } from "@/context/CompanyContext";
import { useDialogActions } from "@/context/DialogContext";
import { useCloudInstance } from "@/hooks/useCloudInstance";
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
import { useCompanyOrder } from "@/hooks/useCompanyOrder";
import { useSignOut } from "@/hooks/useSignOut";
import { navigateTopLevel } from "@/lib/browserNavigation";
@ -234,6 +235,18 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
// exactly one company, and switching means leaving this tenant host entirely.
const cloud = useCloudInstance();
const isCloud = Boolean(cloud);
// The invite shortcut points at the company Invites surface, so an operator
// that hides that surface via PAPERCLIP_HIDDEN_SETTINGS (company.invites or
// company.members) hides this shortcut too. This is the per-deployment knob
// Paperclip Cloud uses to drop the shortcut on its managed stacks while
// other hosters keep it; the streamlined menu already honors it, this shell
// was the gap. Until the health response resolves the hidden set is unknown
// — keep the shortcut out rather than flash it.
const { hidden: hiddenSettings, loaded: hiddenSettingsLoaded } = useHiddenSettings();
const showInvitePeople =
hiddenSettingsLoaded &&
!hidesCompanyPage(hiddenSettings, "company.members") &&
!hidesCompanyPage(hiddenSettings, "company.invites");
const cloudBaseUrl = cloud?.cloudBaseUrl ?? null;
const stacksQuery = useQuery({
queryKey: queryKeys.cloud.stacks,
@ -477,23 +490,25 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild disabled={isEditingOrder}>
<Link
to="/company/settings/invites"
onClick={(event) => {
if (isEditingOrder) {
event.preventDefault();
return;
}
closeNavigationChrome();
}}
>
<UserPlus className="size-4" />
<span className="truncate">
{currentName ? `Invite people to ${currentName}` : "Invite people"}
</span>
</Link>
</DropdownMenuItem>
{showInvitePeople ? (
<DropdownMenuItem asChild disabled={isEditingOrder}>
<Link
to="/company/settings/invites"
onClick={(event) => {
if (isEditingOrder) {
event.preventDefault();
return;
}
closeNavigationChrome();
}}
>
<UserPlus className="size-4" />
<span className="truncate">
{currentName ? `Invite people to ${currentName}` : "Invite people"}
</span>
</Link>
</DropdownMenuItem>
) : null}
{session?.session ? (
<>
<DropdownMenuSeparator />

View File

@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "@/lib/queryKeys";
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
import { SidebarCompanyMenu as SidebarCompanyMenuProduction } from "./SidebarCompanyMenu.production";
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
@ -370,6 +371,56 @@ describe("SidebarCompanyMenu", () => {
});
});
it("shows the production-shell invite shortcut when no surface is hidden", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
queryClient.setQueryData(queryKeys.health, { status: "ok" });
const root = createRoot(container);
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<SidebarCompanyMenuProduction />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await openMenu("Open Acme Labs company switcher");
expect(document.body.textContent).toContain("Invite people to Acme Labs");
act(() => {
root.unmount();
});
});
it("hides the production-shell invite shortcut when the operator hides the invites surface", async () => {
// The production shell (streamlined UI disabled) must honor
// PAPERCLIP_HIDDEN_SETTINGS like the streamlined menu — this is the knob
// Paperclip Cloud uses to drop the shortcut on its managed stacks.
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
queryClient.setQueryData(queryKeys.health, { status: "ok", hiddenSettings: ["company.invites"] });
const root = createRoot(container);
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<SidebarCompanyMenuProduction />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await openMenu("Open Acme Labs company switcher");
expect(document.body.textContent).toContain("Switch");
expect(document.body.textContent).not.toContain("Invite people");
act(() => {
root.unmount();
});
});
it("keeps the invite shortcut out of the menu until hidden settings resolve", async () => {
// No health data in the cache: the hidden-settings set is unknown, so the
// shortcut must not flash in and then disappear once the response lands.