diff --git a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md index 00d6cc01bff..091f90e81cf 100644 --- a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md +++ b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md @@ -369,6 +369,7 @@ Mount surfaces currently wired in the host include: - `projectSidebarItem` - `globalToolbarButton` - `appShellOverlay` (persistent, signed-in application shell) +- `organizationSwitcher` (one React contribution replacing the organization menu) - `toolbarButton` - `contextMenuItem` - `commentAnnotation` @@ -617,3 +618,18 @@ pnpm build For image-supplied plugins and the persistent shell lifecycle, see [Distribution plugins](DISTRIBUTION-PLUGINS.md). + +### Organization switcher + +Declare one `organizationSwitcher` slot with `ui.sidebar.register`. The host +passes `PluginOrganizationSwitcherProps`: current company display data, collapsed +and open state, navigation/logout callbacks, and an icon renderer. Use the host +logout callback; authenticate remote account requests at their owning service. +`currentCompany` describes the host-local company. A distribution plugin must +resolve its external account/organization label itself; the host does not fetch +that portfolio on the plugin's behalf. +The slot props and `useHostContext()` are display context, not proof of identity. +The host resets plugin state on account/company changes and keeps its built-in +menu when no unique contribution exists, discovery fails, the module is missing, +or rendering throws. The slot is a React-only contract; do not use a custom +element export. This replaces only the menu, not company policy or authorization. diff --git a/packages/plugins/sdk/src/ui/index.ts b/packages/plugins/sdk/src/ui/index.ts index cffdb233dc5..f7d1cee64a8 100644 --- a/packages/plugins/sdk/src/ui/index.ts +++ b/packages/plugins/sdk/src/ui/index.ts @@ -152,6 +152,7 @@ export type { PluginWidgetProps, PluginDetailTabProps, PluginSidebarProps, + PluginOrganizationSwitcherProps, PluginRouteSidebarProps, PluginProjectSidebarItemProps, PluginCommentAnnotationProps, diff --git a/packages/plugins/sdk/src/ui/types.ts b/packages/plugins/sdk/src/ui/types.ts index 10478216838..64a9ce2d061 100644 --- a/packages/plugins/sdk/src/ui/types.ts +++ b/packages/plugins/sdk/src/ui/types.ts @@ -16,6 +16,7 @@ import type { AnchorHTMLAttributes, + ReactNode, MouseEvent as ReactMouseEvent, } from "react"; import type { @@ -270,6 +271,26 @@ export interface PluginDetailTabProps { }; } +/** A single installed contribution replaces the organization menu. The host + * retains its built-in menu while loading, on ambiguity, or on render failure. + * These values/callbacks are presentation context, never authorization. */ +export interface PluginOrganizationSwitcherProps { + organizationSwitcher: { + /** Host-local company branding, not an external account/organization label. + * Plugins resolve external organization identity at their owning service. */ + currentCompany: { name: string; logoUrl: string | null } | null; + collapsed: boolean; + open: boolean; + onOpenChange(open: boolean): void; + /** Close mobile navigation before leaving the current organization. */ + onNavigate(): void; + /** Host-owned logout preserves its session/cache invalidation behavior. */ + onSignOut(): void; + signingOut: boolean; + renderIcon(name: string, logoUrl: string | null, inMenu: boolean): ReactNode; + }; +} + /** * Props passed to a plugin sidebar component. * diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 33d1e61e8ef..1c90a7b7a91 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1483,6 +1483,7 @@ export const PLUGIN_UI_SLOT_TYPES = [ "projectSidebarItem", "globalToolbarButton", "appShellOverlay", + "organizationSwitcher", "toolbarButton", "contextMenuItem", "commentAnnotation", diff --git a/server/src/__tests__/local-service-supervisor.test.ts b/server/src/__tests__/local-service-supervisor.test.ts index 99d49e4c1d8..bd891224793 100644 --- a/server/src/__tests__/local-service-supervisor.test.ts +++ b/server/src/__tests__/local-service-supervisor.test.ts @@ -54,7 +54,7 @@ describe("local service supervision", () => { workspaceRuntime: { services: [{ name: "web", - command: "node -e \"const http=require('node:http'); process.on('SIGTERM',()=>{}); http.createServer((req,res)=>{ process.stdout.write('request '+req.url+'\\\\n',(error)=>{ if (!error) res.end('ok'); }); }).listen(Number(process.env.PORT), '127.0.0.1')\"", + command: "command -v node; node --version; node -e \"const http=require('node:http'); console.log('fixture starting', process.execPath, process.env.PORT); process.on('SIGTERM',()=>{}); http.createServer((req,res)=>{ process.stdout.write('request '+req.url+'\\\\n',(error)=>{ if (!error) res.end('ok'); }); }).listen(Number(process.env.PORT), '127.0.0.1',()=>console.log('fixture listening', process.env.PORT))\"", port: { type: "auto" }, readiness: { type: "http", diff --git a/server/src/__tests__/plugin-organization-switcher.test.ts b/server/src/__tests__/plugin-organization-switcher.test.ts new file mode 100644 index 00000000000..f6f20b27f23 --- /dev/null +++ b/server/src/__tests__/plugin-organization-switcher.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { pluginManifestV1Schema } from "@paperclipai/shared"; +import { pluginCapabilityValidator } from "../services/plugin-capability-validator.js"; + +describe("organization switcher installation", () => { + it("accepts the slot and requires the sidebar capability", () => { + const manifest = pluginManifestV1Schema.parse({ + id: "example.account", apiVersion: 1, version: "1.0.0", displayName: "Account", categories: ["ui"], + description: "Organization navigation", author: "Example", + capabilities: ["ui.sidebar.register"], entrypoints: { worker: "./dist/worker.js", ui: "./dist/ui" }, + ui: { slots: [{ type: "organizationSwitcher", id: "organizations", displayName: "Organizations", exportName: "Organizations" }] }, + }); + const validator = pluginCapabilityValidator(); + expect(validator.validateManifestCapabilities(manifest).allowed).toBe(true); + expect(validator.validateManifestCapabilities({ ...manifest, capabilities: [] })).toMatchObject({ allowed: false, missing: ["ui.sidebar.register"] }); + }); +}); diff --git a/server/src/services/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts index eb0b83d3f94..e015a9a8b57 100644 --- a/server/src/services/plugin-capability-validator.ts +++ b/server/src/services/plugin-capability-validator.ts @@ -164,6 +164,7 @@ const UI_SLOT_CAPABILITIES: Record = { dashboardWidget: "ui.dashboardWidget.register", globalToolbarButton: "ui.action.register", appShellOverlay: "ui.action.register", + organizationSwitcher: "ui.sidebar.register", toolbarButton: "ui.action.register", contextMenuItem: "ui.action.register", commentAnnotation: "ui.commentAnnotation.register", diff --git a/ui/package.json b/ui/package.json index 7104d63d8fa..64b7a759e9f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -74,6 +74,7 @@ "yjs": "13.6.32" }, "devDependencies": { + "@paperclipai/plugin-sdk": "workspace:*", "@sentry/browser": "10.71.0", "@storybook/addon-a11y": "10.5.10", "@storybook/addon-docs": "10.5.10", diff --git a/ui/src/components/PluginOrganizationSwitcher.test.tsx b/ui/src/components/PluginOrganizationSwitcher.test.tsx new file mode 100644 index 00000000000..866b984b020 --- /dev/null +++ b/ui/src/components/PluginOrganizationSwitcher.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment jsdom +import { useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginOrganizationSwitcherProps } from "@paperclipai/plugin-sdk/ui"; +import { PluginOrganizationSwitcher } from "./PluginOrganizationSwitcher"; +import { registerPluginReactComponent, registerPluginWebComponent, type ResolvedPluginSlot } from "@/plugins/slots"; + +const state = vi.hoisted(() => ({ userId: "alice", companyId: "company-a", settled: true, companyListReady: true, companyIds: ["company-a", "company-b"], slots: [] as ResolvedPluginSlot[], errorMessage: null as string | null, mobile: false, collapsed: false, close: vi.fn(), signOut: vi.fn(), props: null as PluginOrganizationSwitcherProps | null })); +vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => state, useCompanyListQuery: () => ({ isSuccess: state.companyListReady, data: { unauthorized: false, companies: state.companyIds.map(id => ({ id, name: "Acme", issuePrefix: "ACME", logoUrl: "/logo" })) } }) })); +vi.mock("@/api/auth", () => ({ authApi: { getSession: async () => ({ user: { id: state.userId } }) } })); +vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: state.companyId, selectedCompany: { name: "Acme", issuePrefix: "ACME", logoUrl: "/logo" } }) })); +vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: state.mobile, setSidebarOpen: state.close, collapsed: state.collapsed, peeking: false }) })); +vi.mock("@/hooks/useSignOut", () => ({ useSignOut: () => ({ mutate: state.signOut, isPending: false }) })); +vi.mock("./CompanyPatternIcon", () => ({ CompanyPatternIcon: () => icon })); +vi.mock("@/plugins/slots", async (importOriginal) => ({ ...await importOriginal(), usePluginSlots: () => state })); +const slot: ResolvedPluginSlot = { type: "organizationSwitcher", id: "switcher", displayName: "Organizations", exportName: "Switcher", pluginId: "plugin-1", pluginKey: "fixture.switcher", pluginDisplayName: "Fixture", pluginVersion: "1" }; +let root: Root | undefined; +let container: HTMLDivElement; +const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); +function render() { + if (!root) { container = document.createElement("div"); document.body.append(container); root = createRoot(container); } + flushSync(() => root!.render()); +} +afterEach(() => { + if (root) flushSync(() => root!.unmount()); root = undefined; container?.remove(); client.clear(); vi.restoreAllMocks(); + Object.assign(state, { userId: "alice", companyId: "company-a", settled: true, companyListReady: true, companyIds: ["company-a", "company-b"], slots: [], errorMessage: null, mobile: false, collapsed: false, props: null }); + state.close.mockClear(); state.signOut.mockClear(); +}); +function register() { + registerPluginReactComponent(slot.pluginKey, slot.exportName, (props) => { + const host = (props as unknown as PluginOrganizationSwitcherProps).organizationSwitcher; + state.props = { organizationSwitcher: host }; + const [selected, setSelected] = useState(false); + return ; + }); + state.slots = [slot]; +} +describe("organization navigation replacement", () => { + it("keeps built-in navigation for absent, ambiguous, failed or unsettled discovery", () => { + render(); expect(container.textContent).toBe("Built-in organizations"); + register(); state.slots = [slot, { ...slot, id: "other" }]; render(); expect(container.textContent).toBe("Built-in organizations"); + state.slots = [slot]; state.errorMessage = "offline"; render(); expect(container.textContent).toBe("Built-in organizations"); + state.errorMessage = null; state.settled = false; render(); expect(container.textContent).toBe("Built-in organizations"); + }); + it("falls back when a declared module is missing or rendering fails", () => { + state.slots = [{ ...slot, exportName: "Missing" }]; render(); expect(container.textContent).toBe("Built-in organizations"); + vi.spyOn(console, "error").mockImplementation(() => {}); + registerPluginReactComponent(slot.pluginKey, "Broken", () => { throw new Error("render failed"); }); + state.slots = [{ ...slot, exportName: "Broken" }]; render(); expect(container.textContent).toBe("Built-in organizations"); + }); + it("does not mount a new account with the previous account's company selection", () => { + register(); render(); + state.userId = "bob"; + state.companyListReady = false; + state.props = null; + render(); + expect(container.textContent).toBe("Built-in organizations"); + expect(state.props).toBeNull(); + state.companyListReady = true; + state.companyIds = ["company-b"]; + render(); + expect(container.textContent).toBe("Built-in organizations"); + expect(state.props).toBeNull(); + state.companyId = "company-b"; + render(); + expect(container.textContent).toBe("Private organizations"); + }); + it("keeps required navigation when the replacement cannot accept React callbacks", () => { + registerPluginWebComponent(slot.pluginKey, "WebSwitcher", "fixture-switcher"); + state.slots = [{ ...slot, exportName: "WebSwitcher" }]; + render(); + expect(container.textContent).toBe("Built-in organizations"); + }); + it("renders one replacement, keeps host controls, and clears state across identity changes", () => { + register(); state.mobile = true; state.collapsed = true; render(); + expect(container.textContent).toBe("Private organizations"); + const host = state.props!.organizationSwitcher; + expect(host.currentCompany).toEqual({ name: "Acme", logoUrl: "/logo" }); expect(host.collapsed).toBe(true); + flushSync(() => host.onOpenChange(true)); expect(state.props!.organizationSwitcher.open).toBe(true); + flushSync(() => host.onNavigate()); expect(state.close).toHaveBeenCalledWith(false); expect(state.props!.organizationSwitcher.open).toBe(false); + host.onSignOut(); expect(state.signOut).toHaveBeenCalledOnce(); + flushSync(() => container.querySelector("button")!.click()); render(); expect(container.textContent).toBe("selected"); + state.userId = "bob"; render(); expect(container.textContent).toBe("Private organizations"); + flushSync(() => container.querySelector("button")!.click()); state.companyId = "company-b"; render(); expect(container.textContent).toBe("Private organizations"); + }); +}); diff --git a/ui/src/components/PluginOrganizationSwitcher.tsx b/ui/src/components/PluginOrganizationSwitcher.tsx new file mode 100644 index 00000000000..19028a0d5ab --- /dev/null +++ b/ui/src/components/PluginOrganizationSwitcher.tsx @@ -0,0 +1,64 @@ +import { useState, type ReactNode } from "react"; +import type { PluginOrganizationSwitcherProps } from "@paperclipai/plugin-sdk/ui"; +import { useAccountIdentity, useCompanyListQuery } from "@/api/companies-query"; +import { useCompany } from "@/context/CompanyContext"; +import { useSidebar } from "@/context/SidebarContext"; +import { useSignOut } from "@/hooks/useSignOut"; +import { PluginSlotMount, usePluginSlots } from "@/plugins/slots"; +import { CompanyPatternIcon } from "./CompanyPatternIcon"; + +/** Optional replacement; the built-in menu stays usable throughout rollout. */ +export function PluginOrganizationSwitcher({ children, open: controlledOpen, onOpenChange }: { + children: ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const { userId, settled } = useAccountIdentity(); + const { selectedCompanyId } = useCompany(); + const companyList = useCompanyListQuery(); + // Selection is component state and can outlive its account until an effect + // clears it. Resolve it against this account's query before mounting plugins. + const selectedCompany = companyList.data?.companies.find(company => company.id === selectedCompanyId) ?? null; + const contextSettled = settled && companyList.isSuccess && !companyList.data?.unauthorized + && (selectedCompanyId === null || selectedCompany !== null); + return ( + + {children} + + ); +} + +function OrganizationSwitcher({ children, settled, companyId, company, open: controlledOpen, onOpenChange }: { + children: ReactNode; settled: boolean; companyId: string | null; + company: { name: string; issuePrefix: string; logoUrl?: string | null } | null; + open?: boolean; onOpenChange?: (open: boolean) => void; +}) { + const [internalOpen, setInternalOpen] = useState(false); + const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar(); + const open = controlledOpen ?? internalOpen; + const setOpen = onOpenChange ?? setInternalOpen; + function closeNavigation() { + setOpen(false); + if (isMobile) setSidebarOpen(false); + } + const signOut = useSignOut({ onSignedOut: closeNavigation }); + const { slots, errorMessage } = usePluginSlots({ slotTypes: ["organizationSwitcher"], companyId, enabled: settled }); + // Never choose an arbitrary winner for a replacement surface. + if (!settled || errorMessage || slots.length !== 1) return children; + const props: PluginOrganizationSwitcherProps = { + organizationSwitcher: { + currentCompany: company ? { name: company.name, logoUrl: company.logoUrl ?? null } : null, + collapsed: collapsed && !peeking, open, onOpenChange: setOpen, + onNavigate: closeNavigation, onSignOut: () => signOut.mutate(), signingOut: signOut.isPending, + renderIcon: (name, logoUrl, inMenu) => , + }, + }; + return ; +} diff --git a/ui/src/components/SidebarCompanyMenu.production.tsx b/ui/src/components/SidebarCompanyMenu.production.tsx index af118f53280..20d26072389 100644 --- a/ui/src/components/SidebarCompanyMenu.production.tsx +++ b/ui/src/components/SidebarCompanyMenu.production.tsx @@ -45,6 +45,8 @@ import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils"; import { useSidebar } from "../context/SidebarContext"; import { CompanyPatternIcon } from "./CompanyPatternIcon"; +import { PluginOrganizationSwitcher } from "./PluginOrganizationSwitcher"; + interface SidebarCompanyMenuProps { open?: boolean; onOpenChange?: (open: boolean) => void; @@ -212,7 +214,15 @@ function SortableCompanyItem({ ); } -export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps = {}) { +export function SidebarCompanyMenu(props: SidebarCompanyMenuProps = {}) { + return ( + + + + ); +} + +function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps) { const [internalOpen, setInternalOpen] = useState(false); const [isEditingOrder, setIsEditingOrder] = useState(false); const { companies, selectedCompany, setSelectedCompanyId, companyListUnavailable, retryCompanies } = diff --git a/ui/src/components/SidebarCompanyMenu.test.tsx b/ui/src/components/SidebarCompanyMenu.test.tsx index 4c07dfb19f2..8280988fdda 100644 --- a/ui/src/components/SidebarCompanyMenu.test.tsx +++ b/ui/src/components/SidebarCompanyMenu.test.tsx @@ -9,6 +9,8 @@ import { queryKeys } from "@/lib/queryKeys"; import { SidebarCompanyMenu } from "./SidebarCompanyMenu"; import { SidebarCompanyMenu as SidebarCompanyMenuProduction } from "./SidebarCompanyMenu.production"; +vi.mock("./PluginOrganizationSwitcher", () => ({ PluginOrganizationSwitcher: ({ children }: { children: ReactNode }) => children })); + const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn(), signInEmail: vi.fn(), diff --git a/ui/src/components/SidebarCompanyMenu.tsx b/ui/src/components/SidebarCompanyMenu.tsx index 0734bb057d0..04b285b3b5f 100644 --- a/ui/src/components/SidebarCompanyMenu.tsx +++ b/ui/src/components/SidebarCompanyMenu.tsx @@ -45,6 +45,8 @@ import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils"; import { useSidebar } from "../context/SidebarContext"; import { CompanyPatternIcon } from "./CompanyPatternIcon"; +import { PluginOrganizationSwitcher } from "./PluginOrganizationSwitcher"; + interface SidebarCompanyMenuProps { open?: boolean; onOpenChange?: (open: boolean) => void; @@ -212,7 +214,15 @@ function SortableCompanyItem({ ); } -export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps = {}) { +export function SidebarCompanyMenu(props: SidebarCompanyMenuProps = {}) { + return ( + + + + ); +} + +function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps) { const [internalOpen, setInternalOpen] = useState(false); const [isEditingOrder, setIsEditingOrder] = useState(false); const { companies, selectedCompany, setSelectedCompanyId, companyListUnavailable, retryCompanies } = diff --git a/ui/src/plugins/slots.tsx b/ui/src/plugins/slots.tsx index f5bf01866d2..042acaf0b47 100644 --- a/ui/src/plugins/slots.tsx +++ b/ui/src/plugins/slots.tsx @@ -701,6 +701,7 @@ export function usePluginSlots(filters: SlotFilters): UsePluginSlotsResult { type PluginSlotErrorBoundaryProps = { slot: ResolvedPluginSlot; + fallback?: ReactNode; className?: string; children: ReactNode; }; @@ -728,6 +729,7 @@ class PluginSlotErrorBoundary extends Component {this.props.slot.pluginDisplayName}: failed to render @@ -770,6 +772,10 @@ type PluginSlotMountProps = { context: PluginSlotContext; className?: string; missingBehavior?: "hidden" | "placeholder"; + /** Host-specific props; slot/context cannot be overridden. */ + componentProps?: Record; + /** Preserve required host navigation if an optional component is unavailable. */ + fallback?: ReactNode; }; /** @@ -829,6 +835,8 @@ export function PluginSlotMount({ context, className, missingBehavior = "hidden", + componentProps, + fallback, }: PluginSlotMountProps) { usePluginRegistrySubscription(); const [, forceRerender] = useState(0); @@ -852,6 +860,7 @@ export function PluginSlotMount({ }, [component, slot.pluginId]); if (!component) { + if (fallback !== undefined) return fallback; if (missingBehavior === "hidden") return null; return (
@@ -861,9 +870,9 @@ export function PluginSlotMount({ } if (component.kind === "react") { - const node = createElement(component.component, { slot, context }); + const node = createElement(component.component, { ...componentProps, slot, context }); return ( - + {className ?
{node}
: node}
@@ -871,6 +880,8 @@ export function PluginSlotMount({ ); } + if (componentProps && fallback !== undefined) return fallback; + return (