diff --git a/dev-plugins/hello-world/plugin.js b/dev-plugins/hello-world/plugin.js new file mode 100644 index 000000000..b61d572a5 --- /dev/null +++ b/dev-plugins/hello-world/plugin.js @@ -0,0 +1,13 @@ +const helloWorldPlugin = { + id: "hello-world", + name: "Hello World Plugin", + version: "1.0.0", + description: "A minimal example plugin that logs to the console.", + register() { + console.log("Hello World from a dynamically loaded plugin!"); + } +}; +export { + helloWorldPlugin as default +}; +//# sourceMappingURL=plugin.js.map diff --git a/dev-plugins/manifest.json b/dev-plugins/manifest.json new file mode 100644 index 000000000..304cc5f5d --- /dev/null +++ b/dev-plugins/manifest.json @@ -0,0 +1,12 @@ +{ + "apiVersion": "1", + "plugins": [ + { + "id": "hello-world", + "name": "Hello World Plugin", + "version": "1.0.0", + "entrypoint": "/ipa/modern-ui/plugins/hello-world/plugin.js", + "enabled": true + } + ] +} diff --git a/freeipa-webui-plugin-example/.gitignore b/freeipa-webui-plugin-example/.gitignore new file mode 100644 index 000000000..b94707787 --- /dev/null +++ b/freeipa-webui-plugin-example/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/freeipa-webui-plugin-example/package.json b/freeipa-webui-plugin-example/package.json new file mode 100644 index 000000000..24882fc11 --- /dev/null +++ b/freeipa-webui-plugin-example/package.json @@ -0,0 +1,15 @@ +{ + "name": "freeipa-webui-plugin-example", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Example plugin for FreeIPA Modern WebUI - demonstrates dynamic plugin loading.", + "license": "GPL-3.0-or-later", + "scripts": { + "build": "vite build" + }, + "devDependencies": { + "vite": "^6.3.5", + "typescript": "^5.8.3" + } +} diff --git a/freeipa-webui-plugin-example/src/index.ts b/freeipa-webui-plugin-example/src/index.ts new file mode 100644 index 000000000..060552d8e --- /dev/null +++ b/freeipa-webui-plugin-example/src/index.ts @@ -0,0 +1,12 @@ +const helloWorldPlugin = { + id: "hello-world", + name: "Hello World Plugin", + version: "1.0.0", + description: "A minimal example plugin that logs to the console.", + + register() { + console.log("Hello World from a dynamically loaded plugin!"); + }, +}; + +export default helloWorldPlugin; diff --git a/freeipa-webui-plugin-example/tsconfig.json b/freeipa-webui-plugin-example/tsconfig.json new file mode 100644 index 000000000..d650efee0 --- /dev/null +++ b/freeipa-webui-plugin-example/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "isolatedModules": true + }, + "include": ["src"] +} diff --git a/freeipa-webui-plugin-example/vite.config.ts b/freeipa-webui-plugin-example/vite.config.ts new file mode 100644 index 000000000..db873b88c --- /dev/null +++ b/freeipa-webui-plugin-example/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + build: { + lib: { + entry: "src/index.ts", + formats: ["es"], + fileName: () => "plugin.js", + }, + sourcemap: true, + minify: false, + }, +}); diff --git a/freeipa-webui-plugin-sdk/.gitignore b/freeipa-webui-plugin-sdk/.gitignore new file mode 100644 index 000000000..b94707787 --- /dev/null +++ b/freeipa-webui-plugin-sdk/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/freeipa-webui-plugin-sdk/package.json b/freeipa-webui-plugin-sdk/package.json new file mode 100644 index 000000000..ae1b117ca --- /dev/null +++ b/freeipa-webui-plugin-sdk/package.json @@ -0,0 +1,55 @@ +{ + "name": "@freeipa/plugin-sdk", + "version": "0.1.0", + "description": "Plugin SDK for FreeIPA Modern WebUI – types, registry, loader, and build utilities for plugin authors.", + "license": "GPL-3.0-or-later", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./pluginViteConfig": { + "import": "./dist/pluginViteConfig.js", + "types": "./dist/pluginViteConfig.d.ts" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsc -b", + "clean": "rm -rf dist" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0", + "react-router": "^7.0.0", + "@reduxjs/toolkit": "^2.0.0", + "react-redux": "^9.0.0", + "@patternfly/react-core": "^6.0.0", + "@patternfly/react-icons": "^6.0.0", + "@patternfly/react-table": "^6.0.0" + }, + "peerDependenciesMeta": { + "@patternfly/react-icons": { "optional": true }, + "@patternfly/react-table": { "optional": true } + }, + "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" + } +} diff --git a/freeipa-webui-plugin-sdk/src/DynamicNav.tsx b/freeipa-webui-plugin-sdk/src/DynamicNav.tsx new file mode 100644 index 000000000..f88e85b6f --- /dev/null +++ b/freeipa-webui-plugin-sdk/src/DynamicNav.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { NavItem } from "@patternfly/react-core"; +import { NavLink } from "react-router"; +import { usePluginNavItems } from "./hooks"; + +/** + * Renders PatternFly NavItem elements for every nav entry registered by + * plugins. Drop this at the end of the host's sidebar Nav component. + */ +export const DynamicNav: React.FC = () => { + const navItems = usePluginNavItems(); + + if (navItems.length === 0) { + return null; + } + + return ( + <> + {navItems.map((item) => ( + + {item.label} + + ))} + + ); +}; diff --git a/freeipa-webui-plugin-sdk/src/DynamicRoutes.tsx b/freeipa-webui-plugin-sdk/src/DynamicRoutes.tsx new file mode 100644 index 000000000..e301ee1ef --- /dev/null +++ b/freeipa-webui-plugin-sdk/src/DynamicRoutes.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { Route } from "react-router"; +import { usePluginRoutes } from "./hooks"; + +/** + * Renders elements for every route registered by plugins. + * Drop this inside the host's 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 => { } /> } /> - {/* Redirect to Active users page if user is logged in and navigates to the root page */} + } /> + {/* Plugin-registered routes */} + {pluginRoutes.map((r) => ( + } + /> + ))} } /> } + element={} /> {/* 404 page */} } /> diff --git a/src/navigation/Nav.tsx b/src/navigation/Nav.tsx index dba409c49..d4f2d81d9 100644 --- a/src/navigation/Nav.tsx +++ b/src/navigation/Nav.tsx @@ -13,6 +13,7 @@ import { updateBrowserTitle, } from "src/store/Global/routes-slice"; import { useConfigurationSettings } from "src/utils/configurationSettings"; +import { usePluginNavItems } from "@freeipa/plugin-sdk"; // Renders NavItem const renderNavItem = ( @@ -43,7 +44,6 @@ const renderNavItem = ( // Renders 'Navigation' const Navigation = () => { - // The first level will determine if the section is expanded and highligted const activeFirstLevel = useAppSelector( (state) => state.routes.activeFirstLevel ); @@ -52,6 +52,7 @@ const Navigation = () => { const activePageName = useAppSelector((state) => state.routes.activePageName); const configurationSettings = useConfigurationSettings(); + const pluginNavItems = usePluginNavItems(); const navigationRoutes = React.useMemo(() => { return getNavigationRoutes(configurationSettings); @@ -109,6 +110,41 @@ const Navigation = () => { ); })} + {pluginNavItems.length > 0 && ( + item.path === activePageName + )} + isExpanded={pluginNavItems.some( + (item) => item.path === activeFirstLevel + )} + > + {pluginNavItems.map((item) => ( + { + dispatch(updateActiveSecondLevel(item.path)); + dispatch(updateActivePageName(item.path)); + dispatch( + updateBrowserTitle( + item.title || `Identity Management - ${item.label}` + ) + ); + dispatch(updateActiveFirstLevel(item.path)); + dispatch( + updateBreadCrumbPath([ + { name: item.label, url: item.path }, + ]) + ); + }} + > + {item.label} + + ))} + + )} ); diff --git a/src/pages/Dashboard/Dashboard.tsx b/src/pages/Dashboard/Dashboard.tsx new file mode 100644 index 000000000..c732f5235 --- /dev/null +++ b/src/pages/Dashboard/Dashboard.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { + PageSection, + Content, + ContentVariants, + Gallery, + GalleryItem, + Card, + CardTitle, + CardBody, + CardHeader, + Icon, +} from "@patternfly/react-core"; +import { ServerIcon } from "@patternfly/react-icons"; +import { ExtensionSlot, EXTENSION_POINTS } from "@freeipa/plugin-sdk"; + +const Dashboard: React.FC = () => { + return ( + + Dashboard + + + + + + + + {" "} + System Status + + + + FreeIPA server is running. Use the navigation to manage identity, + policy, and authentication. + + + + + + + ); +}; + +export default Dashboard; diff --git a/src/store/store.ts b/src/store/store.ts index 29910b7cf..6e575a95e 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,4 +1,5 @@ -import { configureStore } from "@reduxjs/toolkit"; +import { configureStore, combineReducers } from "@reduxjs/toolkit"; +import type { Reducer } from "@reduxjs/toolkit"; import { setupListeners } from "@reduxjs/toolkit/query"; import globalReducer from "./Global/global-slice"; import { api } from "../services/rpc"; @@ -6,30 +7,47 @@ import routesReducer from "./Global/routes-slice"; import authReducer from "./Global/auth-slice"; import alertsReducer from "./Global/alerts-slice"; +const staticReducers = { + api: api.reducer, + global: globalReducer, + routes: routesReducer, + auth: authReducer, + alerts: alertsReducer, +}; + +const pluginReducers: Record = {}; + +function createRootReducer() { + return combineReducers({ + ...staticReducers, + ...pluginReducers, + }); +} + export const setupStore = () => { const store = configureStore({ - reducer: { - api: api.reducer, - global: globalReducer, - routes: routesReducer, - auth: authReducer, - alerts: alertsReducer, - }, - // Adding the api middleware enables caching, invalidation, polling, - // and other useful features of `rtk-query`. + reducer: createRootReducer(), middleware: (getDefaultMiddleware) => getDefaultMiddleware({ - serializableCheck: false, // Removes the warning about non-serializable data + serializableCheck: false, }).concat(api.middleware), }); - // optional, but required for refetchOnFocus/refetchOnReconnect behaviors - // see `setupListeners` docs - takes an optional callback as the 2nd arg for customization setupListeners(store.dispatch); return store; }; const store = setupStore(); + +/** Called by the plugin registry to inject a new reducer at runtime. */ +export function injectPluginReducer(key: string, reducer: Reducer): void { + if (pluginReducers[key]) { + return; + } + pluginReducers[key] = reducer; + store.replaceReducer(createRootReducer()); +} + export type RootState = ReturnType; export type AppDispatch = typeof store.dispatch; export default store; diff --git a/vite.config.ts b/vite.config.ts index a9d9b1ec8..dcf86c8ce 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from "vitest/config"; import license from "rollup-plugin-license"; import path from "path"; +import fs from "fs"; const getReactPlugin = async (isDev: boolean) => { if (isDev) { @@ -9,6 +10,45 @@ const getReactPlugin = async (isDev: boolean) => { return (await import("@vitejs/plugin-react")).default(); }; +/** + * Vite plugin that serves plugin files from dev-plugins/ during development. + * In production, plugins are served by the IPA server (Apache/httpd). + */ +function devPluginsServer() { + const PLUGINS_PREFIX = "/ipa/modern-ui/plugins/"; + const PLUGINS_DIR = path.join(__dirname, "dev-plugins"); + + return { + name: "dev-plugins-server", + configureServer(server: any) { + server.middlewares.use((req: any, res: any, next: any) => { + const urlPath = (req.url || "").split("?")[0]; + if (!urlPath.startsWith(PLUGINS_PREFIX)) { + return next(); + } + + const relative = urlPath.slice(PLUGINS_PREFIX.length); + const filePath = path.join(PLUGINS_DIR, relative); + + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + return next(); + } + + const ext = path.extname(filePath); + const mimeTypes: Record = { + ".js": "application/javascript", + ".json": "application/json", + ".map": "application/json", + }; + + res.setHeader("Content-Type", mimeTypes[ext] || "application/octet-stream"); + res.setHeader("Access-Control-Allow-Origin", "*"); + fs.createReadStream(filePath).pipe(res); + }); + }, + }; +} + // https://vite.dev/config/ export default defineConfig(async ({ mode }) => { const isDev = mode === "development"; @@ -27,6 +67,7 @@ export default defineConfig(async ({ mode }) => { includePrivate: true, // Default is false. }, }), + isDev && devPluginsServer(), ], resolve: { alias: {