mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
refactor(ui): remove the legacy Cloud organization switcher (#14061)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Its sidebar provides navigation between organizations. > - The generic organization switcher slot lets installed plugins own that navigation. > - Both Core UI shells still contain the old Cloud portfolio menu. > - Cloud now uses its private Account plugin for this menu. > - This pull request removes the duplicate Cloud menu and keeps the built-in company menu. > - This reduces Cloud-specific code without changing the plugin host contract. ## Linked Issues or Issue Description Refs #13832 and #13854. Related #14060 changes the account popup, not this organization switcher. **What existing behavior does this improve?** Organization navigation in both sidebar shells. **Current behavior** Core retains Cloud portfolio fetching, stack rows and stack-entry links behind the plugin replacement slot. **Proposed behavior** The installed switcher plugin owns Cloud navigation. Core lists local companies when no usable replacement exists. The Members-page Cloud invitation action keeps its existing portfolio API; it is a live caller, not an old-image fallback. ## What Changed - Remove Cloud portfolio queries, stack rendering and Cloud creation/entry branches from both built-in menus. - Remove the unused stack-entry URL helper. - Keep company selection, ordering, invitations, logout and plugin error handling. Hide local company creation on managed hosts, where the server forbids it. - Update switcher tests and the navigation contract. ## Verification - `pnpm -r typecheck` passed, including Rust checks with the installed Cargo toolchain on PATH. - `pnpm exec vitest run --project @paperclipai/ui`: 637 files and 6,727 tests passed. - Focused switcher, plugin host and Cloud link tests: 26 passed after the managed-host creation guard. - `pnpm check:token-gates` passed. - Full `pnpm test:run` was attempted, then stopped after failures in unchanged server tests. Targeted reproduction found an ancestor skills-directory collision for Slack and macOS EACCES errors renaming the company skills cache. Other local failures appeared in email connector skill setup and a process-turn test. This is not a local full-suite pass; clean Linux CI covers the complete suite. - Full `pnpm build`, UI production build and Storybook build passed. All [latest-head CI checks](https://github.com/paperclipai/paperclip/actions/runs/36201814444) passed, including the full Linux test matrix, browser tests, typecheck, build and release checks. Greptile is 5/5 with no unresolved comments. ## Risks - A managed host without a usable switcher plugin now gets the ordinary company menu. It no longer gets the old Cloud portfolio menu, and local company creation remains unavailable there. - The Cloud portfolio endpoint remains required by the Members-page invitation action. This PR does not remove that live endpoint or change its authorization. - No database, authentication, plugin protocol or deployment changes. ## Model Used OpenAI Codex, GPT-6, with reasoning, repository inspection and code execution. The exact deployment identifier and context-window size are not exposed by this session. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4ca404b49a
commit
7f3c06dac4
6 files changed
+148
-644
No files matched your search
@@ -995,7 +995,16 @@ On a Paperclip Cloud-managed instance, `POST /companies` returns `403` with
|
||||
code `cloud_managed`; the trusted-header provisioning path and company import
|
||||
routes remain the only company-creation paths there.
|
||||
|
||||
## 10.1.1 Cloud Stack Portfolio
|
||||
## 10.1.1 Organization navigation and Cloud invitations
|
||||
|
||||
Core's built-in organization switcher lists this instance's companies. An
|
||||
installed plugin can replace it through the generic `organizationSwitcher`
|
||||
slot. The built-in menu remains available when no unique usable contribution
|
||||
exists. Core does not fetch or render Cloud portfolios in its switcher. Managed
|
||||
hosts do not expose local company creation in that built-in menu; their extension
|
||||
owns the creation action.
|
||||
|
||||
The Cloud Members-page invitation action still uses the following route:
|
||||
|
||||
- `GET /cloud/stacks`
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
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";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -38,8 +37,6 @@ 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";
|
||||
import { cloudStackCreateUrl, cloudStackEnterUrl } from "@/lib/cloudLinks";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
@@ -70,73 +67,6 @@ function WorkspaceIcon({ company, inPopover = false }: { company: Company; inPop
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloud stacks have no hot-linkable icon URL in the portfolio payload, so v1
|
||||
* renders the same deterministic monogram treatment cloud's own portfolio page
|
||||
* uses — seeded by the display name, never fetched.
|
||||
*/
|
||||
function StackIcon({ displayName }: { displayName: string }) {
|
||||
return <CompanyPatternIcon companyName={displayName} className={POPOVER_WORKSPACE_ICON_CLASS} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The switcher trigger on a Cloud instance. A Cloud tenant holds exactly one
|
||||
* company and the harness pushes the stack's uploaded workspace icon into
|
||||
* that company's branding, so the company logo is the stack logo here.
|
||||
* The stack rows keep the monogram treatment above.
|
||||
*/
|
||||
function CurrentStackIcon({
|
||||
displayName,
|
||||
company,
|
||||
}: {
|
||||
displayName: string;
|
||||
company: Company | null;
|
||||
}) {
|
||||
return (
|
||||
<CompanyPatternIcon
|
||||
companyName={displayName}
|
||||
logoUrl={company?.logoUrl}
|
||||
className={TRIGGER_WORKSPACE_ICON_CLASS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudStackItem({
|
||||
stack,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
stack: CloudStackSummary;
|
||||
isSelected: boolean;
|
||||
onSelect: (stack: CloudStackSummary) => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onSelect(stack)}
|
||||
className={ORGANIZATION_ROW_CLASS}
|
||||
>
|
||||
<StackIcon displayName={stack.displayName} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className="block truncate font-medium leading-(--organization-popover-name-line-height)"
|
||||
title={stack.displayName}
|
||||
>
|
||||
{stack.displayName}
|
||||
</span>
|
||||
<span
|
||||
className="block truncate text-(length:--text-nano) leading-(--organization-popover-prefix-line-height) text-muted-foreground"
|
||||
title={stack.stackSlug}
|
||||
>
|
||||
{stack.stackSlug}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center">
|
||||
{isSelected ? <Check className="size-4 text-foreground" /> : null}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableCompanyItem({
|
||||
company,
|
||||
isEditing,
|
||||
@@ -257,44 +187,16 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
|
||||
userId: currentUserId,
|
||||
});
|
||||
|
||||
// In Paperclip Cloud the switcher lists the signed-in user's stacks
|
||||
// (organizations) instead of the instance's companies: a cloud instance holds
|
||||
// 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.
|
||||
// Keep invitation links hidden until the operator policy is known.
|
||||
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,
|
||||
queryFn: () => cloudApi.listStacks(),
|
||||
enabled: isCloud,
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
const stacks = stacksQuery.data?.stacks ?? [];
|
||||
const currentStack = isCloud
|
||||
? stacks.find((stack) => stack.isCurrent)
|
||||
?? stacks.find((stack) => Boolean(cloud?.stackSlug) && stack.stackSlug === cloud?.stackSlug)
|
||||
?? null
|
||||
: null;
|
||||
const createStackUrl = isCloud ? cloudStackCreateUrl(cloudBaseUrl) : null;
|
||||
const switcherNoun = isCloud ? "organization" : "company";
|
||||
// The one name the chrome shows for "where am I": the stack in cloud, the
|
||||
// company when self-hosted.
|
||||
const currentName = isCloud
|
||||
? currentStack?.displayName ?? cloud?.stackDisplayName ?? cloud?.stackSlug ?? null
|
||||
: selectedCompany?.name ?? null;
|
||||
const switcherNoun = "company";
|
||||
const currentName = selectedCompany?.name ?? null;
|
||||
// Managed hosts forbid local company creation. Their extension owns that action.
|
||||
const companyCreationManaged = Boolean(useCloudInstance());
|
||||
|
||||
const signOutMutation = useSignOut({ onSignedOut: closeNavigationChrome });
|
||||
|
||||
@@ -325,29 +227,10 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switching stacks is a full top-level navigation, not client routing: the
|
||||
* cloud entry-code handoff authenticates the user for the target stack and
|
||||
* wakes it if it is asleep before landing on its own tenant host.
|
||||
*/
|
||||
function selectStack(stack: CloudStackSummary) {
|
||||
setOpen(false);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
if (stack.stackSlug === currentStack?.stackSlug) return;
|
||||
const target = cloudStackEnterUrl(cloudBaseUrl, stack.stackSlug);
|
||||
if (!target) return;
|
||||
navigateTopLevel(target);
|
||||
}
|
||||
|
||||
function addCompany() {
|
||||
if (companyCreationManaged) return;
|
||||
setOpen(false);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
// Cloud creates organizations in the cloud app; the in-app company wizard
|
||||
// is unreachable there (POST /companies is a 403 floor on managed stacks).
|
||||
if (isCloud) {
|
||||
if (createStackUrl) navigateTopLevel(createStackUrl);
|
||||
return;
|
||||
}
|
||||
// Skip the front-door "how would you like to get started?" choice and land
|
||||
// directly on "Name your organization" — this entry point is unambiguously
|
||||
// "create a new company" (PAP-431).
|
||||
@@ -381,8 +264,7 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
|
||||
// `min-w-0` on every link of the flex chain (button → label row → label)
|
||||
// is what lets the name truncate: a flex item's default `min-width:auto`
|
||||
// floors it at its content width, so without it a long name widens the
|
||||
// trigger past the sidebar and pushes the chevron out of bounds. Company
|
||||
// names were short in practice; cloud stack names are user-chosen.
|
||||
// trigger past the sidebar and pushes the chevron out of bounds.
|
||||
className="h-9 min-w-0 flex-1 justify-start gap-2 px-4 text-left hover:bg-accent/50 hover:text-foreground has-[>svg]:px-4 dark:hover:bg-accent/50"
|
||||
aria-label={
|
||||
currentName
|
||||
@@ -391,9 +273,7 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
|
||||
}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{isCloud
|
||||
? currentName ? <CurrentStackIcon displayName={currentName} company={selectedCompany} /> : null
|
||||
: selectedCompany ? <WorkspaceIcon company={selectedCompany} /> : null}
|
||||
{selectedCompany ? <WorkspaceIcon company={selectedCompany} /> : null}
|
||||
{/* The header has room for ~110px of name beside the collapse
|
||||
control (~142px on mobile, which hides it) — search moved to
|
||||
the nav to buy that width. A name that still
|
||||
@@ -405,7 +285,7 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
|
||||
)}
|
||||
title={currentName ?? undefined}
|
||||
>
|
||||
{currentName ?? (isCloud ? "Select organization" : "Select company")}
|
||||
{currentName ?? `Select ${switcherNoun}`}
|
||||
</span>
|
||||
</span>
|
||||
{!rail && <ChevronsUpDown className="size-3.5 shrink-0 text-muted-foreground" />}
|
||||
@@ -420,95 +300,65 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
|
||||
<DropdownMenuLabel className="p-0 text-(length:--text-compact) font-semibold text-foreground">
|
||||
Organizations
|
||||
</DropdownMenuLabel>
|
||||
{/* Stack order is owned by cloud's own portfolio in v1, so the
|
||||
drag-to-reorder affordance stays self-hosted-only. */}
|
||||
{isCloud ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsEditingOrder((current) => !current);
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{isEditingOrder ? "Done" : "Edit"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsEditingOrder((current) => !current);
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{isEditingOrder ? "Done" : "Edit"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex max-h-96 flex-col gap-0.5 overflow-y-auto px-2.5 pb-2 pt-1">
|
||||
{isCloud ? (
|
||||
<>
|
||||
{stacks.map((stack) => (
|
||||
<CloudStackItem
|
||||
key={stack.stackSlug}
|
||||
stack={stack}
|
||||
isSelected={stack.stackSlug === currentStack?.stackSlug}
|
||||
onSelect={selectStack}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={orderedCompanies.map((company) => company.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{orderedCompanies.map((company) => (
|
||||
<SortableCompanyItem
|
||||
key={company.id}
|
||||
company={company}
|
||||
isEditing={isEditingOrder}
|
||||
isSelected={company.id === selectedCompany?.id}
|
||||
onSelect={selectCompany}
|
||||
/>
|
||||
))}
|
||||
{stacks.length === 0 ? (
|
||||
<DropdownMenuItem disabled>
|
||||
{stacksQuery.isLoading
|
||||
? "Loading organizations..."
|
||||
: stacksQuery.isError
|
||||
? "Could not load organizations"
|
||||
: "No organizations"}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={orderedCompanies.map((company) => company.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{orderedCompanies.length === 0 ? (
|
||||
// "No companies" is a claim about the account. After a failed
|
||||
// list request it is one we cannot make, and this menu is the
|
||||
// only place the customer can act on it — say what happened and
|
||||
// offer the way back.
|
||||
companyListUnavailable ? (
|
||||
<>
|
||||
<DropdownMenuItem disabled>Couldn't load companies</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
// Keep the menu open so the result of the retry is visible.
|
||||
event.preventDefault();
|
||||
void retryCompanies();
|
||||
}}
|
||||
>
|
||||
{orderedCompanies.map((company) => (
|
||||
<SortableCompanyItem
|
||||
key={company.id}
|
||||
company={company}
|
||||
isEditing={isEditingOrder}
|
||||
isSelected={company.id === selectedCompany?.id}
|
||||
onSelect={selectCompany}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{orderedCompanies.length === 0 ? (
|
||||
// "No companies" is a claim about the account. After a failed
|
||||
// list request it is one we cannot make, and this menu is the
|
||||
// only place the customer can act on it — say what happened and
|
||||
// offer the way back.
|
||||
companyListUnavailable ? (
|
||||
<>
|
||||
<DropdownMenuItem disabled>Couldn't load companies</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
// Keep the menu open so the result of the retry is visible.
|
||||
event.preventDefault();
|
||||
void retryCompanies();
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Try again
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem disabled>No companies</DropdownMenuItem>
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Try again
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem disabled>No companies</DropdownMenuItem>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 border-t border-border px-2.5 pb-2.5 pt-2">
|
||||
{/* A cloud instance without a configured cloud origin has nowhere to
|
||||
send the user, so the row drops out entirely. */}
|
||||
{isCloud && !createStackUrl ? null : (
|
||||
{!companyCreationManaged && (
|
||||
<DropdownMenuItem
|
||||
onClick={addCompany}
|
||||
className={ORGANIZATION_ACTION_CLASS}
|
||||
|
||||
@@ -28,19 +28,12 @@ const mockSidebarPreferencesApi = vi.hoisted(() => ({
|
||||
getCompanyOrder: vi.fn(),
|
||||
updateCompanyOrder: vi.fn(),
|
||||
}));
|
||||
const mockCloudApi = vi.hoisted(() => ({
|
||||
listStacks: vi.fn(),
|
||||
}));
|
||||
const mockNavigateTopLevel = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
authApi: mockAuthApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/api/cloud", () => ({
|
||||
cloudApi: mockCloudApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/browserNavigation", () => ({
|
||||
navigateTopLevel: mockNavigateTopLevel,
|
||||
}));
|
||||
@@ -141,29 +134,6 @@ const CLOUD_HEALTH = {
|
||||
},
|
||||
};
|
||||
|
||||
const CLOUD_STACKS = {
|
||||
stacks: [
|
||||
{
|
||||
displayName: "Acme Labs",
|
||||
stackSlug: "acme-labs",
|
||||
primaryHost: "acme-labs.example.test",
|
||||
lifecycleState: "active",
|
||||
sleepState: "awake",
|
||||
role: "owner",
|
||||
isCurrent: true,
|
||||
},
|
||||
{
|
||||
displayName: "Strata Systems",
|
||||
stackSlug: "strata",
|
||||
primaryHost: "strata.example.test",
|
||||
lifecycleState: "active",
|
||||
sleepState: "asleep",
|
||||
role: "member",
|
||||
isCurrent: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("SidebarCompanyMenu", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
@@ -179,7 +149,6 @@ describe("SidebarCompanyMenu", () => {
|
||||
},
|
||||
});
|
||||
mockAuthApi.signOut.mockResolvedValue(undefined);
|
||||
mockCloudApi.listStacks.mockResolvedValue(CLOUD_STACKS);
|
||||
mockSidebarPreferencesApi.getCompanyOrder.mockResolvedValue({
|
||||
orderedIds: ["company-1", "company-2", "company-3"],
|
||||
updatedAt: null,
|
||||
@@ -199,7 +168,7 @@ describe("SidebarCompanyMenu", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderMenu(options: { cloud?: boolean; health?: unknown } = {}) {
|
||||
function renderMenu(options: { cloud?: boolean; health?: unknown; production?: boolean } = {}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
@@ -209,10 +178,11 @@ describe("SidebarCompanyMenu", () => {
|
||||
queryClient.setQueryData(queryKeys.health, options.health ?? CLOUD_HEALTH);
|
||||
}
|
||||
const root = createRoot(container);
|
||||
const Menu = options.production ? SidebarCompanyMenuProduction : SidebarCompanyMenu;
|
||||
act(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarCompanyMenu />
|
||||
<Menu />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
@@ -599,7 +569,6 @@ describe("SidebarCompanyMenu", () => {
|
||||
|
||||
expect(mockOpenOnboarding).toHaveBeenCalledTimes(1);
|
||||
expect(mockNavigateTopLevel).not.toHaveBeenCalled();
|
||||
expect(mockCloudApi.listStacks).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
@@ -661,168 +630,22 @@ describe("SidebarCompanyMenu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("switches organizations instead of companies", async () => {
|
||||
const { root } = renderMenu({ cloud: true });
|
||||
it.each([false, true])("keeps company navigation without local creation when a managed host has no switcher plugin (production=%s)", async (production) => {
|
||||
const { root } = renderMenu({ cloud: true, production });
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockCloudApi.listStacks).toHaveBeenCalledTimes(1);
|
||||
await openMenu("Open Acme Labs organization switcher");
|
||||
|
||||
expect(document.body.textContent).toContain("Organizations");
|
||||
expect(document.body.textContent).toContain("Create organization");
|
||||
expect(document.body.textContent).not.toContain("Organization settings");
|
||||
expect(document.body.textContent).not.toContain("Switch company");
|
||||
expect(document.body.textContent).not.toContain("Create new company...");
|
||||
expect(document.body.textContent).not.toContain("Company settings");
|
||||
|
||||
// Stacks, not companies: both rows carry the slug badge, and the current
|
||||
// stack is the checked one.
|
||||
expect(document.body.textContent).toContain("Acme Labs");
|
||||
expect(document.body.textContent).toContain("acme-labs");
|
||||
expect(document.body.textContent).toContain("Strata Systems");
|
||||
expect(document.body.textContent).toContain("strata");
|
||||
expect(document.body.textContent).not.toContain("Anachronist Wiki");
|
||||
expect(document.body.textContent).not.toContain("ANA");
|
||||
|
||||
const currentRow = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
.find((element) => element.textContent?.includes("Acme Labs"));
|
||||
expect(currentRow?.classList.contains("bg-accent")).toBe(false);
|
||||
|
||||
// Long slugs must not squeeze the display name out of the row, so the
|
||||
// secondary line truncates and keeps the full slug on hover.
|
||||
const slugBadge = document.body.querySelector('[title="strata"]');
|
||||
expect(slugBadge?.className).toContain("truncate");
|
||||
|
||||
// Drag-to-reorder is self-hosted only in v1.
|
||||
expect(Array.from(document.body.querySelectorAll("button"))
|
||||
.some((element) => element.textContent === "Edit")).toBe(false);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the synced company logo on the trigger while stack rows keep monograms", async () => {
|
||||
const { root } = renderMenu({ cloud: true });
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
// A Cloud tenant holds exactly one company, and the harness pushes the
|
||||
// stack's uploaded workspace icon into that company's branding — so the
|
||||
// trigger renders the company logo, not the monogram.
|
||||
const trigger = container.querySelector(
|
||||
'button[aria-label="Open Acme Labs organization switcher"]',
|
||||
);
|
||||
expect(trigger).not.toBeNull();
|
||||
expect(
|
||||
trigger?.querySelector('[data-logo-url="/api/assets/logo-asset-1/content"]'),
|
||||
).not.toBeNull();
|
||||
|
||||
// Other stacks have no hot-linkable icon in the portfolio payload, so
|
||||
// their rows keep the deterministic monogram treatment.
|
||||
await openMenu("Open Acme Labs organization switcher");
|
||||
const strataRow = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
.find((element) => element.textContent?.includes("Strata Systems"));
|
||||
expect(strataRow).toBeTruthy();
|
||||
expect(strataRow?.querySelector("[data-logo-url]")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("enters another stack through a full top-level navigation", async () => {
|
||||
const { root } = renderMenu({ cloud: true });
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await openMenu("Open Acme Labs organization switcher");
|
||||
|
||||
const strataRow = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
.find((element) => element.textContent?.includes("Strata Systems"));
|
||||
expect(strataRow).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
strataRow?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockNavigateTopLevel).toHaveBeenCalledWith(
|
||||
"https://cloud.example.test/stacks/strata/enter",
|
||||
);
|
||||
// No in-app company selection and no client-side route change.
|
||||
expect(mockSetSelectedCompanyId).not.toHaveBeenCalled();
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not navigate when the current stack is picked again", async () => {
|
||||
const { root } = renderMenu({ cloud: true });
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await openMenu("Open Acme Labs organization switcher");
|
||||
|
||||
const currentRow = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
.find((element) => element.textContent?.includes("Acme Labs"));
|
||||
|
||||
act(() => {
|
||||
currentRow?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockNavigateTopLevel).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("sends organization creation to the cloud app, not the in-app wizard", async () => {
|
||||
const { root } = renderMenu({ cloud: true });
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await openMenu("Open Acme Labs organization switcher");
|
||||
|
||||
const createItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
.find((element) => element.textContent?.includes("Create organization"));
|
||||
expect(createItem).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
createItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockNavigateTopLevel).toHaveBeenCalledWith("https://cloud.example.test/stacks/new");
|
||||
expect(mockOpenOnboarding).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the stack slug and hides creation without a cloud base url", async () => {
|
||||
mockCloudApi.listStacks.mockRejectedValue(new Error("portfolio unavailable"));
|
||||
const { root } = renderMenu({
|
||||
cloud: true,
|
||||
health: {
|
||||
status: "ok" as const,
|
||||
cloud: { ...CLOUD_HEALTH.cloud, cloudBaseUrl: null },
|
||||
},
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
await openMenu("Open acme-labs organization switcher");
|
||||
|
||||
expect(document.body.textContent).toContain("Could not load organizations");
|
||||
await openMenu(`Open Acme Labs ${production ? "company" : "organization"} switcher`);
|
||||
expect(document.body.textContent).toContain("Anachronist Wiki");
|
||||
expect(document.body.textContent).toContain("ANA");
|
||||
expect(document.body.textContent).not.toContain("Create organization");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
expect(mockOpenOnboarding).not.toHaveBeenCalled();
|
||||
const row = [...document.querySelectorAll('[data-slot="dropdown-menu-item"]')]
|
||||
.find(element => element.textContent?.includes("Anachronist Wiki"));
|
||||
act(() => row?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(mockSetSelectedCompanyId).toHaveBeenCalledWith("company-3");
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/ANA/dashboard");
|
||||
expect(mockNavigateTopLevel).not.toHaveBeenCalled();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,6 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
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";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -38,8 +37,6 @@ 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";
|
||||
import { cloudStackCreateUrl, cloudStackEnterUrl } from "@/lib/cloudLinks";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
@@ -70,73 +67,6 @@ function WorkspaceIcon({ company, inPopover = false }: { company: Company; inPop
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloud stacks have no hot-linkable icon URL in the portfolio payload, so v1
|
||||
* renders the same deterministic monogram treatment cloud's own portfolio page
|
||||
* uses — seeded by the display name, never fetched.
|
||||
*/
|
||||
function StackIcon({ displayName }: { displayName: string }) {
|
||||
return <CompanyPatternIcon companyName={displayName} className={POPOVER_WORKSPACE_ICON_CLASS} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The switcher trigger on a Cloud instance. A Cloud tenant holds exactly one
|
||||
* company and the harness pushes the stack's uploaded workspace icon into
|
||||
* that company's branding, so the company logo is the stack logo here.
|
||||
* The stack rows keep the monogram treatment above.
|
||||
*/
|
||||
function CurrentStackIcon({
|
||||
displayName,
|
||||
company,
|
||||
}: {
|
||||
displayName: string;
|
||||
company: Company | null;
|
||||
}) {
|
||||
return (
|
||||
<CompanyPatternIcon
|
||||
companyName={displayName}
|
||||
logoUrl={company?.logoUrl}
|
||||
className={TRIGGER_WORKSPACE_ICON_CLASS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudStackItem({
|
||||
stack,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
stack: CloudStackSummary;
|
||||
isSelected: boolean;
|
||||
onSelect: (stack: CloudStackSummary) => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onSelect(stack)}
|
||||
className={ORGANIZATION_ROW_CLASS}
|
||||
>
|
||||
<StackIcon displayName={stack.displayName} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className="block truncate font-medium leading-(--organization-popover-name-line-height)"
|
||||
title={stack.displayName}
|
||||
>
|
||||
{stack.displayName}
|
||||
</span>
|
||||
<span
|
||||
className="block truncate text-(length:--text-nano) leading-(--organization-popover-prefix-line-height) text-muted-foreground"
|
||||
title={stack.stackSlug}
|
||||
>
|
||||
{stack.stackSlug}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center">
|
||||
{isSelected ? <Check className="size-4 text-foreground" /> : null}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableCompanyItem({
|
||||
company,
|
||||
isEditing,
|
||||
@@ -257,40 +187,16 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
|
||||
userId: currentUserId,
|
||||
});
|
||||
|
||||
// In Paperclip Cloud the switcher lists the signed-in user's stacks
|
||||
// (organizations) instead of the instance's companies: a cloud instance holds
|
||||
// exactly one company, and switching means leaving this tenant host entirely.
|
||||
const cloud = useCloudInstance();
|
||||
const isCloud = Boolean(cloud);
|
||||
// Invites now live on the Members page; hide the shortcut when the hosting
|
||||
// operator hides either surface. Until the health response resolves, the
|
||||
// hidden set is unknown — keep the shortcut out rather than flash it.
|
||||
// Keep invitation links hidden until the operator policy is known.
|
||||
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,
|
||||
queryFn: () => cloudApi.listStacks(),
|
||||
enabled: isCloud,
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
const stacks = stacksQuery.data?.stacks ?? [];
|
||||
const currentStack = isCloud
|
||||
? stacks.find((stack) => stack.isCurrent)
|
||||
?? stacks.find((stack) => Boolean(cloud?.stackSlug) && stack.stackSlug === cloud?.stackSlug)
|
||||
?? null
|
||||
: null;
|
||||
const createStackUrl = isCloud ? cloudStackCreateUrl(cloudBaseUrl) : null;
|
||||
const switcherNoun = "organization";
|
||||
// The one name the chrome shows for "where am I": the stack in cloud, the
|
||||
// company when self-hosted.
|
||||
const currentName = isCloud
|
||||
? currentStack?.displayName ?? cloud?.stackDisplayName ?? cloud?.stackSlug ?? null
|
||||
: selectedCompany?.name ?? null;
|
||||
const currentName = selectedCompany?.name ?? null;
|
||||
// Managed hosts forbid local company creation. Their extension owns that action.
|
||||
const companyCreationManaged = Boolean(useCloudInstance());
|
||||
|
||||
const signOutMutation = useSignOut({ onSignedOut: closeNavigationChrome });
|
||||
|
||||
@@ -321,29 +227,10 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switching stacks is a full top-level navigation, not client routing: the
|
||||
* cloud entry-code handoff authenticates the user for the target stack and
|
||||
* wakes it if it is asleep before landing on its own tenant host.
|
||||
*/
|
||||
function selectStack(stack: CloudStackSummary) {
|
||||
setOpen(false);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
if (stack.stackSlug === currentStack?.stackSlug) return;
|
||||
const target = cloudStackEnterUrl(cloudBaseUrl, stack.stackSlug);
|
||||
if (!target) return;
|
||||
navigateTopLevel(target);
|
||||
}
|
||||
|
||||
function addCompany() {
|
||||
if (companyCreationManaged) return;
|
||||
setOpen(false);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
// Cloud creates organizations in the cloud app; the in-app company wizard
|
||||
// is unreachable there (POST /companies is a 403 floor on managed stacks).
|
||||
if (isCloud) {
|
||||
if (createStackUrl) navigateTopLevel(createStackUrl);
|
||||
return;
|
||||
}
|
||||
// Skip the front-door "how would you like to get started?" choice and land
|
||||
// directly on "Name your organization" — this entry point is unambiguously
|
||||
// "create a new company" (PAP-431).
|
||||
@@ -377,8 +264,7 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
|
||||
// `min-w-0` on every link of the flex chain (button → label row → label)
|
||||
// is what lets the name truncate: a flex item's default `min-width:auto`
|
||||
// floors it at its content width, so without it a long name widens the
|
||||
// trigger past the sidebar and pushes the chevron out of bounds. Company
|
||||
// names were short in practice; cloud stack names are user-chosen.
|
||||
// trigger past the sidebar and pushes the chevron out of bounds.
|
||||
className="h-9 min-w-0 flex-1 justify-start gap-2 px-4 text-left hover:bg-sidebar-accent hover:text-sidebar-accent-foreground has-[>svg]:px-4 dark:hover:bg-sidebar-accent dark:hover:text-sidebar-accent-foreground"
|
||||
aria-label={
|
||||
currentName
|
||||
@@ -387,9 +273,7 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
|
||||
}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{isCloud
|
||||
? currentName ? <CurrentStackIcon displayName={currentName} company={selectedCompany} /> : null
|
||||
: selectedCompany ? <WorkspaceIcon company={selectedCompany} /> : null}
|
||||
{selectedCompany ? <WorkspaceIcon company={selectedCompany} /> : null}
|
||||
{/* The header has room for ~110px of name beside the collapse
|
||||
control (~142px on mobile, which hides it) — search moved to
|
||||
the nav to buy that width. A name that still
|
||||
@@ -416,95 +300,65 @@ function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompa
|
||||
<DropdownMenuLabel className="p-0 text-(length:--text-compact) font-semibold text-foreground">
|
||||
Organizations
|
||||
</DropdownMenuLabel>
|
||||
{/* Stack order is owned by cloud's own portfolio in v1, so the
|
||||
drag-to-reorder affordance stays self-hosted-only. */}
|
||||
{isCloud ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsEditingOrder((current) => !current);
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{isEditingOrder ? "Done" : "Edit"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsEditingOrder((current) => !current);
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{isEditingOrder ? "Done" : "Edit"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex max-h-96 flex-col gap-0.5 overflow-y-auto px-2.5 pb-2 pt-1">
|
||||
{isCloud ? (
|
||||
<>
|
||||
{stacks.map((stack) => (
|
||||
<CloudStackItem
|
||||
key={stack.stackSlug}
|
||||
stack={stack}
|
||||
isSelected={stack.stackSlug === currentStack?.stackSlug}
|
||||
onSelect={selectStack}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={orderedCompanies.map((company) => company.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{orderedCompanies.map((company) => (
|
||||
<SortableCompanyItem
|
||||
key={company.id}
|
||||
company={company}
|
||||
isEditing={isEditingOrder}
|
||||
isSelected={company.id === selectedCompany?.id}
|
||||
onSelect={selectCompany}
|
||||
/>
|
||||
))}
|
||||
{stacks.length === 0 ? (
|
||||
<DropdownMenuItem disabled>
|
||||
{stacksQuery.isLoading
|
||||
? "Loading organizations..."
|
||||
: stacksQuery.isError
|
||||
? "Could not load organizations"
|
||||
: "No organizations"}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={orderedCompanies.map((company) => company.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{orderedCompanies.length === 0 ? (
|
||||
// "No companies" is a claim about the account. After a failed
|
||||
// list request it is one we cannot make, and this menu is the
|
||||
// only place the customer can act on it — say what happened and
|
||||
// offer the way back.
|
||||
companyListUnavailable ? (
|
||||
<>
|
||||
<DropdownMenuItem disabled>Couldn't load organizations</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
// Keep the menu open so the result of the retry is visible.
|
||||
event.preventDefault();
|
||||
void retryCompanies();
|
||||
}}
|
||||
>
|
||||
{orderedCompanies.map((company) => (
|
||||
<SortableCompanyItem
|
||||
key={company.id}
|
||||
company={company}
|
||||
isEditing={isEditingOrder}
|
||||
isSelected={company.id === selectedCompany?.id}
|
||||
onSelect={selectCompany}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{orderedCompanies.length === 0 ? (
|
||||
// "No companies" is a claim about the account. After a failed
|
||||
// list request it is one we cannot make, and this menu is the
|
||||
// only place the customer can act on it — say what happened and
|
||||
// offer the way back.
|
||||
companyListUnavailable ? (
|
||||
<>
|
||||
<DropdownMenuItem disabled>Couldn't load organizations</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
// Keep the menu open so the result of the retry is visible.
|
||||
event.preventDefault();
|
||||
void retryCompanies();
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Try again
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem disabled>No organizations</DropdownMenuItem>
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Try again
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem disabled>No organizations</DropdownMenuItem>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 border-t border-border px-2.5 pb-2.5 pt-2">
|
||||
{/* A cloud instance without a configured cloud origin has nowhere to
|
||||
send the user, so the row drops out entirely. */}
|
||||
{isCloud && !createStackUrl ? null : (
|
||||
{!companyCreationManaged && (
|
||||
<DropdownMenuItem
|
||||
onClick={addCompany}
|
||||
className={ORGANIZATION_ACTION_CLASS}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cloudAppUrl, cloudPortfolioManageUrl, cloudStackCreateUrl, cloudStackEnterUrl, cloudStackInviteUrl } from "./cloudLinks";
|
||||
import { cloudAppUrl, cloudPortfolioManageUrl, cloudStackCreateUrl, cloudStackInviteUrl } from "./cloudLinks";
|
||||
|
||||
describe("cloudLinks", () => {
|
||||
it("resolves stack links against the cloud origin", () => {
|
||||
expect(cloudStackEnterUrl("https://app.paperclip.app", "acme")).toBe(
|
||||
"https://app.paperclip.app/stacks/acme/enter",
|
||||
);
|
||||
expect(cloudStackCreateUrl("https://app.paperclip.app")).toBe(
|
||||
"https://app.paperclip.app/stacks/new",
|
||||
);
|
||||
@@ -15,18 +12,6 @@ describe("cloudLinks", () => {
|
||||
expect(cloudPortfolioManageUrl(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("drops a control-plane path suffix on the configured origin", () => {
|
||||
expect(cloudStackEnterUrl("https://cloud.example.test/control-plane", "acme")).toBe(
|
||||
"https://cloud.example.test/stacks/acme/enter",
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes slugs so a crafted portfolio entry cannot climb the path", () => {
|
||||
expect(cloudStackEnterUrl("https://app.paperclip.app", "../../evil")).toBe(
|
||||
"https://app.paperclip.app/stacks/..%2F..%2Fevil/enter",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens Cloud People settings on the configured origin with an escaped stack slug", () => {
|
||||
expect(cloudStackInviteUrl("https://cloud.example.test/control-plane", "team/with?query")).toBe(
|
||||
"https://cloud.example.test/workspaces/team%2Fwith%3Fquery/settings?section=people",
|
||||
@@ -36,11 +21,7 @@ describe("cloudLinks", () => {
|
||||
expect(cloudStackInviteUrl("javascript:alert(1)", "team")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null without a usable base or slug", () => {
|
||||
expect(cloudStackEnterUrl(null, "acme")).toBeNull();
|
||||
expect(cloudStackEnterUrl(" ", "acme")).toBeNull();
|
||||
expect(cloudStackEnterUrl("https://app.paperclip.app", " ")).toBeNull();
|
||||
expect(cloudStackEnterUrl("not a url", "acme")).toBeNull();
|
||||
it("returns null without a usable base", () => {
|
||||
expect(cloudStackCreateUrl(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -25,19 +25,6 @@ export function cloudAppUrl(cloudBaseUrl: string | null | undefined, path: strin
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry-code handoff for another stack: cloud authenticates the user for that
|
||||
* stack and wakes it if it is asleep before redirecting to its tenant host.
|
||||
*/
|
||||
export function cloudStackEnterUrl(
|
||||
cloudBaseUrl: string | null | undefined,
|
||||
stackSlug: string | null | undefined,
|
||||
): string | null {
|
||||
const slug = stackSlug?.trim();
|
||||
if (!slug) return null;
|
||||
return cloudAppUrl(cloudBaseUrl, `/stacks/${encodeURIComponent(slug)}/enter`);
|
||||
}
|
||||
|
||||
/** Cloud's own create-a-stack flow, which replaces the in-app company wizard. */
|
||||
export function cloudStackCreateUrl(cloudBaseUrl: string | null | undefined): string | null {
|
||||
return cloudAppUrl(cloudBaseUrl, "/stacks/new");
|
||||
|
||||
Reference in new issue
Block a user