Repository: freeipa/freeipa-webui
Pull request #694: feat: Plugin Infrastructure - static approach: Monolithic build
Author: login: b3lix
Labels: name: ack
description: Pull Request approved, it can be merged
Created: 2025-04-11T11:48:21Z | Merged: 2025-05-20T19:34:52Z



Diff:
diff --git a/src/components/UsersSections/UsersAccountSettings.tsx b/src/components/UsersSections/UsersAccountSettings.tsx
index 21fc9422f..660cad2e7 100644
--- a/src/components/UsersSections/UsersAccountSettings.tsx
+++ b/src/components/UsersSections/UsersAccountSettings.tsx
@@ -31,6 +31,8 @@ import IpaCalendar from "../Form/IpaCalendar";
 import IpaSshPublicKeys from "../Form/IpaSshPublicKeys";
 import IpaCertificates from "../Form/IpaCertificates";
 import IpaCertificateMappingData from "../Form/IpaCertificateMappingData";
+import { ExtensionSlot } from "src/core/plugins/ExtensionSlot";
+import { userEditForm } from "src/core/plugins/extensionPoints";
 
 interface PropsToUsersAccountSettings {
   user: Partial<User>;
@@ -218,6 +220,16 @@ const UsersAccountSettings = (props: PropsToUsersAccountSettings) => {
                 metadata={props.metadata}
               />
             </FormGroup>
+
+            {/* Extension point for user edit form from plugins */}
+            <ExtensionSlot
+              extensionPointId={userEditForm}
+              context={{
+                user: props.user,
+                onChange: recordOnChange,
+                isReadOnly: false,
+              }}
+            />
           </Form>
         </FlexItem>
         <FlexItem flex={{ default: "flex_1" }} className="pf-v5-u-w-50">
diff --git a/src/core/plugins/ExtensionSlot.tsx b/src/core/plugins/ExtensionSlot.tsx
new file mode 100644
index 000000000..f32961bf8
--- /dev/null
+++ b/src/core/plugins/ExtensionSlot.tsx
@@ -0,0 +1,42 @@
+import React from "react";
+import { ExtensionPointId } from "./types";
+import { pluginRegistry } from "./PluginRegistry";
+
+interface ExtensionSlotProps {
+  /**
+   * The ID of the extension point to render extensions for.
+   */
+  extensionPointId: ExtensionPointId;
+
+  /**
+   * Optional context data to pass to the extension components
+   */
+  context?: Record<string, unknown>;
+}
+
+/**
+ * Component that renders all registered extensions for a specific extension point
+ */
+export const ExtensionSlot = (props: ExtensionSlotProps) => {
+  const { extensionPointId, context = {} } = props;
+
+  const extensions = pluginRegistry.getExtensions(extensionPointId);
+
+  if (extensions.length === 0) {
+    console.debug(
+      `No extensions found for extension point: ${extensionPointId.id}`
+    );
+    return null;
+  }
+
+  return (
+    <>
+      {extensions.map((extension, index) => {
+        const { component: Component } = extension;
+        return (
+          <Component key={`${extensionPointId.id}-${index}`} {...context} />
+        );
+      })}
+    </>
+  );
+};
diff --git a/src/core/plugins/PluginRegistry.ts b/src/core/plugins/PluginRegistry.ts
new file mode 100644
index 000000000..c7c2cf1a9
--- /dev/null
+++ b/src/core/plugins/PluginRegistry.ts
@@ -0,0 +1,121 @@
+import { PluginModule, ExtensionPointId, ExtensionComponent } from "./types";
+
+/**
+ * Creates a plugin registry that manages all plugins and their extensions
+ */
+const createPluginRegistry = () => {
+  // private state
+  const plugins: Map<string, PluginModule> = new Map();
+  const extensions: Map<string, ExtensionComponent[]> = new Map();
+
+  /**
+   * Helper method to get a consistent string ID from an extension point
+   */
+  const getExtensionPointIdString = (
+    extensionPointId: ExtensionPointId
+  ): string => {
+    return extensionPointId.id;
+  };
+
+  /**
+   * Register a single extension
+   */
+  const registerExtension = (extension: ExtensionComponent): void => {
+    const { extensionPointId } = extension;
+
+    // get the extension point ID string
+    const extensionPointIdString = getExtensionPointIdString(extensionPointId);
+
+    if (!extensions.has(extensionPointIdString)) {
+      extensions.set(extensionPointIdString, []);
+    }
+
+    const currentExtensions = extensions.get(extensionPointIdString)!;
+    currentExtensions.push(extension);
+
+    // sort by priority if provided (higher priority first)
+    currentExtensions.sort((a, b) => (b.priority || 0) - (a.priority || 0));
+  };
+
+  return {
+    /**
+     * Register a plugin and all its extensions
+     */
+    registerPlugin: (plugin: PluginModule): void => {
+      if (plugins.has(plugin.id)) {
+        console.warn(`Plugin with ID "${plugin.id}" is already registered.`);
+        return;
+      }
+
+      // register the plugin
+      plugins.set(plugin.id, plugin);
+
+      // register all extensions from this plugin
+      for (const extension of plugin.extensions) {
+        registerExtension(extension);
+      }
+
+      // initialize plugin if needed
+      if (plugin.initialize) {
+        try {
+          plugin.initialize();
+        } catch (error) {
+          console.error(`Error initializing plugin "${plugin.id}":`, error);
+        }
+      }
+    },
+
+    /**
+     * Get all extensions for a specific extension point
+     */
+    getExtensions: (
+      extensionPointId: ExtensionPointId
+    ): ExtensionComponent[] => {
+      const extensionPointIdString =
+        getExtensionPointIdString(extensionPointId);
+      return extensions.get(extensionPointIdString) || [];
+    },
+
+    /**
+     * Get all registered plugins
+     */
+    getPlugins: (): PluginModule[] => {
+      return Array.from(plugins.values());
+    },
+
+    /**
+     * Get a specific plugin by ID
+     */
+    getPluginById: (pluginId: string): PluginModule | undefined => {
+      return plugins.get(pluginId);
+    },
+
+    /**
+     * Check if a plugin is registered
+     */
+    hasPlugin: (pluginId: string): boolean => {
+      return plugins.has(pluginId);
+    },
+
+    /**
+     * Cleanup all registered plugins
+     */
+    cleanup: (): void => {
+      for (const plugin of plugins.values()) {
+        if (plugin.cleanup) {
+          try {
+            plugin.cleanup();
+          } catch (error) {
+            console.error(`Error cleaning up plugin "${plugin.id}":`, error);
+          }
+        }
+      }
+
+      plugins.clear();
+      extensions.clear();
+    },
+  };
+};
+
+// create a singleton instance
+export const pluginRegistry = createPluginRegistry();
diff --git a/src/core/plugins/extensionPoints.ts b/src/core/plugins/extensionPoints.ts
new file mode 100644
index 000000000..9daa5375e
--- /dev/null
+++ b/src/core/plugins/extensionPoints.ts
@@ -0,0 +1,27 @@
+import {
+  dashboardContent,
+  userEditForm,
+  hostGroupsTableColumns,
+  loginCustomization,
+} from "./types";
+
+/**
+ * All available extension points in the application
+ */
+// reexporting from types.ts for backward compatibility
+export {
+  dashboardContent,
+  userEditForm,
+  hostGroupsTableColumns,
+  loginCustomization,
+};
+
+/**
+ * Map of all extension points by ID
+ */
+export const extensionPointsMap = {
+  dashboardContent,
+  userEditForm,
+  hostGroupsTableColumns,
+  loginCustomization,
+};
diff --git a/src/core/plugins/types.ts b/src/core/plugins/types.ts
new file mode 100644
index 000000000..7451072a5
--- /dev/null
+++ b/src/core/plugins/types.ts
@@ -0,0 +1,83 @@
+import { Reducer } from "@reduxjs/toolkit";
+import React from "react";
+
+/**
+ * Represents an extension point in the application where plugins can inject content
+ */
+export interface ExtensionPoint {
+  id: string;
+  displayName?: string;
+  description?: string;
+}
+
+/**
+ * All available extension points in the application
+ */
+export const dashboardContent: ExtensionPoint = {
+  id: "dashboardContent",
+  displayName: "Dashboard Content",
+  description: "Add content to the main dashboard",
+};
+
+export const userEditForm: ExtensionPoint = {
+  id: "userEditForm",
+  displayName: "User Edit Form",
+  description: "Add fields to the user edit form",
+};
+
+export const hostGroupsTableColumns: ExtensionPoint = {
+  id: "hostGroupsTableColumns",
+  displayName: "Host Groups Table Columns",
+  description: "Add columns to the Host Groups table",
+};
+
+export const loginCustomization: ExtensionPoint = {
+  id: "loginCustomization",
+  displayName: "Login Customization",
+  description: "Customize the login page appearance",
+};
+
+/**
+ * Type for extension point IDs
+ */
+export type ExtensionPointId =
+  | typeof dashboardContent
+  | typeof userEditForm
+  | typeof hostGroupsTableColumns
+  | typeof loginCustomization;
+
+/**
+ * Base plugin interface with metadata
+ */
+export interface Plugin {
+  id: string;
+  name: string;
+  version: string;
+  description: string;
+  author?: string;
+}
+
+/**
+ * Represents a component that extends a specific extension point
+ */
+export interface ExtensionComponent {
+  extensionPointId: ExtensionPointId;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  component: (...args: any[]) => React.JSX.Element | null;
+  priority?: number; // higher priority will be rendered first
+  metadata?: Record<string, unknown>; // additional metadata if needed
+}
+
+/**
+ * Complete plugin module interface
+ */
+export interface PluginModule extends Plugin {
+  extensions: ExtensionComponent[];
+
+  // optional lifecycle hooks
+  initialize?: () => void;
+  cleanup?: () => void;
+
+  // optional reducers for Redux integration
+  reducers?: Record<string, Reducer>;
+}
diff --git a/src/login/LoginMainPage.tsx b/src/login/LoginMainPage.tsx
index 4c8147e67..333fa8471 100644
--- a/src/login/LoginMainPage.tsx
+++ b/src/login/LoginMainPage.tsx
@@ -18,8 +18,11 @@ import useAlerts from "src/hooks/useAlerts";
 // Icons
 import { ExclamationCircleIcon } from "@patternfly/react-icons";
 // Images
-import BrandImg from "src/assets/images/product-name.png";
+import FreeIPABrandImg from "src/assets/images/product-name.png";
 import BackgroundImg from "src/assets/images/login-screen-background.jpg";
+// Plugin system
+import { ExtensionSlot } from "src/core/plugins/ExtensionSlot";
+import { loginCustomization } from "src/core/plugins/extensionPoints";
 // RPC
 import {
   MetaResponse,
@@ -46,6 +49,14 @@ const LoginMainPage = () => {
   const navigate = useNavigate();
   const location = useLocation();
 
+  const [logo, setLogo] = React.useState(FreeIPABrandImg);
+  const [logoAlt, setLogoAlt] = React.useState("FreeIPA logo");
+  const [loginPageText, setloginPageText] = React.useState(
+    "FreeIPA provides centralized authentication, authorization and account " +
+      "information by storing data about user, groups, hosts and other objects " +
+      "necessary to manage the security aspects of a network of computers."
+  );
+
   // Alerts to show in the UI
   const alerts = useAlerts();
 
@@ -343,22 +354,27 @@ const LoginMainPage = () => {
     />
   );
 
-  const placeHolderText =
-    "· To log in with username and password, enter them in the corresponding fields, then click 'Log in'. \n\n" +
-    "· To log in with Kerberos, please make sure you have valid tickets (obtainable via kinit) and configured the browser correctly, then click 'Log in'. \n\n" +
-    "· To log in with certificate, please make sure you have valid personal certificate.";
-
   return (
     <>
+      {/* Extension slot for login customization */}
+      <ExtensionSlot
+        extensionPointId={loginCustomization}
+        context={{
+          setLogo,
+          setLogoAlt,
+          setloginPageText,
+        }}
+      />
+
       <alerts.ManagedAlerts />
       <LoginPage
         style={{ whiteSpace: "pre-line" }}
         footerListVariants={ListVariant.inline}
-        brandImgSrc={BrandImg}
-        brandImgAlt="FreeIPA logo"
+        brandImgSrc={logo}
+        brandImgAlt={logoAlt}
         backgroundImgSrc={BackgroundImg}
         footerListItems={listItem}
-        textContent={placeHolderText}
+        textContent={loginPageText}
         loginTitle="Log in to your account"
         loginSubtitle="Enter your credentials."
         socialMediaLoginContent={socialMediaLoginContent}
diff --git a/src/main.tsx b/src/main.tsx
index 0484be257..541092c0a 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,4 +1,4 @@
-import React from "react";
+import React, { useEffect } from "react";
 import ReactDOM from "react-dom/client";
 import App from "./App";
 import "./main.css";
@@ -15,6 +15,17 @@ import "@patternfly/patternfly/utilities/Display/display.css";
 import "@patternfly/patternfly/utilities/Accessibility/accessibility.css";
 // Navigation
 import { URL_PREFIX } from "./navigation/NavRoutes";
+// Plugins
+import { registerAllPlugins } from "./plugins";
+
+// wrapper component to register plugins after rendering
+function AppWithPlugins() {
+  useEffect(() => {
+    registerAllPlugins();
+  }, []);
+
+  return <App />;
+}
 
 const root = ReactDOM.createRoot(
   document.getElementById("root") as HTMLElement
@@ -24,7 +35,7 @@ root.render(
   <Provider store={store}>
     <React.StrictMode>
       <BrowserRouter basename={URL_PREFIX}>
-        <App />
+        <AppWithPlugins />
       </BrowserRouter>
     </React.StrictMode>
   </Provider>
diff --git a/src/navigation/AppRoutes.tsx b/src/navigation/AppRoutes.tsx
index 9637aecbd..d19d07d42 100644
--- a/src/navigation/AppRoutes.tsx
+++ b/src/navigation/AppRoutes.tsx
@@ -9,6 +9,7 @@ import DataSpinner from "src/components/layouts/DataSpinner";
 import { useAppSelector } from "src/store/hooks";
 
 // PAGE COMPONENTS
+import Dashboard from "src/pages/Dashboard/Dashboard";
 import ActiveUsers from "src/pages/ActiveUsers/ActiveUsers";
 import ActiveUsersTabs from "src/pages/ActiveUsers/ActiveUsersTabs";
 import StageUsers from "src/pages/StageUsers/StageUsers";
@@ -75,6 +76,8 @@ export const AppRoutes = ({ isInitialDataLoaded }): React.ReactElement => {
         <Routes>
           {userLoggedIn ? (
             <>
+              {/* Dashboard Route */}
+              <Route path="" element={<Dashboard />} />
               <Route path="active-users">
                 <Route path="" element={<ActiveUsers />} />
                 <Route path=":uid">
diff --git a/src/pages/Dashboard/Dashboard.tsx b/src/pages/Dashboard/Dashboard.tsx
new file mode 100644
index 000000000..4a1409d50
--- /dev/null
+++ b/src/pages/Dashboard/Dashboard.tsx
@@ -0,0 +1,47 @@
+import React from "react";
+import {
+  Page,
+  PageSection,
+  Title,
+  Grid,
+  GridItem,
+} from "@patternfly/react-core";
+import { ExtensionSlot } from "src/core/plugins/ExtensionSlot";
+import { dashboardContent } from "src/core/plugins/extensionPoints";
+
+/**
+ * Dashboard page component that displays the main dashboard
+ * and includes the dashboardContent extension point
+ */
+const Dashboard = () => {
+  return (
+    <Page>
+      <PageSection variant="light">
+        <Title headingLevel="h1" size="2xl">
+          Dashboard
+        </Title>
+      </PageSection>
+
+      <PageSection>
+        <Grid hasGutter>
+          <GridItem span={12}>
+            {/* Core dashboard content */}
+            <div className="pf-v5-c-card">
+              <div className="pf-v5-c-card__title">System Status</div>
+              <div className="pf-v5-c-card__body">
+                <p>FreeIPA system is up and running.</p>
+              </div>
+            </div>
+          </GridItem>
+
+          {/* Extension point for dashboard content from plugins */}
+          <GridItem span={12}>
+            <ExtensionSlot extensionPointId={dashboardContent} />
+          </GridItem>
+        </Grid>
+      </PageSection>
+    </Page>
+  );
+};
+
+export default Dashboard;
diff --git a/src/pages/HostGroups/HostGroupsTable.tsx b/src/pages/HostGroups/HostGroupsTable.tsx
index 415748492..9112e22e7 100644
--- a/src/pages/HostGroups/HostGroupsTable.tsx
+++ b/src/pages/HostGroups/HostGroupsTable.tsx
@@ -9,6 +9,9 @@ import { HostGroup } from "../../utils/datatypes/globalDataTypes";
 import SkeletonOnTableLayout from "../../components/layouts/Skeleton/SkeletonOnTableLayout";
 // React Router DOM
 import { Link } from "react-router-dom";
+// Plugin support
+import { ExtensionSlot } from "../../core/plugins/ExtensionSlot";
+import { hostGroupsTableColumns } from "../../core/plugins/extensionPoints";
 
 interface GroupsData {
   isHostGroupSelectable: (group: HostGroup) => boolean;
@@ -158,6 +161,11 @@ const HostGroupsTable = (props: PropsToTable) => {
       <Th modifier="wrap"></Th>
       <Th modifier="wrap">{columnNames.cn}</Th>
       <Th modifier="wrap">{columnNames.description}</Th>
+      {/* Extension point for additional columns */}
+      <ExtensionSlot
+        extensionPointId={hostGroupsTableColumns}
+        context={{ isHeader: true }}
+      />
     </Tr>
   );
 
@@ -179,6 +187,11 @@ const HostGroupsTable = (props: PropsToTable) => {
         </Link>
       </Td>
       <Td dataLabel={columnNames.description}>{group.description}</Td>
+      {/* Extension point for additional columns */}
+      <ExtensionSlot
+        extensionPointId={hostGroupsTableColumns}
+        context={{ hostGroup: group, rowIndex }}
+      />
     </Tr>
   ));
 
diff --git a/src/plugins/README.md b/src/plugins/README.md
new file mode 100644
index 000000000..16fd2be4c
--- /dev/null
+++ b/src/plugins/README.md
@@ -0,0 +1,406 @@
+# FreeIPA Modern WebUI: Plugin Developer Guide
+
+This guide explains how to create and implement plugins for the FreeIPA Modern WebUI. The plugin system allows extending the WebUI functionality without modifying the core application.
+
+## Table of Contents
+
+1. [Plugin System Overview](#plugin-system-overview)
+2. [Plugin Structure](#plugin-structure)
+3. [Step-by-Step Guide](#step-by-step-guide)
+4. [Extension Points](#extension-points)
+5. [Real-World Examples](#real-world-examples)
+6. [Best Practices](#best-practices)
+7. [Troubleshooting](#troubleshooting)
+
+## Plugin System Overview
+
+The FreeIPA Modern WebUI implements a plugin architecture that allows developers to:
+
+- Add new content to various parts of the UI
+- Customize existing functionality
+- Add new features without modifying core code
+
+Plugins are registered at application startup and can provide extensions to predefined extension points. Each plugin consists of React components, metadata, and optional lifecycle hooks.
+
+## Plugin Structure
+
+A plugin consists of the following key elements:
+
+### Plugin Module
+
+Each plugin must export a `PluginModule` object with these properties:
+
+- **id**: Unique identifier for the plugin
+- **name**: Display name of the plugin
+- **version**: Plugin version
+- **description**: Brief description of what the plugin does
+- **author** (optional): Plugin author information
+- **extensions**: Array of extension components
+- **initialize** (optional): Function called when the plugin is registered
+- **cleanup** (optional): Function called when the application is unloaded
+
+### Extension Component
+
+Each extension connects a React component to a specific extension point:
+
+- **extensionPointId**: Reference to the extension point
+- **component**: React component to render
+- **priority** (optional): Determines rendering order
+- **metadata** (optional): Additional configuration data
+
+## Step-by-Step Guide
+
+### 1. Create Plugin Directory
+
+Create a new directory for your plugin in the `src/plugins` folder:
+
+```
+src/plugins/my-custom-plugin/
+```
+
+### 2. Create Plugin Components
+
+Create your React components in a `components` subdirectory:
+
+```
+src/plugins/my-custom-plugin/components/MyCustomComponent.tsx
+```
+
+```typescript
+// Example component
+import { useEffect, useState } from "react";
+
+interface MyComponentProps {
+  // Define props based on extension point requirements
+  someProperty?: string;
+}
+
+const MyCustomComponent = (props: MyComponentProps) => {
+  const { someProperty } = props;
+
+  // Your component implementation
+  return (
+    <div className="my-custom-component">
+      <h3>My Custom Plugin</h3>
+      <p>This is a custom extension component.</p>
+      {someProperty && <p>Property: {someProperty}</p>}
+    </div>
+  );
+};
+
+export default MyCustomComponent;
+```
+
+### 3. Define Plugin Module
+
+Create an `index.ts` file in your plugin directory:
+
+```
+src/plugins/my-custom-plugin/index.ts
+```
+
+```typescript
+import { PluginModule } from "src/core/plugins/types";
+import { dashboardContent } from "src/core/plugins/types";
+import MyCustomComponent from "./components/MyCustomComponent";
+
+// Define your plugin
+const myCustomPlugin: PluginModule = {
+  id: "my-custom-plugin",
+  name: "My Custom Plugin",
+  version: "1.0.0",
+  description: "A custom plugin for FreeIPA WebUI",
+  author: "Your Name",
+
+  // Define extensions
+  extensions: [
+    {
+      extensionPointId: dashboardContent,
+      component: () => <MyCustomComponent />,
+      priority: 100,
+    },
+  ],
+
+  // Optional lifecycle hooks
+  initialize: () => {
+    console.info("My Custom Plugin initialized");
+    // Perform setup tasks
+  },
+
+  cleanup: () => {
+    console.info("My Custom Plugin cleanup");
+    // Perform cleanup tasks
+  },
+};
+
+export default myCustomPlugin;
+```
+
+### 4. Register Your Plugin
+
+Update the main plugins index file at `src/plugins/index.ts`:
+
+```typescript
+import { pluginRegistry } from "src/core/plugins";
+
+// Import all plugins
+import helloWorldPlugin from "./hello-world";
+import { userStatusPlugin } from "./user-status";
+import { hostGroupDNColumnPlugin } from "./hostgroup-dn-column";
+import myCustomPlugin from "./my-custom-plugin"; // Add your plugin import
+
+/**
+ * Register all plugins with the plugin registry
+ */
+export function registerAllPlugins(): void {
+  // Add all plugins to be registered here
+  const plugins = [
+    helloWorldPlugin,
+    userStatusPlugin,
+    hostGroupDNColumnPlugin,
+    myCustomPlugin, // Add your plugin to the array
+  ];
+
+  // Register each plugin
+  plugins.forEach((plugin) => {
+    try {
+      pluginRegistry.registerPlugin(plugin);
+    } catch (error) {
+      console.error(`Failed to register plugin ${plugin.id}:`, error);
+    }
+  });
+
+  console.info("Plugin registration complete");
+}
+```
+
+## Extension Points
+
+The application defines several extension points where plugins can add content:
+
+### Available Extension Points
+
+- **dashboardContent**: Add content to the main dashboard
+- **userEditForm**: Add fields to the user edit form
+- **hostGroupsTableColumns**: Add columns to the Host Groups table
+- **loginCustomization**: Customize the login page appearance
+
+To use an extension point, reference it in your plugin's extensions array:
+
+```typescript
+import { dashboardContent } from "src/core/plugins/types";
+
+extensions: [
+  {
+    extensionPointId: dashboardContent,
+    component: () => <MyComponent />,
+  },
+],
+```
+
+### Creating Custom Extension Points
+
+You are not limited to the predefined extension points. You can create your own extension points to make your application even more extensible.
+
+#### 1. Define a Custom Extension Point
+
+Add your extension point in a file where it makes sense for your feature:
+
+```typescript
+// src/features/my-feature/extensionPoints.ts
+import { ExtensionPoint } from "src/core/plugins/types";
+
+export const myFeatureSettings: ExtensionPoint = {
+  id: "myFeatureSettings",
+  displayName: "My Feature Settings",
+  description: "Add custom settings to My Feature",
+};
+```
+
+#### 2. Add it to the types
+
+To have proper TypeScript support, add your extension point to the `ExtensionPointId` type in your local project:
+
+```typescript
+// src/core/plugins/custom-types.ts
+import {
+  ExtensionPointId as CoreExtensionPointId,
+  dashboardContent,
+  userEditForm,
+} from "src/core/plugins/types";
+import { myFeatureSettings } from "src/features/my-feature/extensionPoints";
+
+// Extend the core extension point type
+export type ExtensionPointId = CoreExtensionPointId | typeof myFeatureSettings;
+```
+
+#### 3. Create an Extension Slot
+
+Add an extension slot in your component where you want the extensions to render:
+
+```typescript
+// src/features/my-feature/MyFeature.tsx
+import { ExtensionSlot } from "src/core/plugins/ExtensionSlot";
+import { myFeatureSettings } from "./extensionPoints";
+
+const MyFeature = () => {
+  return (
+    <div className="my-feature">
+      <h2>My Feature</h2>
+
+      {/* This will render any extension targeting myFeatureSettings */}
+      <div className="my-feature-settings">
+        <h3>Settings</h3>
+        <ExtensionSlot extensionPointId={myFeatureSettings} />
+      </div>
+    </div>
+  );
+};
+```
+
+#### 4. Create Extensions for Your Custom Extension Point
+
+Other plugins can now target your extension point:
+
+```typescript
+// src/plugins/feature-settings/index.ts
+import { PluginModule } from "src/core/plugins/types";
+import { myFeatureSettings } from "src/features/my-feature/extensionPoints";
+import FeatureSettingsComponent from "./components/FeatureSettingsComponent";
+
+const featureSettingsPlugin: PluginModule = {
+  id: "feature-settings",
+  name: "Feature Settings Plugin",
+  version: "1.0.0",
+  description: "Adds custom settings to My Feature",
+
+  extensions: [
+    {
+      extensionPointId: myFeatureSettings,
+      component: () => <FeatureSettingsComponent />,
+      priority: 100,
+    },
+  ],
+};
+
+export default featureSettingsPlugin;
+```
+
+Creating custom extension points allows different parts of your application to be extended independently, promoting a more modular architecture.
+
+## Real-World Examples
+
+Here are some examples from the existing plugins to help you understand how to implement various features:
+
+### User Status Plugin
+
+The User Status plugin adds a status field to the user edit form:
+
+```typescript
+// Simplified example
+const userStatusPlugin: PluginModule = {
+  id: "user-status",
+  name: "User Status Plugin",
+  version: "1.0.0",
+  description: "Adds user status field to user forms",
+
+  extensions: [
+    {
+      extensionPointId: userEditForm,
+      component: (props) => <UserStatusField {...props} />,
+    },
+  ],
+};
+```
+
+### Login Customizer Plugin
+
+Customizes the login page with a logo and text:
+
+```typescript
+// Simplified example
+const loginCustomizerPlugin: PluginModule = {
+  id: "login-customizer",
+  name: "Login Customizer",
+  version: "1.0.0",
+  description: "Customizes the login page appearance",
+
+  extensions: [
+    {
+      extensionPointId: loginCustomization,
+      component: (props) => <LoginCustomizerComponent {...props} />,
+    },
+  ],
+};
+```
+
+## Best Practices
+
+1. **Use TypeScript**: Define proper interfaces for your components and props
+2. **Follow Naming Conventions**:
+
+   - Plugin directories: kebab-case (e.g., `my-custom-plugin`)
+   - Component files: PascalCase (e.g., `MyComponent.tsx`)
+   - Plugin ID: kebab-case (e.g., `"my-custom-plugin"`)
+
+3. **Error Handling**:
+
+   - Always handle potential errors in your components
+   - Use try/catch blocks in lifecycle hooks
+
+4. **Clean Up Resources**:
+
+   - Implement the cleanup method to remove event listeners, subscriptions, etc.
+   - Use useEffect cleanup functions in components
+
+5. **Performance**:
+
+   - Keep components small and focused
+   - Use memoization when appropriate
+   - Avoid unnecessary renders
+
+6. **Code Organization**:
+
+   - Keep related files in appropriate directories
+   - Use index.ts files for exporting
+
+7. **Consistency**:
+   - Follow existing patterns in the codebase
+   - Use PatternFly components for UI consistency
+
+## Troubleshooting
+
+### Common Issues
+
+1. **Plugin not loading**:
+
+   - Check the browser console for errors
+   - Verify that your plugin is properly registered in `src/plugins/index.ts`
+   - Ensure your plugin ID is unique
+
+2. **Component not rendering**:
+
+   - Confirm the extension point ID is correct
+   - Check that your component is properly exported
+   - Verify props are being handled correctly
+
+3. **TypeScript errors**:
+
+   - Ensure you're using proper types from `src/core/plugins/types.ts`
+   - Use `unknown` instead of `any` for better type safety
+
+4. **React warnings**:
+   - Use the React DevTools to identify issues
+   - Check component key props in lists
+   - Ensure proper dependency arrays in useEffect hooks
+
+### Debugging Tips
+
+- Use `console.info` in lifecycle hooks to verify registration
+- Add temporary UI indicators to confirm extension points are working
+- Check the Redux store for plugin-related state changes
+- Test components in isolation before integrating them as plugins
+
+---
+
+This guide covers the basics of creating and implementing plugins for the FreeIPA Modern WebUI. For more advanced scenarios or specific questions, refer to the core plugin implementation files or existing plugin examples.
diff --git a/src/plugins/hello-world/components/Greeting.tsx b/src/plugins/hello-world/components/Greeting.tsx
new file mode 100644
index 000000000..ef9ceb653
--- /dev/null
+++ b/src/plugins/hello-world/components/Greeting.tsx
@@ -0,0 +1,53 @@
+import React from "react";
+
+/**
+ * A simple greeting component for the Hello World plugin
+ */
+const Greeting = () => {
+  return (
+    <div className="pf-v5-c-card pf-m-compact" style={{ margin: "1rem 0" }}>
+      <div
+        className="pf-v5-c-card__title"
+        style={{ fontSize: "1.5rem", fontWeight: "bold", color: "#06c" }}
+      >
+        ✨ Hello World Plugin (HTML/CSS Styling) ✨
+      </div>
+      <div className="pf-v5-c-card__body">
+        <p style={{ fontSize: "1.1rem", fontStyle: "italic", color: "#484" }}>
+          This content is provided by the Hello World plugin using HTML/CSS
+          styling!
+        </p>
+        <p
+          style={{
+            fontSize: "1.1rem",
+            background: "linear-gradient(45deg, #06c, #4a90e2)",
+            WebkitBackgroundClip: "text",
+            WebkitTextFillColor: "transparent",
+            padding: "0.5rem 0",
+          }}
+        >
+          Plugins can add content to various parts of the application.
+        </p>
+      </div>
+      <div className="pf-v5-c-card__footer">
+        <button
+          className="pf-v5-c-button pf-m-primary"
+          style={{
+            background: "linear-gradient(45deg, #06c, #4a90e2)",
+            border: "none",
+            padding: "0.75rem 1.5rem",
+            borderRadius: "25px",
+            boxShadow: "0 4px 6px rgba(0,0,0,0.1)",
+            transition: "transform 0.2s ease",
+          }}
+          onMouseOver={(e) => (e.currentTarget.style.transform = "scale(1.05)")}
+          onMouseOut={(e) => (e.currentTarget.style.transform = "scale(1)")}
+        >
+          ✨ Demo Button ✨
+        </button>
+      </div>
+    </div>
+  );
+};
+
+export default Greeting;
diff --git a/src/plugins/hello-world/components/GreetingPatternfly.tsx b/src/plugins/hello-world/components/GreetingPatternfly.tsx
new file mode 100644
index 000000000..1ec038cee
--- /dev/null
+++ b/src/plugins/hello-world/components/GreetingPatternfly.tsx
@@ -0,0 +1,56 @@
+import React from "react";
+import {
+  Card,
+  CardTitle,
+  CardBody,
+  CardFooter,
+  Button,
+  Title,
+  Text,
+  TextVariants,
+} from "@patternfly/react-core";
+
+/**
+ * A greeting component built using PatternFly 5 components
+ */
+const GreetingPatternfly = () => {
+  return (
+    <Card isCompact style={{ margin: "1rem 0" }}>
+      <CardTitle>
+        <Title headingLevel="h2" size="xl">
+          <span style={{ color: "#06c" }}>
+            ✨ Hello World Plugin (PatternFly 5) ✨
+          </span>
+        </Title>
+      </CardTitle>
+      <CardBody>
+        <Text component={TextVariants.p}>
+          <span style={{ fontStyle: "italic", color: "#484" }}>
+            This content is provided by the Hello World plugin using PatternFly
+            5 components!
+          </span>
+        </Text>
+        <Text component={TextVariants.p} style={{ padding: "0.5rem 0" }}>
+          <span style={{ color: "#06c" }}>
+            Plugins can add content to various parts of the application using
+            the official PatternFly component library.
+          </span>
+        </Text>
+      </CardBody>
+      <CardFooter>
+        <Button
+          variant="primary"
+          style={{
+            background: "linear-gradient(45deg, #06c, #4a90e2)",
+            boxShadow: "0 4px 6px rgba(0,0,0,0.1)",
+          }}
+          className="pf-u-px-lg pf-u-py-md"
+        >
+          ✨ PatternFly Button ✨
+        </Button>
+      </CardFooter>
+    </Card>
+  );
+};
+
+export default GreetingPatternfly;
diff --git a/src/plugins/hello-world/index.ts b/src/plugins/hello-world/index.ts
new file mode 100644
index 000000000..b88830432
--- /dev/null
+++ b/src/plugins/hello-world/index.ts
@@ -0,0 +1,44 @@
+import { PluginModule } from "src/core/plugins/types";
+import Greeting from "./components/Greeting";
+import GreetingPatternfly from "./components/GreetingPatternfly";
+import { dashboardContent } from "src/core/plugins/extensionPoints";
+
+/**
+ * Hello World plugin definition
+ * This plugin demonstrates adding components to the dashboard in two different styles:
+ * 1. Using HTML/CSS styling
+ * 2. Using PatternFly 5 components
+ */
+const helloWorldPlugin: PluginModule = {
+  id: "hello-world",
+  name: "Hello World",
+  version: "1.0.0",
+  description:
+    "A simple Hello World plugin for FreeIPA WebUI with multiple greeting styles",
+  author: "FreeIPA Team",
+
+  extensions: [
+    {
+      // HTML/CSS styled greeting
+      extensionPointId: dashboardContent,
+      component: Greeting,
+      priority: 1,
+    },
+    {
+      // PatternFly 5 styled greeting
+      extensionPointId: dashboardContent,
+      component: GreetingPatternfly,
+      priority: 2, // higher priority to appear above the HTML/CSS version
+    },
+  ],
+
+  initialize: () => {
+    console.log("Hello World plugin initialized");
+  },
+
+  cleanup: () => {
+    console.log("Hello World plugin cleaned up");
+  },
+};
+
+export default helloWorldPlugin;
diff --git a/src/plugins/hostgroup-dn-column/components/HostGroupDNColumn.tsx b/src/plugins/hostgroup-dn-column/components/HostGroupDNColumn.tsx
new file mode 100644
index 000000000..b7432ca7c
--- /dev/null
+++ b/src/plugins/hostgroup-dn-column/components/HostGroupDNColumn.tsx
@@ -0,0 +1,44 @@
+import React from "react";
+import { Th, Td } from "@patternfly/react-table";
+import { Tooltip } from "@patternfly/react-core";
+import { HostGroup } from "src/utils/datatypes/globalDataTypes";
+
+interface HostGroupDNColumnProps {
+  // for header cell
+  isHeader?: boolean;
+
+  // for data cell
+  hostGroup?: HostGroup;
+  rowIndex?: number;
+}
+
+/**
+ * Component that renders a DN (Distinguished Name) column for the Host Groups table
+ */
+export const HostGroupDNColumn = (props: HostGroupDNColumnProps) => {
+  const { isHeader, hostGroup } = props;
+
+  // header cell rendering
+  if (isHeader) {
+    return <Th modifier="wrap">Distinguished Name</Th>;
+  }
+
+  // render data cell (if hostGroup is provided)
+  if (hostGroup && hostGroup.dn) {
+    // DN is long, so shortenned version is displayed + tooltip
+    const fullDN = hostGroup.dn;
+    const displayDN =
+      fullDN.length > 40 ? `${fullDN.substring(0, 37)}...` : fullDN;
+
+    return (
+      <Td dataLabel="Distinguished Name">
+        <Tooltip content={fullDN} maxWidth="500px">
+          <span>{displayDN}</span>
+        </Tooltip>
+      </Td>
+    );
+  }
+
+  // empty cell fallback
+  return <Td dataLabel="Distinguished Name">-</Td>;
+};
diff --git a/src/plugins/hostgroup-dn-column/index.ts b/src/plugins/hostgroup-dn-column/index.ts
new file mode 100644
index 000000000..f736b1fff
--- /dev/null
+++ b/src/plugins/hostgroup-dn-column/index.ts
@@ -0,0 +1,34 @@
+import { PluginModule } from "src/core/plugins/types";
+import { hostGroupsTableColumns } from "src/core/plugins/extensionPoints";
+import { HostGroupDNColumn } from "./components/HostGroupDNColumn";
+
+/**
+ * Host Group DN Column Plugin
+ * Adds a Distinguished Name (DN) column to the Host Groups table
+ */
+export const hostGroupDNColumnPlugin: PluginModule = {
+  id: "hostgroup-dn-column",
+  name: "Host Group DN Column",
+  version: "1.0.0",
+  description: "Adds a Distinguished Name (DN) column to the Host Groups table",
+  author: "FreeIPA Team",
+
+  // Register UI components
+  extensions: [
+    {
+      extensionPointId: hostGroupsTableColumns,
+      component: HostGroupDNColumn,
+      priority: 10,
+    },
+  ],
+
+  // Initialize plugin
+  initialize: () => {
+    console.log("Host Group DN Column plugin loaded");
+  },
+
+  // Cleanup plugin
+  cleanup: () => {
+    console.log("Host Group DN Column plugin unloaded");
+  },
+};
diff --git a/src/plugins/index.ts b/src/plugins/index.ts
new file mode 100644
index 000000000..63854523f
--- /dev/null
+++ b/src/plugins/index.ts
@@ -0,0 +1,23 @@
+import { pluginRegistry } from "src/core/plugins/PluginRegistry";
+
+// Import all plugins
+import helloWorldPlugin from "./hello-world";
+import { userStatusPlugin } from "./user-status";
+import { hostGroupDNColumnPlugin } from "./hostgroup-dn-column";
+import loginCustomizerPlugin from "./login-customizer";
+
+/**
+ * Register all plugins with the plugin registry
+ */
+export function registerAllPlugins(): void {
+  // add all plugins to be registered here
+  const plugins = [
+    helloWorldPlugin,
+    userStatusPlugin,
+    hostGroupDNColumnPlugin,
+    loginCustomizerPlugin,
+  ];
+
+  // register each plugin
+  plugins.forEach((plugin) => pluginRegistry.registerPlugin(plugin));
+}
diff --git a/src/plugins/login-customizer/assets/Logo-Red_Hat-A-Standard-RGB.Small-logo-transparent.png b/src/plugins/login-customizer/assets/Logo-Red_Hat-A-Standard-RGB.Small-logo-transparent.png
new file mode 100644
index 000000000..c2fb19676
Binary files /dev/null and b/src/plugins/login-customizer/assets/Logo-Red_Hat-A-Standard-RGB.Small-logo-transparent.png differ
diff --git a/src/plugins/login-customizer/assets/muni-lg-rgb.png b/src/plugins/login-customizer/assets/muni-lg-rgb.png
new file mode 100644
index 000000000..c5d449228
Binary files /dev/null and b/src/plugins/login-customizer/assets/muni-lg-rgb.png differ
diff --git a/src/plugins/login-customizer/components/LoginCustomizerComponent.tsx b/src/plugins/login-customizer/components/LoginCustomizerComponent.tsx
new file mode 100644
index 000000000..aac207fa3
--- /dev/null
+++ b/src/plugins/login-customizer/components/LoginCustomizerComponent.tsx
@@ -0,0 +1,33 @@
+import { useEffect } from "react";
+import muniLogo from "../assets/muni-lg-rgb.png";
+
+interface LoginCustomizerProps {
+  setLogo: (logo: string) => void;
+  setloginPageText: (text: string) => void;
+}
+
+/**
+ * Component that customizes the login page appearance
+ * This is designed to be used with the loginCustomization extension point
+ */
+const LoginCustomizerComponent = (props: LoginCustomizerProps) => {
+  const { setLogo, setloginPageText } = props;
+
+  const customText =
+    "Masaryk University is the second largest university in the Czech Republic. Founded in 1919 in Brno, it now consists of ten faculties and around 35,000 students. It is named after Tomáš Garrigue Masaryk, the first president of an independent Czechoslovakia.";
+
+  useEffect(() => {
+    setLogo(muniLogo);
+    setloginPageText(customText);
+
+    // component unmounts
+    return () => {
+      setLogo("");
+      setloginPageText("");
+    };
+  }, [setLogo, setloginPageText]);
+
+  return null;
+};
+
+export default LoginCustomizerComponent;
diff --git a/src/plugins/login-customizer/index.ts b/src/plugins/login-customizer/index.ts
new file mode 100644
index 000000000..34d3692fb
--- /dev/null
+++ b/src/plugins/login-customizer/index.ts
@@ -0,0 +1,37 @@
+import { PluginModule } from "src/core/plugins/types";
+import { loginCustomization } from "src/core/plugins/extensionPoints";
+import LoginCustomizerComponent from "./components/LoginCustomizerComponent";
+
+/**
+ * Login Customizer Plugin
+ *
+ * This plugin customizes the login page by replacing the logo and adding custom text.
+ */
+const loginCustomizerPlugin: PluginModule = {
+  id: "login-customizer",
+  name: "Login Customizer",
+  version: "1.0.0",
+  description:
+    "Customizes the login page with a Red Hat logo and additional text",
+  author: "FreeIPA Team",
+
+  extensions: [
+    {
+      extensionPointId: loginCustomization,
+      component: LoginCustomizerComponent,
+      priority: 1,
+    },
+  ],
+
+  // Initialize plugin
+  initialize: () => {
+    console.log("Login Customizer plugin initialized");
+  },
+
+  // Cleanup plugin
+  cleanup: () => {
+    console.log("Login Customizer plugin cleaned up");
+  },
+};
+
+export default loginCustomizerPlugin;
diff --git a/src/plugins/user-status/api/userStatusApiService.ts b/src/plugins/user-status/api/userStatusApiService.ts
new file mode 100644
index 000000000..3b77add52
--- /dev/null
+++ b/src/plugins/user-status/api/userStatusApiService.ts
@@ -0,0 +1,14 @@
+// Re-export the hooks from rpcUserStatus.ts
+import {
+  useGetUserStatusQuery,
+  useUpdateUserStatusMutation,
+  UserStatus,
+  UserStatusShowPayload,
+  UserStatusUpdatePayload,
+} from "src/services/rpcUserStatus";
+
+// Export type definitions
+export type { UserStatus, UserStatusShowPayload, UserStatusUpdatePayload };
+
+// Re-export the hooks
+export { useGetUserStatusQuery, useUpdateUserStatusMutation };
diff --git a/src/plugins/user-status/components/UserStatusField.tsx b/src/plugins/user-status/components/UserStatusField.tsx
new file mode 100644
index 000000000..c2f163129
--- /dev/null
+++ b/src/plugins/user-status/components/UserStatusField.tsx
@@ -0,0 +1,89 @@
+import React, { useState, useEffect } from "react";
+import { FormGroup, Radio } from "@patternfly/react-core";
+
+interface User {
+  inetuserstatus?: string | string[];
+  [key: string]: unknown;
+}
+
+interface UserStatusFieldProps {
+  value?: string;
+  onChange?: (value: User) => void;
+  user?: User;
+}
+
+/**
+ * Component that renders a user status radio field for the user edit form
+ */
+const UserStatusField = (props: UserStatusFieldProps) => {
+  const { value: initialValue = "active", onChange = () => {}, user } = props;
+
+  // get initial value from user if available
+  const getCurrentStatus = () => {
+    if (
+      user &&
+      user.inetuserstatus &&
+      Array.isArray(user.inetuserstatus) &&
+      user.inetuserstatus.length > 0
+    ) {
+      return user.inetuserstatus[0];
+    }
+    // if string value is provided directly
+    if (user && typeof user.inetuserstatus === "string") {
+      return user.inetuserstatus;
+    }
+    return initialValue || "active";
+  };
+
+  const [value, setValue] = useState<string>(getCurrentStatus());
+
+  // update internal state when user changes
+  useEffect(() => {
+    setValue(getCurrentStatus());
+  }, [user]);
+
+  const handleChange = (newValue: string) => {
+    // update internal state
+    setValue(newValue);
+
+    // update the user object if provided
+    if (user && typeof user === "object") {
+      const updatedUser = { ...user };
+
+      updatedUser.inetuserstatus = newValue;
+
+      onChange(updatedUser);
+    }
+  };
+
+  return (
+    <FormGroup label="User Status" fieldId="user-status-field">
+      <Radio
+        id="status-active"
+        name="user-status"
+        label="Active"
+        description="User is active and can work on tasks"
+        isChecked={value === "active"}
+        onChange={() => handleChange("active")}
+      />
+      <Radio
+        id="status-inactive"
+        name="user-status"
+        label="Inactive"
+        description="User is inactive and cannot work on tasks"
+        isChecked={value === "inactive"}
+        onChange={() => handleChange("inactive")}
+      />
+      <Radio
+        id="status-disabled"
+        name="user-status"
+        label="Disabled"
+        description="User is disabled for task execution"
+        isChecked={value === "disabled"}
+        onChange={() => handleChange("disabled")}
+      />
+    </FormGroup>
+  );
+};
+
+export default UserStatusField;
diff --git a/src/plugins/user-status/index.ts b/src/plugins/user-status/index.ts
new file mode 100644
index 000000000..dc1cea9ee
--- /dev/null
+++ b/src/plugins/user-status/index.ts
@@ -0,0 +1,36 @@
+import { PluginModule } from "src/core/plugins/types";
+import UserStatusField from "./components/UserStatusField";
+import { userEditForm } from "src/core/plugins/extensionPoints";
+
+/**
+ * User Status Plugin
+ * This plugin allows users to set and view their availability status (active, inactive, disabled)
+ * It uses the inetuserstatus LDAP attribute
+ */
+export const userStatusPlugin: PluginModule = {
+  id: "user-status",
+  name: "User Status",
+  version: "1.0.0",
+  description:
+    "Allow users to set and view their availability status (active, inactive, disabled)",
+  author: "FreeIPA Team",
+
+  // register UI components
+  extensions: [
+    {
+      extensionPointId: userEditForm,
+      component: UserStatusField,
+      priority: 10,
+    },
+  ],
+
+  // initialize plugin
+  initialize: () => {
+    console.log("User Status plugin initialized");
+  },
+
+  // cleanup plugin
+  cleanup: () => {
+    console.log("User Status plugin cleaned up");
+  },
+};
diff --git a/src/services/rpcUserStatus.ts b/src/services/rpcUserStatus.ts
new file mode 100644
index 000000000..9a27c95a0
--- /dev/null
+++ b/src/services/rpcUserStatus.ts
@@ -0,0 +1,125 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { api, getCommand } from "./rpc";
+import { API_VERSION_BACKUP } from "../utils/utils";
+
+/**
+ * User Status-related endpoints: getUserStatus, updateUserStatus
+ *
+ * API commands:
+ * - user_show: https://freeipa.readthedocs.io/en/latest/api/user_show.html
+ *   Used to retrieve user data including inetuserstatus attribute
+ * - user_mod: https://freeipa.readthedocs.io/en/latest/api/user_mod.html
+ *   Used to update user data including inetuserstatus attribute
+ */
+
+export interface UserStatus {
+  userId: string;
+  status: "active" | "inactive" | "disabled";
+}
+
+export interface UserStatusShowPayload {
+  userId: string;
+}
+
+export interface UserStatusUpdatePayload {
+  userId: string;
+  status: "active" | "inactive" | "disabled";
+}
+
+const validateStatus = (status: string): "active" | "inactive" | "disabled" => {
+  if (status === "active" || status === "inactive" || status === "disabled") {
+    return status;
+  }
+  return "active";
+};
+
+const extendedApi = api.injectEndpoints({
+  endpoints: (build) => ({
+    // get user status
+    getUserStatus: build.query<UserStatus, UserStatusShowPayload>({
+      query: (payload) => {
+        return getCommand({
+          method: "user_show",
+          params: [
+            [payload.userId],
+            {
+              all: true,
+              rights: true,
+              version: API_VERSION_BACKUP,
+            },
+          ],
+        });
+      },
+      transformResponse: (response: any): UserStatus => {
+        if (!response || !response.result || !response.result.result) {
+          console.error("Invalid response format:", response);
+          return {
+            userId: "",
+            status: "active",
+          };
+        }
+
+        const userId = Array.isArray(response.result.result.uid)
+          ? response.result.result.uid[0]
+          : response.result.result.uid || "";
+
+        const status = Array.isArray(response.result.result.inetuserstatus)
+          ? response.result.result.inetuserstatus[0]
+          : response.result.result.inetuserstatus || "active";
+
+        return {
+          userId,
+          status: validateStatus(status),
+        };
+      },
+      providesTags: ["FullUser"],
+    }),
+
+    // update user status
+    updateUserStatus: build.mutation<UserStatus, UserStatusUpdatePayload>({
+      query: (payload) => {
+        return getCommand({
+          method: "user_mod",
+          params: [
+            [payload.userId],
+            {
+              inetuserstatus: [payload.status],
+              version: API_VERSION_BACKUP,
+            },
+          ],
+        });
+      },
+      transformResponse: (response: any): UserStatus => {
+        if (!response || !response.result || !response.result.result) {
+          console.error("Invalid response format:", response);
+
+          const context =
+            (response && response.error && response.error.context) || {};
+
+          return {
+            userId: context.userId || "",
+            status: validateStatus(context.status || "active"),
+          };
+        }
+
+        const userId = Array.isArray(response.result.result.uid)
+          ? response.result.result.uid[0]
+          : response.result.result.uid || "";
+
+        const status = Array.isArray(response.result.result.inetuserstatus)
+          ? response.result.result.inetuserstatus[0]
+          : response.result.result.inetuserstatus || "active";
+
+        return {
+          userId,
+          status: validateStatus(status),
+        };
+      },
+      invalidatesTags: ["FullUser"],
+    }),
+  }),
+  overrideExisting: false,
+});
+
+export const { useGetUserStatusQuery, useUpdateUserStatusMutation } =
+  extendedApi;


Reviews:
user: login: sourcery-ai[bot]
body: Hey @b3lix - I've reviewed your changes - here's some feedback:

**Overall Comments**:
- Consider adding a more robust error handling mechanism for plugin registration to prevent a single plugin failure from crashing the entire application.
- The pluginApiService looks like it's mocking the backend - make sure this is removed before merging.

<details>
<summary>Here's what I looked at during the review</summary>

- 🟡 **General issues**: 1 issue found
- 🟢 **Security**: all looks good
- 🟢 **Testing**: all looks good
- 🟡 **Complexity**: 1 issue found
- 🟢 **Documentation**: all looks good
</details>

***

<details>
<summary>Sourcery is free for open source - if you like our reviews please consider sharing them ✨</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https://sourcery.ai)
- [Facebook](https://www.facebook.com/sharer/sharer.php?u=https://sourcery.ai)

</details>

<sub>
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
</sub>
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-11T11:49:24Z
user: login: carma12
body: Some of the comments were already discussed in our meetings, but I provided extra information. Overall looks good, but there are some things that would be interesting to start looking:
- Nowadays the trend is to define functional components instead of class-based components
- Start using PatternFly components could beneficial to get the library features (provided a detailed list of reasons to use it)
- Remove `React.FC` components
- Logging should be done without relying on `console.log` mechanisms
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T14:47:52Z
user: login: carma12
body: - Greetings page with PF ✅ 
- New column in Host groups page ✅ 
- Replace class-based components to functional: Just a tiny renaming suggestion
- User status: I have added most of the comments here. There are suggestions based on code and a few questions. Some of these things have been already discussed in the meeting.

Alternatively: This PR needs to be rebased with the latest changes on the code, this will fix the CI tests not being executed. Also, if the tests are still failing after rebasing, try to execute Prettier (`npm run prettier:fix`) and Linter (`npm run lint`) commands to detect any error related to styling.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T14:05:58Z
user: login: duzda
body: Few notes, let's fix or at least discuss them :)
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T09:00:32Z
user: login: b3lix
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-18T23:03:29Z
user: login: b3lix
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-18T23:05:48Z
user: login: b3lix
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T21:55:12Z
user: login: duzda
body: Please just get the linter fixed.

Other than that, registering plugins would've been imo nicer through custom useHook instead of wrapping it in `useEffect`. Which you've done anyways, apart from using it as a singleton.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T07:38:47Z

Review comments:
diff_hunk: @@ -0,0 +1,118 @@
+import React, { useState, useEffect } from "react";
+import { FormGroup, Radio, Skeleton, Button } from "@patternfly/react-core";
+import { pluginApiService } from "src/services";
+
+interface UserStatusFieldProps {
+  value?: string;
+  onChange?: (value: string) => void;
+  isReadOnly?: boolean;
+  userId?: string;
+  user?: any;
+}
+
+/**
+ * Component that renders a user status radio field for the user edit form
+ */
+const UserStatusField: React.FC<UserStatusFieldProps> = ({
+  value: initialValue = "active",
+  onChange = (val: string) => {
+    console.log("Status changed:", val);
+  },
+  isReadOnly = false,
+  userId,
+  user,
+}) => {
+  const [loading, setLoading] = useState<boolean>(false);
+  const [value, setValue] = useState<string>(initialValue);
+  const [statusUpdated, setStatusUpdated] = useState<boolean>(false);
+
+  // Get userId from either direct prop or from user object
+  const effectiveUserId = userId || (user && user.uid && user.uid[0]);
+
+  // Update internal state when props change
+  useEffect(() => {
+    setValue(initialValue);
+  }, [initialValue]);
+
+  // Handler for radio selection
+  const handleChange = (newValue: string) => {
+    // Update internal state
+    setValue(newValue);
+
+    // Call the onChange handler from parent
+    onChange(newValue);
+
+    // Set status update flag
+    setStatusUpdated(true);
+  };
+
+  // Handler for save button
+  const handleSave = () => {
+    setLoading(true);
+    setStatusUpdated(false);
+
+    console.log(`Saving user ${effectiveUserId} status to ${value}`);
path: src/plugins/user-status/components/UserStatusField.tsx
user: login: sourcery-ai[bot]
body: **suggestion:** Remove or gate console logs for production code.

These debugging statements should either be removed or controlled via a debug flag so as not to clutter production logs.
created_at: 2025-04-11T11:49:24Z
author_association: NONE
diff_hunk: @@ -0,0 +1,230 @@
+import React, { useState, useEffect } from "react";
+import {
+  Label,
+  Badge,
+  Skeleton,
+  Flex,
+  FlexItem,
+  Radio,
+  Button,
+  Alert,
+} from "@patternfly/react-core";
+import { pluginApiService } from "src/services";
+
+interface UserStatusWidgetProps {
+  userId?: string;
+  user?: any;
+}
+
+// User status response type
+interface UserStatusResponse {
+  status: string;
+  userId: string;
+}
+
+/**
+ * Component that displays a user's status in the user details page
+ * and allows for direct editing
+ */
+const UserStatusWidget: React.FC<UserStatusWidgetProps> = ({
path: src/plugins/user-status/components/UserStatusWidget.tsx
user: login: sourcery-ai[bot]
body: **issue (complexity):** Consider using a custom hook to encapsulate state management, API calls, and side-effect logic, which will simplify the component's structure and improve readability by separating concerns related to data handling and rendering logic..

Consider moving state management, API calls, and side-effect logic into a custom hook. This will isolate complexity and simplify the rendering component. For example, you can extract the user status logic:

```tsx
// useUserStatus.ts
import { useState, useEffect } from "react";
import { pluginApiService } from "src/services";

export interface UserStatusResponse {
  status: string;
  userId: string;
}

export const useUserStatus = (effectiveUserId: string | null) => {
  const [status, setStatus] = useState<string | null>(null);
  const [loading, setLoading] = useState<boolean>(!!effectiveUserId);
  const [error, setError] = useState<string | null>(null);
  const [success, setSuccess] = useState<string | null>(null);
  const [newStatus, setNewStatus] = useState<string>("");
  const [isEditing, setIsEditing] = useState<boolean>(false);

  const fetchStatus = async () => {
    if (!effectiveUserId) {
      setError("No user ID available");
      setLoading(false);
      return;
    }
    setLoading(true);
    try {
      const response = await pluginApiService.get<UserStatusResponse>(
        `/user-status/${effectiveUserId}`
      );
      setStatus(response.status);
      setNewStatus(response.status);
    } catch (err) {
      console.error("Error fetching user status:", err);
      setError("Failed to load user status");
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchStatus();
  }, [effectiveUserId]);

  const triggerSave = async () => {
    setError(null);
    setSuccess(null);
    setLoading(true);
    try {
      await pluginApiService.put<UserStatusResponse>(`/user-status/${effectiveUserId}`, {
        status: newStatus,
      });
      setStatus(newStatus);
      setIsEditing(false);
      setSuccess(`User status updated to ${newStatus}`);
      setTimeout(() => setSuccess(null), 3000);
    } catch (err) {
      console.error("Error updating user status:", err);
      setError("Failed to update user status");
    } finally {
      setLoading(false);
    }
  };

  const cancelEditing = () => {
    setNewStatus(status || "");
    setIsEditing(false);
    setError(null);
  };

  return {
    status,
    loading,
    error,
    success,
    newStatus,
    isEditing,
    setNewStatus,
    setIsEditing,
    triggerSave,
    cancelEditing,
  };
};
```

And then simplify your component by using the hook:

```tsx
// UserStatusWidget.tsx
import React from "react";
import {
  Label,
  Badge,
  Skeleton,
  Flex,
  FlexItem,
  Radio,
  Button,
  Alert,
} from "@patternfly/react-core";
import { useUserStatus } from "./useUserStatus";

interface UserStatusWidgetProps {
  userId?: string;
  user?: any;
}

const UserStatusWidget: React.FC<UserStatusWidgetProps> = ({ userId, user }) => {
  const effectiveUserId = userId || (user && user.uid && user.uid[0]);
  const {
    status,
    loading,
    error,
    success,
    newStatus,
    isEditing,
    setNewStatus,
    setIsEditing,
    triggerSave,
    cancelEditing,
  } = useUserStatus(effectiveUserId);

  const renderStatusBadge = (status: string) => {
    const colors: Record<string, string> = {
      active: "green",
      inactive: "blue",
      disabled: "red",
    };
    const color = colors[status] || "grey";
    return <Badge color={color as any}>{status.charAt(0).toUpperCase() + status.slice(1)}</Badge>;
  };

  if (loading && !isEditing) {
    return (
      <div style={{ padding: "8px 0" }}>
        <Skeleton width="100%" height="60px" />
      </div>
    );
  }

  return (
    <div style={{ padding: "8px 0" }}>
      {error && (
        <Alert variant="danger" title={error} className="pf-v5-u-mb-md" isInline />
      )}

      {success && (
        <Alert variant="success" title={success} className="pf-v5-u-mb-md" isInline />
      )}

      {isEditing ? (
        <div>
          <Label>Change User Status:</Label>
          <div className="pf-v5-u-mt-md">
            <Radio
              id="widget-status-active"
              name="widget-user-status"
              label="Active"
              description="User is active and can work on tasks"
              isChecked={newStatus === "active"}
              onChange={() => setNewStatus("active")}
              isDisabled={loading}
            />
            <Radio
              id="widget-status-inactive"
              name="widget-user-status"
              label="Inactive"
              description="User is inactive and cannot work on tasks"
              isChecked={newStatus === "inactive"}
              onChange={() => setNewStatus("inactive")}
              isDisabled={loading}
            />
            <Radio
              id="widget-status-disabled"
              name="widget-user-status"
              label="Disabled"
              description="User is disabled for task execution"
              isChecked={newStatus === "disabled"}
              onChange={() => setNewStatus("disabled")}
              isDisabled={loading}
            />
          </div>
          <Flex className="pf-v5-u-mt-md">
            <FlexItem>
              <Button variant="primary" onClick={triggerSave} isDisabled={loading} isLoading={loading}>
                {loading ? "Saving..." : "Save"}
              </Button>
            </FlexItem>
            <FlexItem>
              <Button variant="link" onClick={cancelEditing} isDisabled={loading}>
                Cancel
              </Button>
            </FlexItem>
          </Flex>
        </div>
      ) : (
        <Flex>
          <FlexItem>
            <Label>Status:</Label>{" "}
            {status ? renderStatusBadge(status) : "No status available"}
          </FlexItem>
          <FlexItem>
            <Button
              variant="link"
              onClick={() => setIsEditing(true)}
              className="pf-v5-u-ml-md"
            >
              Edit
            </Button>
          </FlexItem>
        </Flex>
      )}
    </div>
  );
};

export default UserStatusWidget;
```

This separates side effects and logic from rendering, reducing nested conditionals inside your component while preserving functionality.
created_at: 2025-04-11T11:49:24Z
author_association: NONE
diff_hunk: @@ -0,0 +1,43 @@
+import React from "react";
+import { ExtensionPointId } from "./types";
+import { pluginRegistry } from "./PluginRegistry";
+
+interface ExtensionSlotProps {
+  /**
+   * The ID of the extension point to render extensions for
+   */
+  extensionPointId: ExtensionPointId;
+
+  /**
+   * Optional context data to pass to the extension components
+   */
+  context?: Record<string, any>;
+}
+
+/**
+ * Component that renders all registered extensions for a specific extension point
+ */
+export const ExtensionSlot: React.FC<ExtensionSlotProps> = ({
path: src/core/plugins/ExtensionSlot.tsx
user: login: carma12
body: Consider not using React.FC because according to [this link](https://dev.to/wdp/rethinking-typescript-practices-the-downside-of-reactfc-1ci7):

> When you use React. FC , it implicitly includes children in the props, even if you don't need them. It means all components accept children as a prop. This can lead to unwanted renderings in your component and is not caught by TypeScript due to React.

Instead, pass it as props:
```
export const ExtensionSlot = (props: ExtensionSlotProps) => {
   ... 
   // Refer to the props like, e.g.: props.extensionPointId
};
```
created_at: 2025-04-11T13:12:12Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,104 @@
+import { PluginModule, ExtensionPointId, ExtensionComponent } from './types';
+
+/**
+ * Singleton registry that manages all plugins and their extensions
+ */
+class PluginRegistry {
path: src/core/plugins/PluginRegistry.ts
user: login: carma12
body: The current trend in nowadays React apps is lead more to functional components instead of class-based components (as highlighted in the `Pitfall` [here](https://react.dev/reference/react/Component)). You can learn how to transform class components into functional components [here](https://react.dev/reference/react/Component#migrating-a-simple-component-from-a-class-to-a-function) and [here](https://www.freecodecamp.org/news/function-component-vs-class-component-in-react/). This will also imply removing all the `this` references and adapt some things.

Ideally this must be applied to all class-based components from your solution, as the modern WebUI is already using functional components.
created_at: 2025-04-11T13:22:23Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,83 @@
+import { Reducer } from "@reduxjs/toolkit";
+import React from "react";
+
+/**
+ * Represents an extension point in the application where plugins can inject content
+ */
+export interface ExtensionPoint<T = any> {
path: src/core/plugins/types.ts
user: login: carma12
body: You can replace the `<T = any>` to just `<T>` as is it a Typescript generic. 
created_at: 2025-04-11T13:27:45Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,83 @@
+import { Reducer } from "@reduxjs/toolkit";
+import React from "react";
+
+/**
+ * Represents an extension point in the application where plugins can inject content
+ */
+export interface ExtensionPoint<T = any> {
+  id: string;
+  displayName: string;
+  description: string;
+}
+
+/**
+ * All available extension points in the application
+ */
+export const extensionPoints = {
+  // Dashboard extension points
+  dashboardContent: {
+    id: "dashboardContent",
+    displayName: "Dashboard Content",
+    description: "Add content to the main dashboard",
+  } as ExtensionPoint,
path: src/core/plugins/types.ts
user: login: carma12
body: As discussed in our meetings, try to avoid using `as ExtensionPoint` as this is directly casting the values instead of making the object value to adapt to that given type. An alternative, try to define the custom extension types as, e.g.:
```
const dashboardContent: ExtensionPoint =  {
    id: "dashboardContent",
    displayName: "Dashboard Content",
    description: "Add content to the main dashboard",
  };
``` 
created_at: 2025-04-14T12:13:23Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,83 @@
+import { Reducer } from "@reduxjs/toolkit";
+import React from "react";
+
+/**
+ * Represents an extension point in the application where plugins can inject content
+ */
+export interface ExtensionPoint<T = any> {
+  id: string;
+  displayName: string;
+  description: string;
+}
+
+/**
+ * All available extension points in the application
+ */
+export const extensionPoints = {
+  // Dashboard extension points
+  dashboardContent: {
+    id: "dashboardContent",
+    displayName: "Dashboard Content",
+    description: "Add content to the main dashboard",
+  } as ExtensionPoint,
+
+  // User-related extension points
+  userDetailsContent: {
+    id: "userDetailsContent",
+    displayName: "User Details Content",
+    description: "Add content to the user details page",
+  } as ExtensionPoint,
+
+  userEditForm: {
+    id: "userEditForm",
+    displayName: "User Edit Form",
+    description: "Add fields to the user edit form",
+  } as ExtensionPoint,
+
+  // Navigation extension points
+  navigationItems: {
+    id: "navigationItems",
+    displayName: "Navigation Items",
+    description: "Add items to the main navigation",
+  } as ExtensionPoint,
+};
+
+/**
+ * Type for extension point IDs
+ */
+export type ExtensionPointId = keyof typeof extensionPoints;
+
+/**
+ * Base plugin interface with metadata
+ */
+export interface Plugin {
+  id: string;
+  name: string;
+  version: string;
+  description: string;
+  author?: string;
+}
+
+/**
+ * Represents a component that extends a specific extension point
+ */
+export interface ExtensionComponent<T = any> {
path: src/core/plugins/types.ts
user: login: carma12
body: Replace to `ExtensionComponent<T>`.
created_at: 2025-04-15T14:25:40Z
author_association: COLLABORATOR
diff_hunk: @@ -71,6 +72,8 @@ export const AppRoutes = ({ isInitialDataLoaded }): React.ReactElement => {
         <Routes>
           {userLoggedIn ? (
             <>
+              {/* Dashboard Route */}
+              <Route path="" element={<Dashboard />} />
path: src/navigation/AppRoutes.tsx
user: login: carma12
body: As discussed in the meetings, this Dashboard route can be there for demo purposes. But eventually it will be removed as the plugins will be set on the already-existing pages/routes.
created_at: 2025-04-15T14:27:34Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,46 @@
+import React from "react";
+import {
+  Page,
+  PageSection,
+  Title,
+  Grid,
+  GridItem,
+} from "@patternfly/react-core";
+import { ExtensionSlot } from "src/core/plugins";
+
+/**
+ * Dashboard page component that displays the main dashboard
+ * and includes the dashboardContent extension point
+ */
+const Dashboard: React.FC = () => {
path: src/pages/Dashboard/Dashboard.tsx
user: login: carma12
body: Remove the `React.FC`:
```
const Dashboard = () => { ... }
```
created_at: 2025-04-15T14:28:53Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,52 @@
+import React from "react";
+
+/**
+ * A simple greeting component for the Hello World plugin
+ */
+const Greeting: React.FC = () => {
path: src/plugins/hello-world/components/Greeting.tsx
user: login: carma12
body: Remove the `React.FC`:
```
const Dashboard = () => { ... }
```
created_at: 2025-04-15T14:29:17Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,52 @@
+import React from "react";
+
+/**
+ * A simple greeting component for the Hello World plugin
+ */
+const Greeting: React.FC = () => {
+  return (
+    <div className="pf-v5-c-card pf-m-compact" style={{ margin: "1rem 0" }}>
+      <div
+        className="pf-v5-c-card__title"
+        style={{ fontSize: "1.5rem", fontWeight: "bold", color: "#06c" }}
+      >
+        ✨ Hello World Plugin ✨
+      </div>
+      <div className="pf-v5-c-card__body">
path: src/plugins/hello-world/components/Greeting.tsx
user: login: carma12
body: As discussed in the meetings, it would be interesting to also see this implemented with [PatternFly components](https://v5-archive.patternfly.org/components/all-components). But this example keep the regular HTML code.

Some advantages of using PatternFly:
- It provides out-of-the-box components ready to use
- Highly configurable
- Many components provide already-integrated accessibility features 
- Components contain inner styling set, so they require less CSS code to make it look prettier
  - Although it provides a way to add extra styling via `style` prop (available in all components). E.g. when using [TextArea](https://github.com/freeipa/freeipa-webui/blob/f2ef29ca33d1f6494dcab5eb3f520bf102188ca3/src/components/modals/CertificateModals/IssueNewCertificate.tsx#L323)
- Regular updates and new features provided on every version (released every year)

created_at: 2025-04-15T14:30:56Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,31 @@
+import { PluginModule } from 'src/core/plugins/types';
+import Greeting from './components/Greeting';
+
+/**
+ * Hello World plugin definition
+ */
+const helloWorldPlugin: PluginModule = {
+  id: 'hello-world',
+  name: 'Hello World',
+  version: '1.0.0',
+  description: 'A simple Hello World plugin for FreeIPA WebUI',
+  author: 'FreeIPA Team',
+  
+  extensions: [
+    {
+      extensionPointId: 'dashboardContent',
+      component: Greeting,
+      priority: 10
+    }
+  ],
+  
+  initialize: () => {
+    console.log('Hello World plugin initialized');
+  },
+  
+  cleanup: () => {
+    console.log('Hello World plugin cleaned up');
path: src/plugins/hello-world/index.ts
user: login: carma12
body: There is no need to change this now, but eventually the logs won't be displayed via `console.log` but through another logging method (e.g. log files)
created_at: 2025-04-15T14:40:42Z
author_association: COLLABORATOR
diff_hunk: @@ -216,6 +217,17 @@ const UsersAccountSettings = (props: PropsToUsersAccountSettings) => {
                 metadata={props.metadata}
               />
             </FormGroup>
+
+            {/* Extension point for user edit form from plugins */}
+            <ExtensionSlot
+              extensionPointId="userEditForm"
+              context={{
+                userId: props.user.uid?.[0] as string,
+                user: props.user,
path: src/components/UsersSections/UsersAccountSettings.tsx
user: login: carma12
body: Why passing both `user` and `userId` if you can infer the `uid` from the `user` itself? Like:
```
context={{
     user: props.user,  // <-- This already contains the `uid`
     onChange: recordOnChange,
     isReadOnly: false,
}}
```
created_at: 2025-05-05T07:18:19Z
author_association: COLLABORATOR
diff_hunk: @@ -1,12 +1,14 @@
 import React from "react";
 import { ExtensionPointId } from "./types";
 import { pluginRegistry } from "./PluginRegistry";
+import { extensionPointsMap } from "./extensionPoints";
 
 interface ExtensionSlotProps {
   /**
-   * The ID of the extension point to render extensions for
+   * The ID of the extension point to render extensions for.
+   * Can be either a string ID or an ExtensionPoint object.
    */
-  extensionPointId: ExtensionPointId;
+  extensionPointId: ExtensionPointId | string;
path: src/core/plugins/ExtensionSlot.tsx
user: login: carma12
body: I guess here you want to specify that a given `extensionPointId` can have data types either `ExtensionPointId` (with `id`, `displayName` and `description`) or a given `string` (just the `id`?). If this is the case, you can define the `ExtensionPointId` with some of its parameters as optional (with `?:` ):
```
const dashboardContent: ExtensionPoint = {
  id: "dashboardContent",
  displayName?: "Dashboard Content", // <-- Optional parameter 
  description?: "Add content to the main dashboard", // <--- Optional parameter
};
```

This way, you can keep it as:
```
extensionPointId: ExtensionPointId; // <-- Removing the ' | string' 
``` 
With this solution, you might need to adjust some code, but it will be leaner and easy to read.
created_at: 2025-05-05T07:25:34Z
author_association: COLLABORATOR
diff_hunk: @@ -17,13 +19,29 @@ interface ExtensionSlotProps {
 /**
  * Component that renders all registered extensions for a specific extension point
  */
-export const ExtensionSlot: React.FC<ExtensionSlotProps> = ({
-  extensionPointId,
-  context = {},
-}) => {
-  const extensions = pluginRegistry.getExtensions(extensionPointId);
+export const ExtensionSlot = (props: ExtensionSlotProps) => {
+  const { extensionPointId, context = {} } = props;
+
+  // Handle both string IDs and ExtensionPoint objects
+  const effectiveExtensionPointId =
+    typeof extensionPointId === "string"
+      ? extensionPointsMap[
+          extensionPointId as keyof typeof extensionPointsMap
+        ] || extensionPointId
+      : extensionPointId;
+
+  const extensions = pluginRegistry.getExtensions(
+    effectiveExtensionPointId as ExtensionPointId
+  );
 
   if (extensions.length === 0) {
+    console.debug(
+      `No extensions found for extension point: ${
+        typeof extensionPointId === "string"
+          ? extensionPointId
+          : extensionPointId.id
path: src/core/plugins/ExtensionSlot.tsx
user: login: carma12
body: You are using this expression in multiple parts of the code, so maybe you can define a helper function that contains the same logic:
``` 
const defineExtensionPointType = (type: unknown) => {
   return typeof extensionPointId === "string"
          ? extensionPointId
          : extensionPointId.id
}
```
And use it in the code like:
```
`No extensions found for extension point: ${defineExtensionPointType(extensionPointId) }`
``` 
created_at: 2025-05-05T07:31:51Z
author_association: COLLABORATOR
diff_hunk: @@ -33,7 +51,11 @@ export const ExtensionSlot: React.FC<ExtensionSlotProps> = ({
         const { component: Component } = extension;
         return (
           <Component
-            key={`${extensionPointId}-${extension.extensionPointId}-${index}`}
+            key={`${
+              typeof extensionPointId === "string"
path: src/core/plugins/ExtensionSlot.tsx
user: login: carma12
body: The helper function can also be applied here.
created_at: 2025-05-05T07:32:31Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,106 @@
+import React, { useState, useEffect } from "react";
+import { FormGroup, Radio } from "@patternfly/react-core";
+
+interface UserStatusFieldProps {
+  value?: string;
+  onChange?: (value: any) => void;
+  isReadOnly?: boolean;
+  userId?: string;
+  user?: any;
+}
+
+/**
+ * Component that renders a user status radio field for the user edit form
+ */
+const UserStatusField = (props: UserStatusFieldProps) => {
+  const {
+    value: initialValue = "active",
path: src/plugins/user-status/components/UserStatusField.tsx
user: login: carma12
body: In this case, _I think_ `UserStatusFieldProps` is not needed for the `value`, and `isReadOnly` parameters. You can just define them as `useEffect` hooks in the component itself:
```
const [userStatus, setUserStatus] = useState<
    "active" | "inactive" | "disabled"
  >("active");
const isReadOnly = false; // This is not a hook because in the example  it is always `false`
// ...
``` 
Only `user` and `onChange` will be sent via props as it is needed to process the user's data. 
created_at: 2025-05-05T07:53:39Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,106 @@
+import React, { useState, useEffect } from "react";
+import { FormGroup, Radio } from "@patternfly/react-core";
+
+interface UserStatusFieldProps {
+  value?: string;
+  onChange?: (value: any) => void;
+  isReadOnly?: boolean;
+  userId?: string;
+  user?: any;
+}
+
+/**
+ * Component that renders a user status radio field for the user edit form
+ */
+const UserStatusField = (props: UserStatusFieldProps) => {
+  const {
+    value: initialValue = "active",
+    onChange = () => {
+      // default onChange handler
+    },
+    isReadOnly = false,
+    user,
+  } = props;
+
+  // get initial value from user if available
+  const getCurrentStatus = () => {
+    if (
+      user &&
+      user.inetuserstatus &&
+      Array.isArray(user.inetuserstatus) &&
path: src/plugins/user-status/components/UserStatusField.tsx
user: login: carma12
body: `inetuserstatus` must be a plain string, so this line and the next one are not needed (I think). Otherwise, this must be parsed from the `rpcUserStatus` to return a string.
created_at: 2025-05-05T13:13:26Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,208 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { api, Command, getCommand, FindRPCResponse } from "./rpc";
+import { API_VERSION_BACKUP } from "../utils/utils";
+
+/**
+ * User Status-related endpoints: getUserStatus, updateUserStatus
+ *
+ * API commands:
+ * - user_show: https://freeipa.readthedocs.io/en/latest/api/user_show.html
+ *   Used to retrieve user data including inetuserstatus attribute
+ * - user_mod: https://freeipa.readthedocs.io/en/latest/api/user_mod.html
+ *   Used to update user data including inetuserstatus attribute
+ */
+
+// Define the user status data structure
+export interface UserStatus {
+  userId: string;
+  status: "active" | "inactive" | "disabled";
+}
+
+// Define payload types
+export interface UserStatusShowPayload {
+  userId: string;
+}
+
+export interface UserStatusUpdatePayload {
+  userId: string;
+  status: "active" | "inactive" | "disabled";
+}
+
+const extendedApi = api.injectEndpoints({
+  endpoints: (build) => ({
+    // Get user status
+    getUserStatus: build.query<UserStatus, UserStatusShowPayload>({
+      query: (payload) => {
+        return getCommand({
+          method: "user_show",
+          params: [
+            [payload.userId],
+            {
+              all: true,
+              rights: true,
+              version: API_VERSION_BACKUP,
+            },
+          ],
+        });
+      },
+      transformResponse: (response: any): UserStatus => {
+        // Handle case where response or response.result is null/undefined
+        if (!response || !response.result || !response.result.result) {
+          console.error("Invalid response format:", response);
+          return {
+            userId: "",
+            status: "active", // default status
+          };
+        }
+
+        return {
+          userId: response.result.result.uid?.[0] || "",
+          status: response.result.result.inetuserstatus?.[0] || "active",
path: src/services/rpcUserStatus.ts
user: login: carma12
body: As specified earlier, ideally `userId` and `status` must be returned as plain strings (if are not already). This will help to keep the code logic leaner and prevent unnecessary checks.
created_at: 2025-05-05T13:20:39Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,208 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { api, Command, getCommand, FindRPCResponse } from "./rpc";
+import { API_VERSION_BACKUP } from "../utils/utils";
+
+/**
+ * User Status-related endpoints: getUserStatus, updateUserStatus
+ *
+ * API commands:
+ * - user_show: https://freeipa.readthedocs.io/en/latest/api/user_show.html
+ *   Used to retrieve user data including inetuserstatus attribute
+ * - user_mod: https://freeipa.readthedocs.io/en/latest/api/user_mod.html
+ *   Used to update user data including inetuserstatus attribute
+ */
+
+// Define the user status data structure
+export interface UserStatus {
+  userId: string;
+  status: "active" | "inactive" | "disabled";
+}
+
+// Define payload types
+export interface UserStatusShowPayload {
+  userId: string;
+}
+
+export interface UserStatusUpdatePayload {
+  userId: string;
+  status: "active" | "inactive" | "disabled";
+}
+
+const extendedApi = api.injectEndpoints({
+  endpoints: (build) => ({
+    // Get user status
+    getUserStatus: build.query<UserStatus, UserStatusShowPayload>({
+      query: (payload) => {
+        return getCommand({
+          method: "user_show",
+          params: [
+            [payload.userId],
+            {
+              all: true,
+              rights: true,
+              version: API_VERSION_BACKUP,
+            },
+          ],
+        });
+      },
+      transformResponse: (response: any): UserStatus => {
+        // Handle case where response or response.result is null/undefined
+        if (!response || !response.result || !response.result.result) {
+          console.error("Invalid response format:", response);
+          return {
+            userId: "",
+            status: "active", // default status
+          };
+        }
+
+        return {
+          userId: response.result.result.uid?.[0] || "",
+          status: response.result.result.inetuserstatus?.[0] || "active",
+        };
+      },
+      providesTags: (result, error, arg) => ["FullUser"],
path: src/services/rpcUserStatus.ts
user: login: carma12
body: This is not needed for this example, unless you want to cache the data of that specific field. But if you want to add it, it will be just `providesTags: ["FullUser"],`.

More information about the `providingTags` flag: https://redux-toolkit.js.org/rtk-query/usage/automated-refetching#providing-tags
created_at: 2025-05-05T13:24:01Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,208 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { api, Command, getCommand, FindRPCResponse } from "./rpc";
+import { API_VERSION_BACKUP } from "../utils/utils";
+
+/**
+ * User Status-related endpoints: getUserStatus, updateUserStatus
+ *
+ * API commands:
+ * - user_show: https://freeipa.readthedocs.io/en/latest/api/user_show.html
+ *   Used to retrieve user data including inetuserstatus attribute
+ * - user_mod: https://freeipa.readthedocs.io/en/latest/api/user_mod.html
+ *   Used to update user data including inetuserstatus attribute
+ */
+
+// Define the user status data structure
+export interface UserStatus {
+  userId: string;
+  status: "active" | "inactive" | "disabled";
+}
+
+// Define payload types
+export interface UserStatusShowPayload {
+  userId: string;
+}
+
+export interface UserStatusUpdatePayload {
+  userId: string;
+  status: "active" | "inactive" | "disabled";
+}
+
+const extendedApi = api.injectEndpoints({
+  endpoints: (build) => ({
+    // Get user status
+    getUserStatus: build.query<UserStatus, UserStatusShowPayload>({
+      query: (payload) => {
+        return getCommand({
+          method: "user_show",
+          params: [
+            [payload.userId],
+            {
+              all: true,
+              rights: true,
+              version: API_VERSION_BACKUP,
+            },
+          ],
+        });
+      },
+      transformResponse: (response: any): UserStatus => {
+        // Handle case where response or response.result is null/undefined
+        if (!response || !response.result || !response.result.result) {
+          console.error("Invalid response format:", response);
+          return {
+            userId: "",
+            status: "active", // default status
+          };
+        }
+
+        return {
+          userId: response.result.result.uid?.[0] || "",
+          status: response.result.result.inetuserstatus?.[0] || "active",
+        };
+      },
+      providesTags: (result, error, arg) => ["FullUser"],
+    }),
+
+    // Update user status
+    updateUserStatus: build.mutation<UserStatus, UserStatusUpdatePayload>({
+      query: (payload) => {
+        return getCommand({
+          method: "user_mod",
+          params: [
+            [payload.userId],
+            {
+              inetuserstatus: [payload.status],
+              version: API_VERSION_BACKUP,
+            },
+          ],
+        });
+      },
+      transformResponse: (response: any): UserStatus => {
+        // Handle case where response or response.result is null/undefined
+        if (!response || !response.result || !response.result.result) {
+          console.error("Invalid response format:", response);
+
+          // Return the input status if we can extract it from the error context
+          const context =
+            (response && response.error && response.error.context) || {};
+
+          return {
+            userId: context.userId || "",
+            status: context.status || "active",
+          };
+        }
+
+        return {
+          userId: response.result.result.uid?.[0] || "",
+          status: response.result.result.inetuserstatus?.[0] || "active",
path: src/services/rpcUserStatus.ts
user: login: carma12
body: As specified earlier, ideally userId and status must be returned as plain strings (if are not already). This will help to keep the code logic leaner and prevent unnecessary checks.
created_at: 2025-05-05T13:29:17Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,208 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { api, Command, getCommand, FindRPCResponse } from "./rpc";
+import { API_VERSION_BACKUP } from "../utils/utils";
+
+/**
+ * User Status-related endpoints: getUserStatus, updateUserStatus
+ *
+ * API commands:
+ * - user_show: https://freeipa.readthedocs.io/en/latest/api/user_show.html
+ *   Used to retrieve user data including inetuserstatus attribute
+ * - user_mod: https://freeipa.readthedocs.io/en/latest/api/user_mod.html
+ *   Used to update user data including inetuserstatus attribute
+ */
+
+// Define the user status data structure
+export interface UserStatus {
+  userId: string;
+  status: "active" | "inactive" | "disabled";
+}
+
+// Define payload types
+export interface UserStatusShowPayload {
+  userId: string;
+}
+
+export interface UserStatusUpdatePayload {
+  userId: string;
+  status: "active" | "inactive" | "disabled";
+}
+
+const extendedApi = api.injectEndpoints({
+  endpoints: (build) => ({
+    // Get user status
+    getUserStatus: build.query<UserStatus, UserStatusShowPayload>({
+      query: (payload) => {
+        return getCommand({
+          method: "user_show",
+          params: [
+            [payload.userId],
+            {
+              all: true,
+              rights: true,
+              version: API_VERSION_BACKUP,
+            },
+          ],
+        });
+      },
+      transformResponse: (response: any): UserStatus => {
+        // Handle case where response or response.result is null/undefined
+        if (!response || !response.result || !response.result.result) {
+          console.error("Invalid response format:", response);
+          return {
+            userId: "",
+            status: "active", // default status
+          };
+        }
+
+        return {
+          userId: response.result.result.uid?.[0] || "",
+          status: response.result.result.inetuserstatus?.[0] || "active",
+        };
+      },
+      providesTags: (result, error, arg) => ["FullUser"],
+    }),
+
+    // Update user status
+    updateUserStatus: build.mutation<UserStatus, UserStatusUpdatePayload>({
+      query: (payload) => {
+        return getCommand({
+          method: "user_mod",
+          params: [
+            [payload.userId],
+            {
+              inetuserstatus: [payload.status],
+              version: API_VERSION_BACKUP,
+            },
+          ],
+        });
+      },
+      transformResponse: (response: any): UserStatus => {
+        // Handle case where response or response.result is null/undefined
+        if (!response || !response.result || !response.result.result) {
+          console.error("Invalid response format:", response);
+
+          // Return the input status if we can extract it from the error context
+          const context =
+            (response && response.error && response.error.context) || {};
+
+          return {
+            userId: context.userId || "",
+            status: context.status || "active",
+          };
+        }
+
+        return {
+          userId: response.result.result.uid?.[0] || "",
+          status: response.result.result.inetuserstatus?.[0] || "active",
+        };
+      },
+      invalidatesTags: ["FullUser"],
+    }),
+  }),
+  overrideExisting: false,
+});
+
+// Export the generated hooks
+export const { useGetUserStatusQuery, useUpdateUserStatusMutation } =
+  extendedApi;
+
+// Stand-alone function for direct API access without hooks
+export const getUserStatus = async (userId: string): Promise<UserStatus> => {
+  try {
+    const response = await fetch("/ipa/session/json", {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+      },
+      body: JSON.stringify({
+        method: "user_show",
+        params: [
+          [userId],
+          {
+            all: true,
+            rights: true,
+            version: API_VERSION_BACKUP,
+          },
+        ],
+      }),
+    });
+
+    if (!response.ok) {
+      throw new Error(`Failed to get user status: ${response.statusText}`);
+    }
+
+    const data = await response.json();
+
+    // Handle case where data structure is unexpected
+    if (!data || !data.result || !data.result.result) {
+      console.error("Invalid API response structure:", data);
+      return {
+        userId,
+        status: "active", // default status
+      };
+    }
+
+    return {
+      userId: data.result.result.uid?.[0] || userId,
+      status: data.result.result.inetuserstatus?.[0] || "active",
+    };
+  } catch (error) {
+    console.error("Error in getUserStatus:", error);
+    return {
+      userId,
+      status: "active", // default to active on error
+    };
+  }
+};
path: src/services/rpcUserStatus.ts
user: login: carma12
body: I'm not sure what is the point of keeping this and the rest of standalone functions. It should be enough just using the `useGetUserStatusQuery` and `useUpdateUserStatusMutation` endpoints created above.
created_at: 2025-05-05T13:37:12Z
author_association: COLLABORATOR
diff_hunk: @@ -1,130 +1,132 @@
 import { PluginModule, ExtensionPointId, ExtensionComponent } from "./types";
 
 /**
- * Singleton registry that manages all plugins and their extensions
+ * Creates a plugin registry that manages all plugins and their extensions
  */
-class PluginRegistry {
-  private plugins: Map<string, PluginModule> = new Map();
-  private extensions: Map<string, ExtensionComponent[]> = new Map();
+const createPluginRegistry = () => {
+  // private state
+  const plugins: Map<string, PluginModule> = new Map();
+  const extensions: Map<string, ExtensionComponent[]> = new Map();
 
   /**
-   * Register a plugin and all its extensions
+   * Helper method to get a consistent string ID from an extension point
    */
-  registerPlugin(plugin: PluginModule): void {
-    if (this.plugins.has(plugin.id)) {
-      console.warn(`Plugin with ID "${plugin.id}" is already registered.`);
-      return;
+  const getExtensionPointIdString = (
+    extensionPointId: ExtensionPointId | string
+  ): string => {
+    if (typeof extensionPointId === "string") {
+      return extensionPointId;
     }
 
-    // register the plugin
-    this.plugins.set(plugin.id, plugin);
-
-    // register all extensions from this plugin
-    for (const extension of plugin.extensions) {
-      this.registerExtension(extension);
+    if (typeof extensionPointId === "object" && extensionPointId.id) {
+      return extensionPointId.id;
     }
 
-    // initialize plugin if needed
-    if (plugin.initialize) {
-      try {
-        plugin.initialize();
-      } catch (error) {
-        console.error(`Error initializing plugin "${plugin.id}":`, error);
-      }
-    }
-  }
+    // fallback for any unusual cases
+    return String(extensionPointId);
+  };
 
   /**
    * Register a single extension
    */
-  private registerExtension(extension: ExtensionComponent): void {
+  const registerExtension = (extension: ExtensionComponent): void => {
     const { extensionPointId } = extension;
 
-    // Get the extension point ID string
-    const extensionPointIdString =
-      this.getExtensionPointIdString(extensionPointId);
+    // get the extension point ID string
+    const extensionPointIdString = getExtensionPointIdString(extensionPointId);
 
-    if (!this.extensions.has(extensionPointIdString)) {
-      this.extensions.set(extensionPointIdString, []);
+    if (!extensions.has(extensionPointIdString)) {
+      extensions.set(extensionPointIdString, []);
     }
 
-    const extensions = this.extensions.get(
+    const currentExtensions = extensions.get(
       extensionPointIdString
     ) as ExtensionComponent[];
-    extensions.push(extension);
+    currentExtensions.push(extension);
 
     // sort by priority if provided (higher priority first)
-    extensions.sort((a, b) => (b.priority || 0) - (a.priority || 0));
-  }
-
-  /**
-   * Get all extensions for a specific extension point
-   */
-  getExtensions(
-    extensionPointId: ExtensionPointId | string
-  ): ExtensionComponent[] {
-    const extensionPointIdString =
-      this.getExtensionPointIdString(extensionPointId);
-    return this.extensions.get(extensionPointIdString) || [];
-  }
-
-  /**
-   * Helper method to get a consistent string ID from an extension point
-   */
-  private getExtensionPointIdString(
-    extensionPointId: ExtensionPointId | string
-  ): string {
-    if (typeof extensionPointId === "string") {
-      return extensionPointId;
-    }
-
-    if (typeof extensionPointId === "object" && extensionPointId.id) {
-      return extensionPointId.id;
-    }
-
-    // Fallback for any unusual cases
-    return String(extensionPointId);
-  }
+    currentExtensions.sort((a, b) => (b.priority || 0) - (a.priority || 0));
+  };
+
+  return {
+    /**
+     * Register a plugin and all its extensions
+     */
+    registerPlugin: (plugin: PluginModule): void => {
+      if (plugins.has(plugin.id)) {
+        console.warn(`Plugin with ID "${plugin.id}" is already registered.`);
+        return;
+      }
 
-  /**
-   * Get all registered plugins
-   */
-  getPlugins(): PluginModule[] {
-    return Array.from(this.plugins.values());
-  }
+      // register the plugin
+      plugins.set(plugin.id, plugin);
 
-  /**
-   * Get a specific plugin by ID
-   */
-  getPlugin(pluginId: string): PluginModule | undefined {
-    return this.plugins.get(pluginId);
-  }
-
-  /**
-   * Check if a plugin is registered
-   */
-  hasPlugin(pluginId: string): boolean {
-    return this.plugins.has(pluginId);
-  }
+      // register all extensions from this plugin
+      for (const extension of plugin.extensions) {
+        registerExtension(extension);
+      }
 
-  /**
-   * Cleanup all registered plugins
-   */
-  cleanup(): void {
-    for (const plugin of this.plugins.values()) {
-      if (plugin.cleanup) {
+      // initialize plugin if needed
+      if (plugin.initialize) {
         try {
-          plugin.cleanup();
+          plugin.initialize();
         } catch (error) {
-          console.error(`Error cleaning up plugin "${plugin.id}":`, error);
+          console.error(`Error initializing plugin "${plugin.id}":`, error);
+        }
+      }
+    },
+
+    /**
+     * Get all extensions for a specific extension point
+     */
+    getExtensions: (
+      extensionPointId: ExtensionPointId | string
+    ): ExtensionComponent[] => {
+      const extensionPointIdString =
+        getExtensionPointIdString(extensionPointId);
+      return extensions.get(extensionPointIdString) || [];
+    },
+
+    /**
+     * Get all registered plugins
+     */
+    getPlugins: (): PluginModule[] => {
+      return Array.from(plugins.values());
+    },
+
+    /**
+     * Get a specific plugin by ID
+     */
+    getPlugin: (pluginId: string): PluginModule | undefined => {
path: src/core/plugins/PluginRegistry.ts
user: login: carma12
body: Nit: I would rename it to `getPluginById`.
created_at: 2025-05-05T14:02:16Z
author_association: COLLABORATOR
diff_hunk: @@ -71,6 +72,8 @@ export const AppRoutes = ({ isInitialDataLoaded }): React.ReactElement => {
         <Routes>
           {userLoggedIn ? (
             <>
+              {/* Dashboard Route */}
+              <Route path="" element={<Dashboard />} />
path: src/navigation/AppRoutes.tsx
user: login: duzda
body: Actually having a dashboard for plugin overview sounds nice and we should explore that option in the future.
created_at: 2025-05-06T07:57:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,6 +15,11 @@ import "@patternfly/patternfly/utilities/Display/display.css";
 import "@patternfly/patternfly/utilities/Accessibility/accessibility.css";
 // Navigation
 import { URL_PREFIX } from "./navigation/NavRoutes";
+// Plugins
+import { registerAllPlugins } from "./plugins";
+
+// Register all plugins
+registerAllPlugins();
path: src/main.tsx
user: login: duzda
body: I believe this should await the first render or something like that, this way you can't really interact with rest of the dom in initialize.
created_at: 2025-05-06T07:59:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,46 @@
+import { PluginModule } from "src/core/plugins/types";
+import Greeting from "./components/Greeting";
+import GreetingPatternfly from "./components/GreetingPatternfly";
+import { dashboardContent } from "src/core/plugins/extensionPoints";
+
+/**
+ * Hello World plugin definition
+ * This plugin demonstrates adding components to the dashboard in two different styles:
+ * 1. Using HTML/CSS styling
+ * 2. Using PatternFly 5 components
+ */
+const helloWorldPlugin: PluginModule = {
+  id: "hello-world",
+  name: "Hello World",
+  version: "1.0.0",
+  description:
+    "A simple Hello World plugin for FreeIPA WebUI with multiple greeting styles",
+  author: "FreeIPA Team",
+
+  extensions: [
+    {
+      // HTML/CSS styled greeting
+      extensionPointId: dashboardContent,
+      component: Greeting as React.ComponentType,
path: src/plugins/hello-world/index.ts
user: login: duzda
body: I dislike the cast here, please remove it in all instances and see ExtensionComponent notes.
created_at: 2025-05-06T08:16:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,208 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { api, Command, getCommand, FindRPCResponse } from "./rpc";
+import { API_VERSION_BACKUP } from "../utils/utils";
+
+/**
+ * User Status-related endpoints: getUserStatus, updateUserStatus
+ *
+ * API commands:
+ * - user_show: https://freeipa.readthedocs.io/en/latest/api/user_show.html
+ *   Used to retrieve user data including inetuserstatus attribute
+ * - user_mod: https://freeipa.readthedocs.io/en/latest/api/user_mod.html
+ *   Used to update user data including inetuserstatus attribute
+ */
+
+// Define the user status data structure
+export interface UserStatus {
+  userId: string;
+  status: "active" | "inactive" | "disabled";
+}
+
+// Define payload types
+export interface UserStatusShowPayload {
+  userId: string;
+}
+
+export interface UserStatusUpdatePayload {
+  userId: string;
+  status: "active" | "inactive" | "disabled";
+}
+
+const extendedApi = api.injectEndpoints({
+  endpoints: (build) => ({
+    // Get user status
+    getUserStatus: build.query<UserStatus, UserStatusShowPayload>({
+      query: (payload) => {
+        return getCommand({
+          method: "user_show",
+          params: [
+            [payload.userId],
+            {
+              all: true,
+              rights: true,
+              version: API_VERSION_BACKUP,
+            },
+          ],
+        });
+      },
+      transformResponse: (response: any): UserStatus => {
+        // Handle case where response or response.result is null/undefined
+        if (!response || !response.result || !response.result.result) {
+          console.error("Invalid response format:", response);
+          return {
+            userId: "",
+            status: "active", // default status
+          };
+        }
+
+        return {
+          userId: response.result.result.uid?.[0] || "",
+          status: response.result.result.inetuserstatus?.[0] || "active",
+        };
+      },
+      providesTags: (result, error, arg) => ["FullUser"],
+    }),
+
+    // Update user status
+    updateUserStatus: build.mutation<UserStatus, UserStatusUpdatePayload>({
+      query: (payload) => {
+        return getCommand({
+          method: "user_mod",
+          params: [
+            [payload.userId],
+            {
+              inetuserstatus: [payload.status],
+              version: API_VERSION_BACKUP,
+            },
+          ],
+        });
+      },
+      transformResponse: (response: any): UserStatus => {
+        // Handle case where response or response.result is null/undefined
+        if (!response || !response.result || !response.result.result) {
+          console.error("Invalid response format:", response);
+
+          // Return the input status if we can extract it from the error context
+          const context =
+            (response && response.error && response.error.context) || {};
+
+          return {
+            userId: context.userId || "",
+            status: context.status || "active",
+          };
+        }
+
+        return {
+          userId: response.result.result.uid?.[0] || "",
+          status: response.result.result.inetuserstatus?.[0] || "active",
+        };
+      },
+      invalidatesTags: ["FullUser"],
+    }),
+  }),
+  overrideExisting: false,
+});
+
+// Export the generated hooks
+export const { useGetUserStatusQuery, useUpdateUserStatusMutation } =
+  extendedApi;
+
+// Stand-alone function for direct API access without hooks
+export const getUserStatus = async (userId: string): Promise<UserStatus> => {
+  try {
+    const response = await fetch("/ipa/session/json", {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+      },
+      body: JSON.stringify({
+        method: "user_show",
+        params: [
+          [userId],
+          {
+            all: true,
+            rights: true,
+            version: API_VERSION_BACKUP,
+          },
+        ],
+      }),
+    });
+
+    if (!response.ok) {
+      throw new Error(`Failed to get user status: ${response.statusText}`);
+    }
+
+    const data = await response.json();
+
+    // Handle case where data structure is unexpected
+    if (!data || !data.result || !data.result.result) {
+      console.error("Invalid API response structure:", data);
+      return {
+        userId,
+        status: "active", // default status
+      };
+    }
+
+    return {
+      userId: data.result.result.uid?.[0] || userId,
+      status: data.result.result.inetuserstatus?.[0] || "active",
+    };
+  } catch (error) {
+    console.error("Error in getUserStatus:", error);
+    return {
+      userId,
+      status: "active", // default to active on error
+    };
+  }
+};
path: src/services/rpcUserStatus.ts
user: login: duzda
body: As noted, please use Redux instead of plain javascript for fetching
created_at: 2025-05-06T08:20:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,75 @@
+import { Reducer } from "@reduxjs/toolkit";
+import React from "react";
+
+/**
+ * Represents an extension point in the application where plugins can inject content
+ */
+export interface ExtensionPoint {
+  id: string;
+  displayName: string;
+  description: string;
+}
+
+/**
+ * All available extension points in the application
+ */
+const dashboardContent: ExtensionPoint = {
+  id: "dashboardContent",
+  displayName: "Dashboard Content",
+  description: "Add content to the main dashboard",
+};
+
+const userEditForm: ExtensionPoint = {
+  id: "userEditForm",
+  displayName: "User Edit Form",
+  description: "Add fields to the user edit form",
+};
+
+const hostGroupsTableColumns: ExtensionPoint = {
+  id: "hostGroupsTableColumns",
+  displayName: "Host Groups Table Columns",
+  description: "Add columns to the Host Groups table",
+};
+
+/**
+ * Type for extension point IDs
+ */
+export type ExtensionPointId =
+  | typeof dashboardContent
+  | typeof userEditForm
+  | typeof hostGroupsTableColumns;
+
+/**
+ * Base plugin interface with metadata
+ */
+export interface Plugin {
+  id: string;
+  name: string;
+  version: string;
+  description: string;
+  author?: string;
+}
+
+/**
+ * Represents a component that extends a specific extension point
+ */
+export interface ExtensionComponent {
+  extensionPointId: ExtensionPointId;
+  component: React.ComponentType;
path: src/core/plugins/types.ts
user: login: duzda
body: Type is wrong, should be: `component: (...args: any[]) => React.JSX.Element;`
created_at: 2025-05-06T08:21:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,132 @@
+import { PluginModule, ExtensionPointId, ExtensionComponent } from "./types";
+
+/**
+ * Creates a plugin registry that manages all plugins and their extensions
+ */
+const createPluginRegistry = () => {
+  // private state
+  const plugins: Map<string, PluginModule> = new Map();
+  const extensions: Map<string, ExtensionComponent[]> = new Map();
+
+  /**
+   * Helper method to get a consistent string ID from an extension point
+   */
+  const getExtensionPointIdString = (
+    extensionPointId: ExtensionPointId | string
+  ): string => {
+    if (typeof extensionPointId === "string") {
+      return extensionPointId;
+    }
+
+    if (typeof extensionPointId === "object" && extensionPointId.id) {
+      return extensionPointId.id;
+    }
+
+    // fallback for any unusual cases
+    return String(extensionPointId);
+  };
+
+  /**
+   * Register a single extension
+   */
+  const registerExtension = (extension: ExtensionComponent): void => {
+    const { extensionPointId } = extension;
+
+    // get the extension point ID string
+    const extensionPointIdString = getExtensionPointIdString(extensionPointId);
+
+    if (!extensions.has(extensionPointIdString)) {
+      extensions.set(extensionPointIdString, []);
+    }
+
+    const currentExtensions = extensions.get(
+      extensionPointIdString
+    ) as ExtensionComponent[];
path: src/core/plugins/PluginRegistry.ts
user: login: duzda
body: You can use `const currentExtensions = extensions.get(extensionPointIdString)!`, note the exclamation mark, instead of casting.
created_at: 2025-05-06T08:23:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,132 @@
+import { PluginModule, ExtensionPointId, ExtensionComponent } from "./types";
+
+/**
+ * Creates a plugin registry that manages all plugins and their extensions
+ */
+const createPluginRegistry = () => {
+  // private state
+  const plugins: Map<string, PluginModule> = new Map();
+  const extensions: Map<string, ExtensionComponent[]> = new Map();
+
+  /**
+   * Helper method to get a consistent string ID from an extension point
+   */
+  const getExtensionPointIdString = (
+    extensionPointId: ExtensionPointId | string
+  ): string => {
+    if (typeof extensionPointId === "string") {
+      return extensionPointId;
+    }
+
+    if (typeof extensionPointId === "object" && extensionPointId.id) {
+      return extensionPointId.id;
+    }
+
+    // fallback for any unusual cases
+    return String(extensionPointId);
+  };
+
+  /**
+   * Register a single extension
+   */
+  const registerExtension = (extension: ExtensionComponent): void => {
+    const { extensionPointId } = extension;
+
+    // get the extension point ID string
+    const extensionPointIdString = getExtensionPointIdString(extensionPointId);
+
+    if (!extensions.has(extensionPointIdString)) {
+      extensions.set(extensionPointIdString, []);
path: src/core/plugins/PluginRegistry.ts
user: login: duzda
body: Should we let user know, that we failed to load the plugin?
created_at: 2025-05-06T08:29:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -17,13 +19,29 @@ interface ExtensionSlotProps {
 /**
  * Component that renders all registered extensions for a specific extension point
  */
-export const ExtensionSlot: React.FC<ExtensionSlotProps> = ({
-  extensionPointId,
-  context = {},
-}) => {
-  const extensions = pluginRegistry.getExtensions(extensionPointId);
+export const ExtensionSlot = (props: ExtensionSlotProps) => {
+  const { extensionPointId, context = {} } = props;
+
+  // Handle both string IDs and ExtensionPoint objects
+  const effectiveExtensionPointId =
+    typeof extensionPointId === "string"
+      ? extensionPointsMap[
+          extensionPointId as keyof typeof extensionPointsMap
+        ] || extensionPointId
+      : extensionPointId;
+
+  const extensions = pluginRegistry.getExtensions(
+    effectiveExtensionPointId as ExtensionPointId
+  );
 
   if (extensions.length === 0) {
+    console.debug(
+      `No extensions found for extension point: ${
+        typeof extensionPointId === "string"
+          ? extensionPointId
+          : extensionPointId.id
path: src/core/plugins/ExtensionSlot.tsx
user: login: b3lix
body: Not needed as this part of code was removed regarding change resolving this suggestion https://github.com/freeipa/freeipa-webui/pull/694#discussion_r2072958950 .
created_at: 2025-05-18T23:03:29Z
author_association: COLLABORATOR
diff_hunk: @@ -33,7 +51,11 @@ export const ExtensionSlot: React.FC<ExtensionSlotProps> = ({
         const { component: Component } = extension;
         return (
           <Component
-            key={`${extensionPointId}-${extension.extensionPointId}-${index}`}
+            key={`${
+              typeof extensionPointId === "string"
path: src/core/plugins/ExtensionSlot.tsx
user: login: b3lix
body: Not needed as this part of code was removed regarding change resolving this suggestion https://github.com/freeipa/freeipa-webui/pull/694#discussion_r2072958950 .
created_at: 2025-05-18T23:05:47Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,132 @@
+import { PluginModule, ExtensionPointId, ExtensionComponent } from "./types";
+
+/**
+ * Creates a plugin registry that manages all plugins and their extensions
+ */
+const createPluginRegistry = () => {
+  // private state
+  const plugins: Map<string, PluginModule> = new Map();
+  const extensions: Map<string, ExtensionComponent[]> = new Map();
+
+  /**
+   * Helper method to get a consistent string ID from an extension point
+   */
+  const getExtensionPointIdString = (
+    extensionPointId: ExtensionPointId | string
+  ): string => {
+    if (typeof extensionPointId === "string") {
+      return extensionPointId;
+    }
+
+    if (typeof extensionPointId === "object" && extensionPointId.id) {
+      return extensionPointId.id;
+    }
+
+    // fallback for any unusual cases
+    return String(extensionPointId);
+  };
+
+  /**
+   * Register a single extension
+   */
+  const registerExtension = (extension: ExtensionComponent): void => {
+    const { extensionPointId } = extension;
+
+    // get the extension point ID string
+    const extensionPointIdString = getExtensionPointIdString(extensionPointId);
+
+    if (!extensions.has(extensionPointIdString)) {
+      extensions.set(extensionPointIdString, []);
path: src/core/plugins/PluginRegistry.ts
user: login: b3lix
body: Maybe when we will have some error handling or logger service
created_at: 2025-05-19T21:55:12Z
author_association: COLLABORATOR

PR comments:
user: login: sourcery-ai[bot]
created_at: 2025-04-11T11:48:25Z
body: <!-- Generated by sourcery-ai[bot]: start review_guide -->

## Reviewer's Guide by Sourcery

This pull request introduces a plugin infrastructure to the FreeIPA WebUI, allowing developers to extend the UI's functionality without modifying the core codebase. It includes a PluginRegistry, ExtensionSlot component, and PluginModule type to manage plugins and their extensions. The pull request also adds extension points to the user settings and account settings pages, and introduces a basic dashboard page. A mock PluginApiService is provided to simulate API calls for plugins. Finally, the pull request includes a User Status plugin and a Hello World plugin as examples of how to use the new plugin infrastructure.

#### Sequence diagram for rendering extensions in ExtensionSlot

```mermaid
sequenceDiagram
    participant UI as User Interface
    participant ES as ExtensionSlot
    participant PR as PluginRegistry
    participant Plugin as Plugin Component

    UI->>ES: Renders ExtensionSlot
    ES->>PR: getExtensions(extensionPointId)
    PR->>PR: Retrieve registered extensions
    PR-->>ES: Returns list of extensions
    loop For each extension
        ES->>Plugin: Render Plugin Component
        Plugin-->>ES: Returns rendered component
    end
    ES-->>UI: Returns rendered extensions
```

### File-Level Changes

| Change | Details | Files |
| ------ | ------- | ----- |
| Introduces a plugin infrastructure using extension points to allow plugins to inject content into the UI. | <ul><li>Added `PluginRegistry` to manage plugins and their extensions.</li><li>Added `ExtensionSlot` component to render extensions for a specific extension point.</li><li>Added `PluginModule` and related types to define plugin structure.</li><li>Created `hello-world` plugin as an example.</li><li>Created `user-status` plugin to demonstrate user status functionality.</li><li>Registered all plugins in `src/plugins/index.ts`.</li></ul> | `src/components/UsersSections/UserSettings.tsx`<br/>`src/components/UsersSections/UsersAccountSettings.tsx`<br/>`src/navigation/AppRoutes.tsx`<br/>`src/services/PluginApiService.ts`<br/>`src/plugins/user-status/components/UserStatusWidget.tsx`<br/>`src/core/plugins/PluginRegistry.ts`<br/>`src/plugins/user-status/components/UserStatusField.tsx`<br/>`src/plugins/user-status/api/userStatusApiService.ts`<br/>`src/core/plugins/types.ts`<br/>`src/plugins/README.md`<br/>`src/plugins/hello-world/components/Greeting.tsx`<br/>`src/pages/Dashboard/Dashboard.tsx`<br/>`src/core/plugins/ExtensionSlot.tsx`<br/>`src/plugins/user-status/index.ts`<br/>`src/plugins/hello-world/index.ts`<br/>`src/plugins/index.ts`<br/>`src/core/plugins/index.ts`<br/>`src/services/index.ts` |
| Adds extension points to the user settings and account settings pages. | <ul><li>Added an extension point `userDetailsContent` to the user settings page to allow plugins to add content to the user details section.</li><li>Added an extension point `userEditForm` to the user account settings page to allow plugins to add fields to the user edit form.</li></ul> | `src/components/UsersSections/UserSettings.tsx`<br/>`src/components/UsersSections/UsersAccountSettings.tsx` |
| Introduces a basic dashboard page. | <ul><li>Added a route for the dashboard page.</li><li>Created a basic dashboard component with a system status card.</li><li>Added an extension point `dashboardContent` to the dashboard page to allow plugins to add content to the dashboard.</li></ul> | `src/navigation/AppRoutes.tsx`<br/>`src/pages/Dashboard/Dashboard.tsx` |
| Introduces a mock PluginApiService to simulate API calls for plugins. | <ul><li>Added `pluginApiService` with mock implementations for GET, PUT, POST, and DELETE requests.</li><li>Added helper functions for creating plugin commands and executing them.</li><li>Added types and interfaces for plugin API requests and responses.</li></ul> | `src/services/PluginApiService.ts`<br/>`src/services/index.ts` |
| Adds a User Status plugin that demonstrates the plugin infrastructure. | <ul><li>Created a `UserStatusWidget` component to display and edit the user's status.</li><li>Created a `UserStatusField` component to display and edit the user's status in the user edit form.</li><li>Created a mock API service for the User Status plugin.</li><li>Registered the User Status plugin with the plugin registry.</li></ul> | `src/plugins/user-status/components/UserStatusWidget.tsx`<br/>`src/plugins/user-status/components/UserStatusField.tsx`<br/>`src/plugins/user-status/api/userStatusApiService.ts`<br/>`src/plugins/user-status/index.ts` |
| Adds a Hello World plugin as a basic example. | <ul><li>Created a `Greeting` component to display a simple greeting.</li><li>Registered the Hello World plugin with the plugin registry.</li><li>Added the greeting component to the dashboard using the `dashboardContent` extension point.</li></ul> | `src/plugins/hello-world/components/Greeting.tsx`<br/>`src/plugins/hello-world/index.ts` |

---

<details>
<summary>Tips and commands</summary>

#### Interacting with Sourcery

- **Trigger a new review:** Comment `@sourcery-ai review` on the pull request.
- **Continue discussions:** Reply directly to Sourcery's review comments.
- **Generate a GitHub issue from a review comment:** Ask Sourcery to create an
  issue from a review comment by replying to it. You can also reply to a
  review comment with `@sourcery-ai issue` to create an issue from it.
- **Generate a pull request title:** Write `@sourcery-ai` anywhere in the pull
  request title to generate a title at any time. You can also comment
  `@sourcery-ai title` on the pull request to (re-)generate the title at any time.
- **Generate a pull request summary:** Write `@sourcery-ai summary` anywhere in
  the pull request body to generate a PR summary at any time exactly where you
  want it. You can also comment `@sourcery-ai summary` on the pull request to
  (re-)generate the summary at any time.
- **Generate reviewer's guide:** Comment `@sourcery-ai guide` on the pull
  request to (re-)generate the reviewer's guide at any time.
- **Resolve all Sourcery comments:** Comment `@sourcery-ai resolve` on the
  pull request to resolve all Sourcery comments. Useful if you've already
  addressed all the comments and don't want to see them anymore.
- **Dismiss all Sourcery reviews:** Comment `@sourcery-ai dismiss` on the pull
  request to dismiss all existing Sourcery reviews. Especially useful if you
  want to start fresh with a new review - don't forget to comment
  `@sourcery-ai review` to trigger a new review!
- **Generate a plan of action for an issue:** Comment `@sourcery-ai plan` on
  an issue to generate a plan of action for it.

#### Customizing Your Experience

Access your [dashboard](https://app.sourcery.ai) to:
- Enable or disable review features such as the Sourcery-generated pull request
  summary, the reviewer's guide, and others.
- Change the review language.
- Add, remove or edit custom review instructions.
- Adjust other review settings.

#### Getting Help

- [Contact our support team](mailto:support@sourcery.ai) for questions or feedback.
- Visit our [documentation](https://docs.sourcery.ai) for detailed guides and information.
- Keep in touch with the Sourcery team by following us on [X/Twitter](https://x.com/SourceryAI), [LinkedIn](https://www.linkedin.com/company/sourcery-ai/) or [GitHub](https://github.com/sourcery-ai).

</details>

<!-- Generated by sourcery-ai[bot]: end review_guide -->
author_association: NONE
user: login: b3lix
created_at: 2025-05-19T22:02:28Z
body: Rebased and applied code changes based on reviews

author_association: COLLABORATOR
user: login: b3lix
created_at: 2025-05-20T15:49:56Z
body: @duzda thank you for review. Please ack, so I can merge.
author_association: COLLABORATOR