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 (
+
+ );
+}