Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions dev-plugins/hello-world/plugin.js

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is duplicated from manifest.json, you can import json into .js file, but this defeats the purpose, the example needs to include vite (or any other build tool), which is where the "difficult" part comes into play.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove this example, as it's not realistic, it's too simple to really tell us anything.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions dev-plugins/manifest.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
2 changes: 2 additions & 0 deletions freeipa-webui-plugin-example/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
dist/
15 changes: 15 additions & 0 deletions freeipa-webui-plugin-example/package.json
Original file line number Diff line number Diff line change
@@ -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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we decide to use examples, it shouldn't really be GPL licensed, I would steer very far away from any plugins as a developer. It's an issue for the future, and will have to be discussed team-wide, but instead of just using GPL rather omit the license for now.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I'm not sure if I would add the license parameter here. I would make sense to infer that the license for a custom plugin would be the same as FreeIPA's, IMHO.

"scripts": {
"build": "vite build"
},
"devDependencies": {
"vite": "^6.3.5",
"typescript": "^5.8.3"
}
}
12 changes: 12 additions & 0 deletions freeipa-webui-plugin-example/src/index.ts
Original file line number Diff line number Diff line change
@@ -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;
14 changes: 14 additions & 0 deletions freeipa-webui-plugin-example/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
13 changes: 13 additions & 0 deletions freeipa-webui-plugin-example/vite.config.ts
Original file line number Diff line number Diff line change
@@ -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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minify should be true, that's the purpose.

},
});
2 changes: 2 additions & 0 deletions freeipa-webui-plugin-sdk/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
dist/
55 changes: 55 additions & 0 deletions freeipa-webui-plugin-sdk/package.json
Original file line number Diff line number Diff line change
@@ -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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, be vary of GPL, here it kinda makes sense, the sdk should be the same as main project.

"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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a build system instead of using only tsc, you're including vite, I see no vite config and no invoking the build tool.

"clean": "rm -rf dist"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not in a Makefile, we don't do clean

},
"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 }
},
Comment on lines +37 to +40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why're these optional?

"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"
}
}
26 changes: 26 additions & 0 deletions freeipa-webui-plugin-sdk/src/DynamicNav.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<NavItem key={`plugin-nav-${item.pluginId}-${item.path}`}>
<NavLink to={item.path}>{item.label}</NavLink>
</NavItem>
))}
</>
);
};
23 changes: 23 additions & 0 deletions freeipa-webui-plugin-sdk/src/DynamicRoutes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from "react";
import { Route } from "react-router";
import { usePluginRoutes } from "./hooks";

/**
* Renders <Route> elements for every route registered by plugins.
* Drop this inside the host's <Routes> tree.
*/
export const DynamicRoutes: React.FC = () => {
const routes = usePluginRoutes();

return (
<>
{routes.map((r) => (
<Route
key={`plugin-${r.pluginId}-${r.path}`}
path={r.path}
element={<r.component />}
/>
))}
</>
);
};
95 changes: 95 additions & 0 deletions freeipa-webui-plugin-sdk/src/ExtensionSlot.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
style={{
padding: "8px 12px",
margin: "4px 0",
background: "#fef3f2",
border: "1px solid #f04438",
borderRadius: 4,
fontSize: "0.85em",
color: "#b42318",
}}
>
Plugin &quot;{this.props.pluginId}&quot; encountered an error.
</div>
);
}
return this.props.children;
}
}

// ---------------------------------------------------------------------------
// ExtensionSlot component
// ---------------------------------------------------------------------------

export interface ExtensionSlotProps {
extensionPointId: string;
/** Arbitrary context passed as props to every plugin component. */
context?: Record<string, unknown>;
}

export const ExtensionSlot: React.FC<ExtensionSlotProps> = ({
extensionPointId,
context = {},
}) => {
const extensions = usePluginExtensions(extensionPointId);

if (extensions.length === 0) {
return null;
}

return (
<>
{extensions.map((ext, idx) => {
const Component = ext.component;
return (
<PluginErrorBoundary
key={`${ext.pluginId}-${idx}`}
pluginId={ext.pluginId}
>
<Component {...context} />
</PluginErrorBoundary>
);
})}
</>
);
};
74 changes: 74 additions & 0 deletions freeipa-webui-plugin-sdk/src/PluginLoader.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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.`
);
Comment on lines +45 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The failure counting logic in loadPlugins never reports failures because errors are caught inside the mapped promises.

Because the enabledPlugins.map callback catches and swallows all errors, each async function always resolves, so Promise.allSettled never sees any rejections. Consequently, results.filter((r) => r.status === "rejected") is always empty and failed is always 0.

To fix this, either let the error escape the callback so the promise rejects, or keep the try/catch but update a separate failure counter when a plugin fails instead of relying on status === 'rejected'.

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");
}
Loading
Loading