From 67f90ccde636ad87654425cd6afb081ae2b63c6e Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 18 May 2026 11:33:59 -0500 Subject: [PATCH] Add company settings plugin slot Co-Authored-By: Paperclip --- doc/plugins/PLUGIN_SPEC.md | 15 +- .../src/constants.ts | 2 + .../src/manifest.ts | 7 + .../src/ui/index.tsx | 28 ++++ packages/plugins/sdk/src/index.ts | 1 + packages/plugins/sdk/src/ui/index.ts | 1 + packages/plugins/sdk/src/ui/types.ts | 12 ++ packages/shared/src/constants.ts | 15 ++ packages/shared/src/index.ts | 2 + packages/shared/src/types/plugin.ts | 5 +- packages/shared/src/validators/plugin.test.ts | 26 ++++ packages/shared/src/validators/plugin.ts | 23 ++- .../services/plugin-capability-validator.ts | 1 + ui/src/App.tsx | 2 + .../CompanySettingsSidebar.test.tsx | 56 +++++++ ui/src/components/CompanySettingsSidebar.tsx | 19 ++- .../pages/CompanySettingsPluginPage.test.tsx | 140 ++++++++++++++++++ ui/src/pages/CompanySettingsPluginPage.tsx | 88 +++++++++++ 18 files changed, 438 insertions(+), 5 deletions(-) create mode 100644 ui/src/pages/CompanySettingsPluginPage.test.tsx create mode 100644 ui/src/pages/CompanySettingsPluginPage.tsx diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index c8080e56b9c..d1bd103486c 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -364,13 +364,16 @@ export interface PaperclipPluginManifestV1 { | "contextMenuItem" | "commentAnnotation" | "commentContextMenuItem" - | "settingsPage"; + | "settingsPage" + | "companySettingsPage"; id: string; displayName: string; /** Which export name in the UI bundle provides this component */ exportName: string; /** For detailTab: which entity types this tab appears on */ entityTypes?: Array<"project" | "issue" | "agent" | "goal" | "run">; + /** For page and companySettingsPage: single route segment */ + routePath?: string; }>; }; } @@ -1206,6 +1209,8 @@ For plugins that need richer settings UX beyond what JSON Schema can express, th Both approaches coexist: a plugin can use the auto-generated form for simple config and add a custom settings page slot for advanced configuration or operational dashboards. +For plugins that need a company-scoped settings surface, declare a `companySettingsPage` slot with a `routePath`. The host renders a sidebar item under Company Settings and mounts the component at `/:companyPrefix/company/settings/:routePath`. The page receives `companyId` and `companyPrefix` in its host context. Core settings routes such as `access`, `invites`, `environments`, and `secrets` are reserved and cannot be shadowed by plugin declarations. + ## 20. Local Tooling Plugins that need filesystem, git, terminal, or process operations implement those directly. The host does not wrap or proxy these operations. @@ -1455,6 +1460,14 @@ Each plugin may expose a company-context main page: This page is where board users do most day-to-day work. +## 24.4 Company Settings Plugin Page + +Each ready plugin may expose a company settings page: + +- `/:companyPrefix/company/settings/:routePath` + +The host adds a matching Company Settings sidebar item using the slot `displayName`. Plugin settings route segments are single-segment slugs and must not collide with core company settings pages. + ## 25. Uninstall And Data Lifecycle When a plugin is uninstalled, the host must handle plugin-owned data explicitly. diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts b/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts index 9c18f610b37..8a2e44c00aa 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts @@ -7,6 +7,7 @@ export const PAGE_ROUTE = "kitchensink"; export const SLOT_IDS = { page: "kitchen-sink-page", settingsPage: "kitchen-sink-settings-page", + companySettingsPage: "kitchen-sink-company-settings-page", dashboardWidget: "kitchen-sink-dashboard-widget", sidebar: "kitchen-sink-sidebar-link", sidebarPanel: "kitchen-sink-sidebar-panel", @@ -23,6 +24,7 @@ export const SLOT_IDS = { export const EXPORT_NAMES = { page: "KitchenSinkPage", settingsPage: "KitchenSinkSettingsPage", + companySettingsPage: "KitchenSinkCompanySettingsPage", dashboardWidget: "KitchenSinkDashboardWidget", sidebar: "KitchenSinkSidebarLink", sidebarPanel: "KitchenSinkSidebarPanel", diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts b/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts index bcff32c2eed..5fc99281868 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts @@ -194,6 +194,13 @@ const manifest: PaperclipPluginManifestV1 = { displayName: "Kitchen Sink Settings", exportName: EXPORT_NAMES.settingsPage, }, + { + type: "companySettingsPage", + id: SLOT_IDS.companySettingsPage, + displayName: "Kitchen Sink", + exportName: EXPORT_NAMES.companySettingsPage, + routePath: "kitchen-sink", + }, { type: "dashboardWidget", id: SLOT_IDS.dashboardWidget, diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx b/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx index ea3cb491e31..95ce4c14c1c 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx @@ -10,6 +10,7 @@ import { usePluginToast, type PluginCommentAnnotationProps, type PluginCommentContextMenuItemProps, + type PluginCompanySettingsPageProps, type PluginDetailTabProps, type PluginPageProps, type PluginProjectSidebarItemProps, @@ -2236,6 +2237,33 @@ export function KitchenSinkSettingsPage({ context }: PluginSettingsPageProps) { ); } +export function KitchenSinkCompanySettingsPage({ context }: PluginCompanySettingsPageProps) { + const hostNavigation = useHostNavigation(); + const overview = usePluginOverview(context.companyId); + const href = hostNavigation.resolveHref("/company/settings/kitchen-sink"); + + return ( +
+
+
+
+ Mounted inside company settings +
+ This fixture proves a ready plugin can add a settings sidebar item and render with company context. +
+ +
+
+
+
+ ); +} + export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) { const hostNavigation = useHostNavigation(); const overview = usePluginOverview(context.companyId); diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 92db6d696cf..c43e2b8323e 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -353,6 +353,7 @@ export { PLUGIN_CAPABILITIES, PLUGIN_UI_SLOT_TYPES, PLUGIN_UI_SLOT_ENTITY_TYPES, + PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS, PLUGIN_STATE_SCOPE_KINDS, PLUGIN_JOB_STATUSES, PLUGIN_JOB_RUN_STATUSES, diff --git a/packages/plugins/sdk/src/ui/index.ts b/packages/plugins/sdk/src/ui/index.ts index 8f90af70a3a..e2ce8f63aec 100644 --- a/packages/plugins/sdk/src/ui/index.ts +++ b/packages/plugins/sdk/src/ui/index.ts @@ -146,6 +146,7 @@ export type { // Slot component prop interfaces export type { PluginPageProps, + PluginCompanySettingsPageProps, PluginWidgetProps, PluginDetailTabProps, PluginSidebarProps, diff --git a/packages/plugins/sdk/src/ui/types.ts b/packages/plugins/sdk/src/ui/types.ts index b8216836157..8923803e4e5 100644 --- a/packages/plugins/sdk/src/ui/types.ts +++ b/packages/plugins/sdk/src/ui/types.ts @@ -229,6 +229,18 @@ export interface PluginPageProps { context: PluginHostContext; } +/** + * Props passed to a plugin company settings page component. + * + * A company settings page is mounted at + * `/:companyPrefix/company/settings/:routePath` and always receives the active + * company id and prefix when available. + */ +export interface PluginCompanySettingsPageProps { + /** The current host context, including company id and prefix. */ + context: PluginHostContext; +} + /** * Props passed to a plugin dashboard widget component. * diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 9c295eae956..e880689220a 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -857,6 +857,7 @@ export const PLUGIN_UI_SLOT_TYPES = [ "commentAnnotation", "commentContextMenuItem", "settingsPage", + "companySettingsPage", ] as const; export type PluginUiSlotType = (typeof PLUGIN_UI_SLOT_TYPES)[number]; @@ -887,6 +888,20 @@ export const PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS = [ export type PluginReservedCompanyRouteSegment = (typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number]; +/** + * Reserved route segments under `/:companyPrefix/company/settings/...` that + * plugin company settings pages may not claim. + */ +export const PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS = [ + "general", + "environments", + "access", + "invites", + "secrets", +] as const; +export type PluginReservedCompanySettingsRouteSegment = + (typeof PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS)[number]; + /** * Launcher placement zones describe where a plugin-owned launcher can appear * in the host UI. These are intentionally aligned with current slot surfaces diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a851acf0aca..9afa40f46a3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -111,6 +111,7 @@ export { PLUGIN_CAPABILITIES, PLUGIN_UI_SLOT_TYPES, PLUGIN_UI_SLOT_ENTITY_TYPES, + PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS, PLUGIN_LAUNCHER_PLACEMENT_ZONES, PLUGIN_LAUNCHER_ACTIONS, PLUGIN_LAUNCHER_BOUNDS, @@ -226,6 +227,7 @@ export { type PluginCapability, type PluginUiSlotType, type PluginUiSlotEntityType, + type PluginReservedCompanySettingsRouteSegment, type PluginLauncherPlacementZone, type PluginLauncherAction, type PluginLauncherBounds, diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 6f9629122cc..f9330a48005 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -346,8 +346,11 @@ export interface PluginUiSlotDeclaration { */ entityTypes?: PluginUiSlotEntityType[]; /** - * Optional company-scoped route segment for page and routeSidebar slots. + * Optional company-scoped route segment for page, routeSidebar, and + * companySettingsPage slots. * Example: `kitchensink` becomes `/:companyPrefix/kitchensink`. + * For companySettingsPage, `permissions` becomes + * `/:companyPrefix/company/settings/permissions`. */ routePath?: string; /** diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index 1984aa6b209..7e58b1084fb 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -128,4 +128,30 @@ describe("plugin UI slot validators", () => { expect(parsed.entityTypes).toEqual(["execution_workspace"]); }); + + it("accepts company settings page slots with a non-core settings route", () => { + const parsed = pluginUiSlotDeclarationSchema.parse({ + type: "companySettingsPage", + id: "permissions-settings", + displayName: "Permissions", + exportName: "PermissionsSettingsPage", + routePath: "permissions", + }); + + expect(parsed.routePath).toBe("permissions"); + }); + + it("prevents company settings page slots from shadowing core settings routes", () => { + const parsed = pluginUiSlotDeclarationSchema.safeParse({ + type: "companySettingsPage", + id: "access-settings", + displayName: "Access", + exportName: "AccessSettingsPage", + routePath: "access", + }); + + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true); + }); }); diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index cbc900cf36e..0a857b44d82 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -6,6 +6,7 @@ import { PLUGIN_UI_SLOT_TYPES, PLUGIN_UI_SLOT_ENTITY_TYPES, PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS, + PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS, PLUGIN_LAUNCHER_PLACEMENT_ZONES, PLUGIN_LAUNCHER_ACTIONS, PLUGIN_LAUNCHER_BOUNDS, @@ -322,10 +323,10 @@ export const pluginUiSlotDeclarationSchema = z.object({ path: ["entityTypes"], }); } - if (value.routePath && value.type !== "page" && value.type !== "routeSidebar") { + if (value.routePath && value.type !== "page" && value.type !== "routeSidebar" && value.type !== "companySettingsPage") { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "routePath is only supported for page and routeSidebar slots", + message: "routePath is only supported for page, routeSidebar, and companySettingsPage slots", path: ["routePath"], }); } @@ -336,6 +337,13 @@ export const pluginUiSlotDeclarationSchema = z.object({ path: ["routePath"], }); } + if (value.type === "companySettingsPage" && !value.routePath) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "companySettingsPage slots require routePath", + path: ["routePath"], + }); + } if (value.routePath && PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS.includes(value.routePath as (typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number])) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -343,6 +351,17 @@ export const pluginUiSlotDeclarationSchema = z.object({ path: ["routePath"], }); } + if ( + value.type === "companySettingsPage" + && value.routePath + && PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS.includes(value.routePath as (typeof PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS)[number]) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `company settings routePath "${value.routePath}" is reserved by the host`, + path: ["routePath"], + }); + } }); export type PluginUiSlotDeclarationInput = z.infer; diff --git a/server/src/services/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts index e400ab88ca9..f8b47866c35 100644 --- a/server/src/services/plugin-capability-validator.ts +++ b/server/src/services/plugin-capability-validator.ts @@ -149,6 +149,7 @@ const UI_SLOT_CAPABILITIES: Record = { commentAnnotation: "ui.commentAnnotation.register", commentContextMenuItem: "ui.action.register", settingsPage: "instance.settings.register", + companySettingsPage: "instance.settings.register", routeSidebar: "ui.sidebar.register", }; diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 0da91b9a183..fa320fe4639 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -30,6 +30,7 @@ import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { CompanyEnvironments } from "./pages/CompanyEnvironments"; +import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage"; import { CompanyAccess } from "./pages/CompanyAccess"; import { CompanyInvites } from "./pages/CompanyInvites"; import { CompanySkills } from "./pages/CompanySkills"; @@ -74,6 +75,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx index 429d0812237..adc9fec554e 100644 --- a/ui/src/components/CompanySettingsSidebar.test.tsx +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -10,6 +10,7 @@ const sidebarNavItemMock = vi.hoisted(() => vi.fn()); const mockSidebarBadgesApi = vi.hoisted(() => ({ get: vi.fn(), })); +const mockUsePluginSlots = vi.hoisted(() => vi.fn()); vi.mock("@/lib/router", () => ({ Link: ({ @@ -61,6 +62,10 @@ vi.mock("@/api/sidebarBadges", () => ({ sidebarBadgesApi: mockSidebarBadgesApi, })); +vi.mock("@/plugins/slots", () => ({ + usePluginSlots: mockUsePluginSlots, +})); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -83,6 +88,11 @@ describe("CompanySettingsSidebar", () => { failedRuns: 0, joinRequests: 2, }); + mockUsePluginSlots.mockReturnValue({ + slots: [], + isLoading: false, + errorMessage: null, + }); }); afterEach(() => { @@ -154,4 +164,50 @@ describe("CompanySettingsSidebar", () => { root.unmount(); }); }); + + it("renders company settings pages contributed by ready plugins", async () => { + mockUsePluginSlots.mockReturnValue({ + slots: [ + { + type: "companySettingsPage", + id: "permissions", + displayName: "Permissions", + exportName: "PermissionsPage", + routePath: "permissions", + pluginId: "plugin-1", + pluginKey: "paperclip-ee", + pluginDisplayName: "Paperclip EE", + pluginVersion: "0.1.0", + }, + ], + isLoading: false, + errorMessage: null, + }); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Permissions"); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/permissions", + label: "Permissions", + end: true, + }), + ); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index fb8870c999c..522f2f5bb28 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -1,16 +1,22 @@ import { useQuery } from "@tanstack/react-query"; -import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Settings, Shield, SlidersHorizontal } from "lucide-react"; +import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Puzzle, Settings, Shield, SlidersHorizontal } from "lucide-react"; import { sidebarBadgesApi } from "@/api/sidebarBadges"; import { ApiError } from "@/api/client"; import { Link } from "@/lib/router"; import { queryKeys } from "@/lib/queryKeys"; import { useCompany } from "@/context/CompanyContext"; import { useSidebar } from "@/context/SidebarContext"; +import { usePluginSlots } from "@/plugins/slots"; import { SidebarNavItem } from "./SidebarNavItem"; export function CompanySettingsSidebar() { const { selectedCompany, selectedCompanyId } = useCompany(); const { isMobile, setSidebarOpen } = useSidebar(); + const { slots: companySettingsPluginSlots } = usePluginSlots({ + slotTypes: ["companySettingsPage"], + companyId: selectedCompanyId, + enabled: !!selectedCompanyId, + }); const { data: badges } = useQuery({ queryKey: selectedCompanyId ? queryKeys.sidebarBadges(selectedCompanyId) @@ -67,6 +73,17 @@ export function CompanySettingsSidebar() { badge={badges?.joinRequests ?? 0} end /> + {companySettingsPluginSlots + .filter((slot) => slot.routePath) + .map((slot) => ( + + ))} diff --git a/ui/src/pages/CompanySettingsPluginPage.test.tsx b/ui/src/pages/CompanySettingsPluginPage.test.tsx new file mode 100644 index 00000000000..5c22a35057e --- /dev/null +++ b/ui/src/pages/CompanySettingsPluginPage.test.tsx @@ -0,0 +1,140 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CompanySettingsPluginPage } from "./CompanySettingsPluginPage"; + +const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); +const mockUsePluginSlots = vi.hoisted(() => vi.fn()); +const mockParams = vi.hoisted(() => ({ + companyPrefix: "PAP" as string | undefined, + settingsRoutePath: "permissions" as string | undefined, +})); + +vi.mock("@/context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ + setBreadcrumbs: mockSetBreadcrumbs, + }), +})); + +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ + companies: [{ id: "company-1", name: "Paperclip", issuePrefix: "PAP" }], + selectedCompanyId: "company-1", + }), +})); + +vi.mock("@/lib/router", () => ({ + Link: ({ to, children }: { to: string; children: React.ReactNode }) => {children}, + useLocation: () => ({ pathname: "/PAP/company/settings/permissions", search: "", hash: "" }), + useParams: () => mockParams, +})); + +vi.mock("@/plugins/slots", () => ({ + usePluginSlots: mockUsePluginSlots, + PluginSlotMount: ({ + slot, + context, + }: { + slot: { displayName: string }; + context: { companyId: string | null; companyPrefix: string | null }; + }) => ( +
+ {slot.displayName}:{context.companyId}:{context.companyPrefix} +
+ ), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +async function renderPage(container: HTMLDivElement) { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + return root; +} + +describe("CompanySettingsPluginPage", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockParams.companyPrefix = "PAP"; + mockParams.settingsRoutePath = "permissions"; + mockUsePluginSlots.mockReturnValue({ + slots: [ + { + type: "companySettingsPage", + id: "permissions", + displayName: "Permissions", + exportName: "PermissionsPage", + routePath: "permissions", + pluginId: "plugin-1", + pluginKey: "paperclip-ee", + pluginDisplayName: "Paperclip EE", + pluginVersion: "0.1.0", + }, + ], + isLoading: false, + errorMessage: null, + }); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("mounts the matching company settings slot with company context", async () => { + const root = await renderPage(container); + + expect(container.querySelector('[data-testid="plugin-slot-mount"]')?.textContent).toBe( + "Permissions:company-1:PAP", + ); + expect(mockSetBreadcrumbs).toHaveBeenCalledWith([ + { label: "Settings", href: "/company/settings" }, + { label: "Permissions" }, + ]); + + await act(async () => { + root.unmount(); + }); + }); + + it("fails closed when no ready plugin declares the route", async () => { + mockUsePluginSlots.mockReturnValue({ + slots: [], + isLoading: false, + errorMessage: null, + }); + const root = await renderPage(container); + + expect(container.textContent).toContain("Page not found"); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/pages/CompanySettingsPluginPage.tsx b/ui/src/pages/CompanySettingsPluginPage.tsx new file mode 100644 index 00000000000..e4961a369c3 --- /dev/null +++ b/ui/src/pages/CompanySettingsPluginPage.tsx @@ -0,0 +1,88 @@ +import { useEffect, useMemo } from "react"; +import { useParams } from "@/lib/router"; +import { useBreadcrumbs } from "@/context/BreadcrumbContext"; +import { useCompany } from "@/context/CompanyContext"; +import { PluginSlotMount, usePluginSlots } from "@/plugins/slots"; +import { NotFoundPage } from "./NotFound"; + +export function CompanySettingsPluginPage() { + const params = useParams<{ + companyPrefix?: string; + settingsRoutePath?: string; + }>(); + const { companyPrefix: routeCompanyPrefix, settingsRoutePath } = params; + const { companies, selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + + const routeCompany = useMemo(() => { + if (!routeCompanyPrefix) return null; + const requested = routeCompanyPrefix.toUpperCase(); + return companies.find((company) => company.issuePrefix.toUpperCase() === requested) ?? null; + }, [companies, routeCompanyPrefix]); + const hasInvalidCompanyPrefix = Boolean(routeCompanyPrefix) && !routeCompany; + const resolvedCompanyId = routeCompany?.id ?? (routeCompanyPrefix ? null : selectedCompanyId ?? null); + const companyPrefix = resolvedCompanyId + ? companies.find((company) => company.id === resolvedCompanyId)?.issuePrefix ?? null + : null; + + const { slots, isLoading, errorMessage } = usePluginSlots({ + slotTypes: ["companySettingsPage"], + companyId: resolvedCompanyId, + enabled: Boolean(resolvedCompanyId && settingsRoutePath), + }); + + const pageSlots = useMemo(() => { + if (!settingsRoutePath) return []; + return slots.filter((slot) => slot.routePath === settingsRoutePath); + }, [settingsRoutePath, slots]); + + const pageSlot = pageSlots.length === 1 ? pageSlots[0] : null; + + useEffect(() => { + if (!pageSlot) return; + setBreadcrumbs([ + { label: "Settings", href: "/company/settings" }, + { label: pageSlot.displayName }, + ]); + }, [pageSlot, setBreadcrumbs]); + + if (!resolvedCompanyId) { + if (hasInvalidCompanyPrefix) { + return ; + } + return
Select a company to view this page.
; + } + + if (!settingsRoutePath || isLoading) { + return
Loading...
; + } + + if (errorMessage) { + return ( +
+ Plugin extensions unavailable: {errorMessage} +
+ ); + } + + if (pageSlots.length > 1) { + return ( +
+ Multiple plugins declare the company settings route {settingsRoutePath}. Disable one plugin or change its route. +
+ ); + } + + if (!pageSlot) { + return ; + } + + return ( + + ); +}