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