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:
parent
a661caf74e
commit
446577d174
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue