tree.
+ */
+export const DynamicRoutes: React.FC = () => {
+ const routes = usePluginRoutes();
+
+ return (
+ <>
+ {routes.map((r) => (
+ }
+ />
+ ))}
+ >
+ );
+};
diff --git a/freeipa-webui-plugin-sdk/src/ExtensionSlot.tsx b/freeipa-webui-plugin-sdk/src/ExtensionSlot.tsx
new file mode 100644
index 000000000..6f4365ec4
--- /dev/null
+++ b/freeipa-webui-plugin-sdk/src/ExtensionSlot.tsx
@@ -0,0 +1,95 @@
+import React from "react";
+import { usePluginExtensions } from "./hooks";
+
+// ---------------------------------------------------------------------------
+// Error boundary that catches crashes in plugin components
+// ---------------------------------------------------------------------------
+
+interface ErrorBoundaryProps {
+ pluginId: string;
+ children: React.ReactNode;
+}
+
+interface ErrorBoundaryState {
+ error: Error | null;
+}
+
+class PluginErrorBoundary extends React.Component<
+ ErrorBoundaryProps,
+ ErrorBoundaryState
+> {
+ constructor(props: ErrorBoundaryProps) {
+ super(props);
+ this.state = { error: null };
+ }
+
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
+ return { error };
+ }
+
+ componentDidCatch(error: Error, info: React.ErrorInfo): void {
+ console.error(
+ `[ExtensionSlot] Plugin "${this.props.pluginId}" crashed:`,
+ error,
+ info.componentStack
+ );
+ }
+
+ render(): React.ReactNode {
+ if (this.state.error) {
+ return (
+
+ Plugin "{this.props.pluginId}" encountered an error.
+
+ );
+ }
+ return this.props.children;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ExtensionSlot component
+// ---------------------------------------------------------------------------
+
+export interface ExtensionSlotProps {
+ extensionPointId: string;
+ /** Arbitrary context passed as props to every plugin component. */
+ context?: Record;
+}
+
+export const ExtensionSlot: React.FC = ({
+ extensionPointId,
+ context = {},
+}) => {
+ const extensions = usePluginExtensions(extensionPointId);
+
+ if (extensions.length === 0) {
+ return null;
+ }
+
+ return (
+ <>
+ {extensions.map((ext, idx) => {
+ const Component = ext.component;
+ return (
+
+
+
+ );
+ })}
+ >
+ );
+};
diff --git a/freeipa-webui-plugin-sdk/src/PluginLoader.ts b/freeipa-webui-plugin-sdk/src/PluginLoader.ts
new file mode 100644
index 000000000..40f41718e
--- /dev/null
+++ b/freeipa-webui-plugin-sdk/src/PluginLoader.ts
@@ -0,0 +1,74 @@
+import type { PluginManifest, PluginModule } from "./types";
+import { pluginRegistry } from "./PluginRegistry";
+
+export interface LoadPluginsOptions {
+ manifestUrl?: string;
+}
+
+const DEFAULT_MANIFEST_URL = "/ipa/modern-ui/plugins/manifest.json";
+
+/**
+ * Fetch the plugin manifest from the server and dynamically import each
+ * enabled plugin. Plugins that fail to load or register are skipped
+ * individually without affecting other plugins.
+ */
+export async function loadPlugins(
+ options: LoadPluginsOptions = {}
+): Promise {
+ const manifestUrl = options.manifestUrl ?? DEFAULT_MANIFEST_URL;
+
+ let manifest: PluginManifest;
+ try {
+ const response = await fetch(manifestUrl);
+ if (!response.ok) {
+ console.warn(
+ `[PluginLoader] Manifest fetch returned ${response.status}, no plugins loaded.`
+ );
+ return;
+ }
+ manifest = await response.json();
+ } catch (err) {
+ console.warn("[PluginLoader] Could not fetch plugin manifest:", err);
+ return;
+ }
+
+ const enabledPlugins = manifest.plugins.filter((p) => p.enabled);
+ if (enabledPlugins.length === 0) {
+ console.info("[PluginLoader] No enabled plugins found.");
+ return;
+ }
+
+ console.info(
+ `[PluginLoader] Loading ${enabledPlugins.length} plugin(s)...`
+ );
+
+ const results = await Promise.allSettled(
+ enabledPlugins.map(async (entry) => {
+ try {
+ const mod = await import(/* @vite-ignore */ entry.entrypoint);
+ const pluginModule: PluginModule = mod.default ?? mod;
+
+ if (!pluginModule.id || !pluginModule.register) {
+ console.error(
+ `[PluginLoader] Plugin at "${entry.entrypoint}" does not export a valid PluginModule.`
+ );
+ return;
+ }
+
+ await pluginRegistry.registerPlugin(pluginModule);
+ } catch (err) {
+ console.error(
+ `[PluginLoader] Failed to load plugin "${entry.id}" from "${entry.entrypoint}":`,
+ err
+ );
+ }
+ })
+ );
+
+ const failed = results.filter((r) => r.status === "rejected").length;
+ if (failed > 0) {
+ console.warn(`[PluginLoader] ${failed} plugin(s) failed to load.`);
+ }
+
+ await pluginRegistry.runPhase("init");
+}
diff --git a/freeipa-webui-plugin-sdk/src/PluginRegistry.ts b/freeipa-webui-plugin-sdk/src/PluginRegistry.ts
new file mode 100644
index 000000000..07083c073
--- /dev/null
+++ b/freeipa-webui-plugin-sdk/src/PluginRegistry.ts
@@ -0,0 +1,222 @@
+import type {
+ PluginModule,
+ RegisteredExtension,
+ ComponentExtensionConfig,
+ NavigationItemConfig,
+ RouteConfig,
+ PluginAPI,
+} from "./types";
+import type { Reducer } from "@reduxjs/toolkit";
+
+type PhaseHandler = () => void;
+
+export class PluginRegistry {
+ private plugins = new Map();
+ private extensions = new Map();
+ private routes: (RouteConfig & { pluginId: string })[] = [];
+ private navItems: (NavigationItemConfig & { pluginId: string })[] = [];
+ private reducers = new Map();
+ private injectedEndpoints: { pluginId: string; endpoints: any }[] = [];
+ private phaseHandlers = new Map();
+ private listeners = new Set<() => void>();
+ private revision = 0;
+
+ private configGetter: (() => Record) | null = null;
+ private userGetter: (() => string | null) | null = null;
+ private endpointInjector: ((endpoints: any) => void) | null = null;
+ private reducerInjector:
+ | ((key: string, reducer: Reducer) => void)
+ | null = null;
+
+ /** Host calls this so plugins can read IPA config. */
+ setConfigGetter(getter: () => Record): void {
+ this.configGetter = getter;
+ }
+
+ /** Host calls this so plugins can read the logged-in user. */
+ setUserGetter(getter: () => string | null): void {
+ this.userGetter = getter;
+ }
+
+ /** Host calls this to provide the RTK Query injectEndpoints callback. */
+ setEndpointInjector(injector: (endpoints: any) => void): void {
+ this.endpointInjector = injector;
+ }
+
+ /** Host calls this to provide the dynamic reducer injection callback. */
+ setReducerInjector(
+ injector: (key: string, reducer: Reducer) => void
+ ): void {
+ this.reducerInjector = injector;
+ }
+
+ /** Build a PluginAPI scoped to a specific plugin. */
+ private createAPI(pluginId: string): PluginAPI {
+ return {
+ addComponent: (config: ComponentExtensionConfig) => {
+ for (const target of config.targets) {
+ const list = this.extensions.get(target) || [];
+ list.push({
+ pluginId,
+ title: config.title,
+ description: config.description,
+ component: config.component,
+ priority: config.priority ?? 0,
+ });
+ list.sort((a, b) => b.priority - a.priority);
+ this.extensions.set(target, list);
+ }
+ this.notify();
+ },
+
+ addNavigationItem: (config: NavigationItemConfig) => {
+ this.navItems.push({ ...config, pluginId });
+ this.navItems.sort(
+ (a, b) => (a.position ?? 100) - (b.position ?? 100)
+ );
+ this.notify();
+ },
+
+ addRoute: (config: RouteConfig) => {
+ this.routes.push({ ...config, pluginId });
+ this.notify();
+ },
+
+ addReducer: (key: string, reducer: Reducer) => {
+ this.reducers.set(key, reducer);
+ if (this.reducerInjector) {
+ this.reducerInjector(key, reducer);
+ }
+ this.notify();
+ },
+
+ injectEndpoints: (endpoints: any) => {
+ this.injectedEndpoints.push({ pluginId, endpoints });
+ if (this.endpointInjector) {
+ this.endpointInjector(endpoints);
+ }
+ this.notify();
+ },
+
+ getConfig: () => (this.configGetter ? this.configGetter() : {}),
+ getUser: () => (this.userGetter ? this.userGetter() : null),
+
+ onPhase: (phase: string, handler: PhaseHandler) => {
+ const handlers = this.phaseHandlers.get(phase) || [];
+ handlers.push(handler);
+ this.phaseHandlers.set(phase, handlers);
+ },
+ };
+ }
+
+ /** Register a loaded plugin module. */
+ async registerPlugin(plugin: PluginModule): Promise {
+ if (this.plugins.has(plugin.id)) {
+ console.warn(
+ `[PluginRegistry] Plugin "${plugin.id}" already registered, skipping.`
+ );
+ return;
+ }
+
+ this.plugins.set(plugin.id, plugin);
+ const api = this.createAPI(plugin.id);
+
+ try {
+ await plugin.register(api);
+ console.info(
+ `[PluginRegistry] Registered "${plugin.name}" v${plugin.version}`
+ );
+ } catch (err) {
+ console.error(
+ `[PluginRegistry] Failed to register "${plugin.id}":`,
+ err
+ );
+ this.plugins.delete(plugin.id);
+ }
+ }
+
+ // -- Accessors --------------------------------------------------------
+
+ getExtensions(extensionPointId: string): RegisteredExtension[] {
+ return this.extensions.get(extensionPointId) || [];
+ }
+
+ getRoutes(): (RouteConfig & { pluginId: string })[] {
+ return [...this.routes];
+ }
+
+ getNavItems(): (NavigationItemConfig & { pluginId: string })[] {
+ return [...this.navItems];
+ }
+
+ getReducers(): Map {
+ return new Map(this.reducers);
+ }
+
+ getPlugins(): Map {
+ return new Map(this.plugins);
+ }
+
+ getRevision(): number {
+ return this.revision;
+ }
+
+ // -- Subscription -----------------------------------------------------
+
+ subscribe(listener: () => void): () => void {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+
+ private notify(): void {
+ this.revision++;
+ this.listeners.forEach((fn) => {
+ try {
+ fn();
+ } catch {
+ /* listener errors must not break the registry */
+ }
+ });
+ }
+
+ // -- Lifecycle --------------------------------------------------------
+
+ async runPhase(phase: string): Promise {
+ const handlers = this.phaseHandlers.get(phase) || [];
+ for (const handler of handlers) {
+ try {
+ handler();
+ } catch (err) {
+ console.error(
+ `[PluginRegistry] Error in "${phase}" phase handler:`,
+ err
+ );
+ }
+ }
+ }
+
+ cleanup(): void {
+ for (const [, plugin] of this.plugins) {
+ try {
+ plugin.cleanup?.();
+ } catch (err) {
+ console.error(
+ `[PluginRegistry] Cleanup error for "${plugin.id}":`,
+ err
+ );
+ }
+ }
+ this.plugins.clear();
+ this.extensions.clear();
+ this.routes = [];
+ this.navItems = [];
+ this.reducers.clear();
+ this.injectedEndpoints = [];
+ this.phaseHandlers.clear();
+ this.listeners.clear();
+ this.revision++;
+ }
+}
+
+/** Singleton instance used by both the host and plugins. */
+export const pluginRegistry = new PluginRegistry();
diff --git a/freeipa-webui-plugin-sdk/src/extensionPoints.ts b/freeipa-webui-plugin-sdk/src/extensionPoints.ts
new file mode 100644
index 000000000..1c81e8e4b
--- /dev/null
+++ b/freeipa-webui-plugin-sdk/src/extensionPoints.ts
@@ -0,0 +1,36 @@
+import type { ExtensionPointDefinition } from "./types";
+
+export const EXTENSION_POINTS = {
+ DASHBOARD_CONTENT: "ipa/dashboard/content/v1",
+ USER_DETAIL_SECTIONS: "ipa/users/detail/sections/v1",
+ LOGIN_BRANDING: "ipa/login/branding/v1",
+ HOST_GROUPS_TABLE_COLUMNS: "ipa/hostgroups/table/columns/v1",
+ HEADER_TOOLS: "ipa/layout/header-tools/v1",
+} as const;
+
+export type ExtensionPointId =
+ (typeof EXTENSION_POINTS)[keyof typeof EXTENSION_POINTS];
+
+export const extensionPointDefinitions: ExtensionPointDefinition[] = [
+ {
+ id: EXTENSION_POINTS.DASHBOARD_CONTENT,
+ description: "Add widgets or content blocks to the main Dashboard page.",
+ },
+ {
+ id: EXTENSION_POINTS.USER_DETAIL_SECTIONS,
+ description: "Add extra sections to the user detail/settings page.",
+ },
+ {
+ id: EXTENSION_POINTS.LOGIN_BRANDING,
+ description:
+ "Customize login page branding (logo, text) via an effect component.",
+ },
+ {
+ id: EXTENSION_POINTS.HOST_GROUPS_TABLE_COLUMNS,
+ description: "Add extra columns to the Host Groups table.",
+ },
+ {
+ id: EXTENSION_POINTS.HEADER_TOOLS,
+ description: "Add items to the masthead/toolbar area.",
+ },
+];
diff --git a/freeipa-webui-plugin-sdk/src/hooks.ts b/freeipa-webui-plugin-sdk/src/hooks.ts
new file mode 100644
index 000000000..656bf4765
--- /dev/null
+++ b/freeipa-webui-plugin-sdk/src/hooks.ts
@@ -0,0 +1,41 @@
+import { useSyncExternalStore } from "react";
+import { pluginRegistry } from "./PluginRegistry";
+import type { RegisteredExtension, RouteConfig, NavigationItemConfig } from "./types";
+
+function subscribeToRegistry(callback: () => void): () => void {
+ return pluginRegistry.subscribe(callback);
+}
+
+function getRegistryRevision(): number {
+ return pluginRegistry.getRevision();
+}
+
+/**
+ * Re-renders when the plugin registry changes.
+ * Returns the current registry revision (used as a cache key).
+ */
+function useRegistrySync(): number {
+ return useSyncExternalStore(subscribeToRegistry, getRegistryRevision);
+}
+
+/** Get all plugin components registered at an extension point. */
+export function usePluginExtensions(
+ extensionPointId: string
+): RegisteredExtension[] {
+ useRegistrySync();
+ return pluginRegistry.getExtensions(extensionPointId);
+}
+
+/** Get all plugin-registered routes. */
+export function usePluginRoutes(): (RouteConfig & { pluginId: string })[] {
+ useRegistrySync();
+ return pluginRegistry.getRoutes();
+}
+
+/** Get all plugin-registered navigation items. */
+export function usePluginNavItems(): (NavigationItemConfig & {
+ pluginId: string;
+})[] {
+ useRegistrySync();
+ return pluginRegistry.getNavItems();
+}
diff --git a/freeipa-webui-plugin-sdk/src/index.ts b/freeipa-webui-plugin-sdk/src/index.ts
new file mode 100644
index 000000000..078b8f2f8
--- /dev/null
+++ b/freeipa-webui-plugin-sdk/src/index.ts
@@ -0,0 +1,43 @@
+// Types
+export type {
+ PluginModule,
+ PluginAPI,
+ PluginManifest,
+ PluginManifestEntry,
+ RegisteredExtension,
+ ExtensionPointDefinition,
+ ComponentExtensionConfig,
+ NavigationItemConfig,
+ RouteConfig,
+} from "./types";
+
+// Registry
+export { PluginRegistry, pluginRegistry } from "./PluginRegistry";
+
+// Loader
+export { loadPlugins } from "./PluginLoader";
+export type { LoadPluginsOptions } from "./PluginLoader";
+
+// Components
+export { ExtensionSlot } from "./ExtensionSlot";
+export type { ExtensionSlotProps } from "./ExtensionSlot";
+export { DynamicRoutes } from "./DynamicRoutes";
+export { DynamicNav } from "./DynamicNav";
+
+// Hooks
+export {
+ usePluginExtensions,
+ usePluginRoutes,
+ usePluginNavItems,
+} from "./hooks";
+
+// Extension point constants
+export { EXTENSION_POINTS, extensionPointDefinitions } from "./extensionPoints";
+export type { ExtensionPointId } from "./extensionPoints";
+
+// Shared dependencies
+export { exposeSharedDependencies, getSharedDependency } from "./sharedDeps";
+
+// Plugin Vite config
+export { pluginViteConfig } from "./pluginViteConfig";
+export type { PluginViteConfigOptions } from "./pluginViteConfig";
diff --git a/freeipa-webui-plugin-sdk/src/pluginViteConfig.ts b/freeipa-webui-plugin-sdk/src/pluginViteConfig.ts
new file mode 100644
index 000000000..ae63cdfdb
--- /dev/null
+++ b/freeipa-webui-plugin-sdk/src/pluginViteConfig.ts
@@ -0,0 +1,77 @@
+/**
+ * Vite build configuration preset for plugin authors.
+ *
+ * Usage in a plugin's vite.config.ts:
+ *
+ * import { defineConfig } from "vite";
+ * import { pluginViteConfig } from "@freeipa/plugin-sdk/pluginViteConfig";
+ *
+ * export default defineConfig(pluginViteConfig({ pluginName: "my-plugin" }));
+ */
+
+import type { UserConfig } from "vite";
+
+const SHARED_EXTERNALS = [
+ "react",
+ "react-dom",
+ "react-dom/client",
+ "react/jsx-runtime",
+ "react-router",
+ "react-redux",
+ "@reduxjs/toolkit",
+ "@reduxjs/toolkit/query",
+ "@reduxjs/toolkit/query/react",
+ "@patternfly/react-core",
+ "@patternfly/react-icons",
+ "@patternfly/react-table",
+ "@freeipa/plugin-sdk",
+];
+
+const GLOBALS_MAP: Record = {
+ react: "window.__IPA_SHARED__.React",
+ "react-dom": "window.__IPA_SHARED__.ReactDOM",
+ "react-dom/client": "window.__IPA_SHARED__.ReactDOM",
+ "react/jsx-runtime": "window.__IPA_SHARED__.React",
+ "react-router": "window.__IPA_SHARED__.ReactRouter",
+ "react-redux": "window.__IPA_SHARED__.ReactRedux",
+ "@reduxjs/toolkit": "window.__IPA_SHARED__.ReduxToolkit",
+ "@reduxjs/toolkit/query": "window.__IPA_SHARED__.ReduxToolkit",
+ "@reduxjs/toolkit/query/react": "window.__IPA_SHARED__.ReduxToolkit",
+ "@patternfly/react-core": "window.__IPA_SHARED__.PatternFlyReactCore",
+ "@patternfly/react-icons": "window.__IPA_SHARED__.PatternFlyReactIcons",
+ "@patternfly/react-table": "window.__IPA_SHARED__.PatternFlyReactTable",
+};
+
+export interface PluginViteConfigOptions {
+ pluginName: string;
+ /** Override the entry file (default: "src/index.ts"). */
+ entry?: string;
+ /** Override the output directory (default: "dist"). */
+ outDir?: string;
+}
+
+export function pluginViteConfig(
+ options: PluginViteConfigOptions
+): UserConfig {
+ const entry = options.entry ?? "src/index.ts";
+ const outDir = options.outDir ?? "dist";
+
+ return {
+ build: {
+ outDir,
+ lib: {
+ entry,
+ formats: ["es"],
+ fileName: () => "plugin.js",
+ },
+ rollupOptions: {
+ external: SHARED_EXTERNALS,
+ output: {
+ globals: GLOBALS_MAP,
+ },
+ },
+ sourcemap: true,
+ minify: false,
+ },
+ };
+}
diff --git a/freeipa-webui-plugin-sdk/src/sharedDeps.ts b/freeipa-webui-plugin-sdk/src/sharedDeps.ts
new file mode 100644
index 000000000..010a6d25c
--- /dev/null
+++ b/freeipa-webui-plugin-sdk/src/sharedDeps.ts
@@ -0,0 +1,25 @@
+/**
+ * Expose host dependencies on window.__IPA_SHARED__ so dynamically loaded
+ * plugins can access them without bundling their own copies.
+ *
+ * Call this once in the host's main.tsx BEFORE loading any plugins.
+ */
+export function exposeSharedDependencies(deps: Record): void {
+ (window as any).__IPA_SHARED__ = deps;
+}
+
+/**
+ * Retrieve a shared dependency exposed by the host.
+ * Plugins can call this, but typically the Vite externals config
+ * handles the mapping automatically.
+ */
+export function getSharedDependency(name: string): T {
+ const shared = (window as any).__IPA_SHARED__;
+ if (!shared || !(name in shared)) {
+ throw new Error(
+ `[PluginSDK] Shared dependency "${name}" not found. ` +
+ `Is the host exposing it via exposeSharedDependencies()?`
+ );
+ }
+ return shared[name] as T;
+}
diff --git a/freeipa-webui-plugin-sdk/src/types.ts b/freeipa-webui-plugin-sdk/src/types.ts
new file mode 100644
index 000000000..17568a443
--- /dev/null
+++ b/freeipa-webui-plugin-sdk/src/types.ts
@@ -0,0 +1,93 @@
+import type { Reducer } from "@reduxjs/toolkit";
+import type { ComponentType, ReactNode } from "react";
+
+// ---------------------------------------------------------------------------
+// Extension point definition
+// ---------------------------------------------------------------------------
+
+export interface ExtensionPointDefinition {
+ id: string;
+ description: string;
+}
+
+// ---------------------------------------------------------------------------
+// Plugin API -- handed to every plugin during register()
+// ---------------------------------------------------------------------------
+
+export interface ComponentExtensionConfig {
+ targets: string[];
+ title: string;
+ description: string;
+ component: ComponentType;
+ priority?: number;
+}
+
+export interface NavigationItemConfig {
+ label: string;
+ path: string;
+ group?: string;
+ icon?: ReactNode;
+ position?: number;
+ title?: string;
+}
+
+export interface RouteConfig {
+ path: string;
+ component: ComponentType;
+ title?: string;
+}
+
+export interface PluginAPI {
+ addComponent(config: ComponentExtensionConfig): void;
+ addNavigationItem(config: NavigationItemConfig): void;
+ addRoute(config: RouteConfig): void;
+ addReducer(key: string, reducer: Reducer): void;
+ injectEndpoints(endpoints: any): void;
+ getConfig(): Record;
+ getUser(): string | null;
+ onPhase(phase: "init" | "ready" | "cleanup", handler: () => void): void;
+}
+
+// ---------------------------------------------------------------------------
+// Plugin module contract -- what every plugin default-exports
+// ---------------------------------------------------------------------------
+
+export interface PluginModule {
+ id: string;
+ name: string;
+ version: string;
+ description?: string;
+ minHostVersion?: string;
+
+ register(api: PluginAPI): void | Promise;
+ cleanup?(): void;
+}
+
+// ---------------------------------------------------------------------------
+// Internal registered-extension record
+// ---------------------------------------------------------------------------
+
+export interface RegisteredExtension {
+ pluginId: string;
+ title: string;
+ description: string;
+ component: ComponentType;
+ priority: number;
+}
+
+// ---------------------------------------------------------------------------
+// Manifest types (returned by the server endpoint)
+// ---------------------------------------------------------------------------
+
+export interface PluginManifestEntry {
+ id: string;
+ name: string;
+ version: string;
+ entrypoint: string;
+ enabled: boolean;
+}
+
+export interface PluginManifest {
+ apiVersion: string;
+ plugins: PluginManifestEntry[];
+}
diff --git a/freeipa-webui-plugin-sdk/tsconfig.json b/freeipa-webui-plugin-sdk/tsconfig.json
new file mode 100644
index 000000000..a0cd34145
--- /dev/null
+++ b/freeipa-webui-plugin-sdk/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ES2020",
+ "moduleResolution": "bundler",
+ "lib": ["ES2020", "DOM"],
+ "jsx": "react-jsx",
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "isolatedModules": true
+ },
+ "include": ["src"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/package-lock.json b/package-lock.json
index 0f0fdbd65..36aa9c96e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.9",
"license": "GPL-3.0-or-later",
"dependencies": {
+ "@freeipa/plugin-sdk": "file:../freeipa-webui-plugin-sdk",
"@patternfly/patternfly": "^6.4.0",
"@patternfly/react-core": "^6.4.0",
"@patternfly/react-icons": "^6.4.0",
@@ -64,6 +65,42 @@
"node": ">=18"
}
},
+ "../freeipa-webui-plugin-sdk": {
+ "version": "0.1.0",
+ "license": "GPL-3.0-or-later",
+ "devDependencies": {
+ "@patternfly/react-core": "^6.4.0",
+ "@patternfly/react-icons": "^6.4.0",
+ "@patternfly/react-table": "^6.4.0",
+ "@reduxjs/toolkit": "^2.6.1",
+ "@types/react": "^18.0.0",
+ "@types/react-dom": "^18.1.1",
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0",
+ "react-redux": "^9.2.0",
+ "react-router": "^7.12.0",
+ "typescript": "^5.8.3",
+ "vite": "^6.3.5"
+ },
+ "peerDependencies": {
+ "@patternfly/react-core": "^6.0.0",
+ "@patternfly/react-icons": "^6.0.0",
+ "@patternfly/react-table": "^6.0.0",
+ "@reduxjs/toolkit": "^2.0.0",
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0",
+ "react-redux": "^9.0.0",
+ "react-router": "^7.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@patternfly/react-icons": {
+ "optional": true
+ },
+ "@patternfly/react-table": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@acemir/cssom": {
"version": "0.9.31",
"resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
@@ -1952,6 +1989,10 @@
"node": ">=14"
}
},
+ "node_modules/@freeipa/plugin-sdk": {
+ "resolved": "../freeipa-webui-plugin-sdk",
+ "link": true
+ },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"dev": true,
diff --git a/package.json b/package.json
index efbb81ace..a4d82792d 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
"author": "",
"license": "GPL-3.0-or-later",
"dependencies": {
+ "@freeipa/plugin-sdk": "file:../freeipa-webui-plugin-sdk",
"@patternfly/patternfly": "^6.4.0",
"@patternfly/react-core": "^6.4.0",
"@patternfly/react-icons": "^6.4.0",
diff --git a/src/main.tsx b/src/main.tsx
index 662f53d9c..b79615c8d 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,5 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
+import * as ReactRouter from "react-router";
+import * as ReduxToolkit from "@reduxjs/toolkit";
+import * as ReactRedux from "react-redux";
+import * as PatternFlyReactCore from "@patternfly/react-core";
+import * as PatternFlyReactIcons from "@patternfly/react-icons";
+import * as PatternFlyReactTable from "@patternfly/react-table";
import App from "./App";
import "./main.css";
// react router dom
@@ -15,17 +21,50 @@ import "@patternfly/patternfly/utilities/Display/display.css";
import "@patternfly/patternfly/utilities/Accessibility/accessibility.css";
// Navigation
import { URL_PREFIX } from "./navigation/NavRoutes";
+// Plugin infrastructure
+import {
+ exposeSharedDependencies,
+ loadPlugins,
+ pluginRegistry,
+} from "@freeipa/plugin-sdk";
+import { api } from "./services/rpc";
+import { injectPluginReducer } from "./store/store";
+
+exposeSharedDependencies({
+ React,
+ ReactDOM,
+ ReactRouter,
+ ReduxToolkit,
+ ReactRedux,
+ PatternFlyReactCore,
+ PatternFlyReactIcons,
+ PatternFlyReactTable,
+});
+
+pluginRegistry.setEndpointInjector((endpoints) => {
+ api.injectEndpoints({ endpoints });
+});
+pluginRegistry.setReducerInjector((key, reducer) => {
+ injectPluginReducer(key, reducer);
+});
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
-root.render(
-
-
-
-
-
-
-
-);
+async function bootstrap() {
+ await loadPlugins();
+ await pluginRegistry.runPhase("ready");
+
+ root.render(
+
+
+
+
+
+
+
+ );
+}
+
+bootstrap();
diff --git a/src/navigation/AppRoutes.tsx b/src/navigation/AppRoutes.tsx
index 3747a28fe..46d769fd1 100644
--- a/src/navigation/AppRoutes.tsx
+++ b/src/navigation/AppRoutes.tsx
@@ -76,11 +76,13 @@ import TrustsTabs from "src/pages/Trusts/TrustsTabs";
import IdRangesTabs from "src/pages/IdRanges/IdRangesTabs";
import GlobalTrustConfig from "src/pages/Trusts/GlobalTrustConfig";
import OtpTokens from "src/pages/OtpTokens/OtpTokens";
+import Dashboard from "src/pages/Dashboard/Dashboard";
+import { usePluginRoutes } from "@freeipa/plugin-sdk";
// Renders routes (React)
export const AppRoutes = ({ isInitialDataLoaded }): React.ReactElement => {
- // Redux: Get if user is logged in
const userLoggedIn = useAppSelector((state) => state.auth.isUserLoggedIn);
+ const pluginRoutes = usePluginRoutes();
const configurationSettings = useConfigurationSettings();
const dnsIsEnabled = configurationSettings.dnsIsEnabled;
@@ -546,11 +548,19 @@ export const AppRoutes = ({ isInitialDataLoaded }): React.ReactElement => {
} />