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: {