Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

WebUI

The WebUI is a Preact single-page application served from AppState::config.webui.static_dir. It is built separately from the Rust server and is not embedded in the binary.

Stack

ComponentLibrary
FrameworkPreact 10 (React 19 API via preact/compat)
LanguageTypeScript
UI componentsPatternFly 6 (PF6 pf-t--global--* design tokens)
RouterReact Router 7 (basename="/ui")
Build toolVite 6
Dark modeThemeProvider / useTheme() (webui/src/theme.tsx) — persists to localStorage, respects prefers-color-scheme, falls back to server-configured default_theme
Toast notificationsToastProvider / useToast() (webui/src/toast.tsx) — portal-based PF6 AlertGroup

Building

cd webui
npm install
npm run build   # produces webui/dist/

The build output is a static dist/ directory with a single index.html entry point and hashed asset filenames. Point webui.static_dir in the server config to this directory.

Serving

The axum router mounts tower-http::ServeDir on /ui:

#![allow(unused)]
fn main() {
.nest_service(
    "/ui",
    ServeDir::new(&static_dir)
        .fallback(ServeFile::new(&index)),
)
}

The fallback to index.html enables client-side routing — any /ui/** path that does not match a static file returns index.html, and React Router (running on Preact via preact/compat) handles the route client-side.

Pages

User-facing auth pages (/ui/auth/)

These pages are shown to end users during OAuth2 flows. They do not require an existing session to load (the server delivers the SPA HTML to unauthenticated browsers), but all API calls they make will return 401 if the user is not logged in. All auth pages include a theme toggle (moon/sun icon) and support the configured logo_url and display_name from GET /api/auth/info.

PageRoutePurpose
Login/ui/auth/loginSPNEGO attempts automatically; falls back to username/password form. An OTP stage is also available (see below). On success, redirects to return_to query parameter. Displays the configured logo and a theme toggle button.
Consent/ui/auth/consentDisplays client name and requested scopes (fetched from GET /api/auth/consent). Allow/Deny buttons call POST /api/auth/consent. Includes a branded masthead with logo, display_name, and theme toggle.
Device verification/ui/auth/deviceTwo-step flow: user enters the device user code (or it is pre-filled from the user_code URL parameter), then sees a consent screen with the client name and scope. Includes a branded masthead with logo, display_name, and theme toggle.
Error/ui/auth/errorDisplays OAuth2 error codes (access_denied, invalid_request, etc.) passed as query parameters.

Admin pages (/ui/admin/)

These pages are for IdP operators. All admin API calls require a valid session cookie (a user who has logged in through /ui/auth/login).

Sidebar navigation: the admin sidebar uses NavSection components to group pages into six domain areas: OAuth2, Access Control, Workloads, Identity, Federation, and Infrastructure. Each group is permission-filtered: groups with no visible items are hidden entirely.

Breadcrumb navigation: detail pages (client detail, user detail, group detail, etc.) show a PF6 Breadcrumb / BreadcrumbItem component in the masthead — for example “Clients > my-client-name”. The active breadcrumb item carries aria-current="page". Breadcrumb items are permission-filtered using the same RBAC rules as the sidebar navigation: a user without clients:read will not see the “Clients” breadcrumb item. The breadcrumb is rendered via useBreadcrumb() in AdminLayout.tsx and is injected into MastheadContent so it does not push the main content area down.

Branding: the admin masthead displays the configured logo_url (if set) and display_name from GET /api/auth/info. A theme toggle button (moon/sun icon) is shown in the masthead controls area.

PageRoutePurpose
Clients/ui/admin/clientsList, create, update, and delete OAuth2 client registrations. Includes a text search filter in the toolbar.
Scopes/ui/admin/scopesList and manage custom OAuth2 scope definitions.
Identity HBAC/ui/admin/hbacList, create, and manage Identity HBAC policy rules.
SPIFFE Workloads/ui/admin/spiffeList and manage SPIFFE workload registrations.
Users/ui/admin/usersList users and view user details.
Groups/ui/admin/groupsList groups and view group memberships.
Federated Accounts/ui/admin/federated-accountsList and manage federated account linkages.
IPA Upstream IdPs/ui/admin/ipa-idpsList and configure IPA-sourced upstream IdP registrations.
Signing Keys/ui/admin/keysList signing keys; trigger key rotation via POST /api/admin/keys/rotate.
Cluster Nodes/ui/admin/nodesList registered cluster nodes and runtime gossip statistics. Fetches GET /api/admin/nodes (CRDT node list, requires nodes:read) and GET /api/gossip/stats (runtime statistics, unauthenticated) concurrently and presents them together: CRDT counts, gossip round history, per-peer last-sync timestamps, and enrollment status.
Audit Log/ui/admin/auditLists audit events from GET /api/admin/audit. Shows time, event type, subject, principal, outcome, and detail columns. Events are sourced from the systemd journal namespace (or JSONL file fallback). Supports filtering by type, subject, and outcome. Service principal subjects (containing /, e.g. host/node1.example.com@REALM) are rendered as plain text; user subjects are rendered as links to the user detail page. Includes a Pagination component with a page-size selector.

OTP login stage

From the password stage, a link “Sign in with password + OTP code instead” switches the form to the OTP stage. The OTP stage shows:

  • Username — pre-filled and disabled (cannot be changed once entered).
  • Password — the user’s regular password.
  • OTP code — rendered with inputMode="numeric" and autoComplete="one-time-code"; non-digit characters are stripped on input.

Submitting the OTP form calls api.auth.loginOtp(username, password, otpCode) (POST /api/auth/otp). On success, the session cookie is set and the page redirects to return_to.

User profile page (/ui/me)

webui/src/user/ProfilePage.tsx provides self-service profile management for authenticated users. It includes a branded masthead with optional logo (from info.logo_url), display_name, and a theme toggle button.

Profile editing

For IPA users, the profile section displays all user attributes organized into groups (Identity, Contact, Address, Work, Account, Misc) with an “Edit” button. The page fetches GET /api/me/profile which returns all attributes with per-attribute access rights (attributelevelrights). Fields with write rights become editable inline when the user clicks “Edit”. Multi-valued attributes (email, phone) support add/remove. A diff is computed on save — only changed attributes are sent to PATCH /api/me/profile.

Admin-configurable visibility is driven by the [webui.self_service] config section, delivered via GET /api/auth/info. Admins can hide groups, hide individual attributes, suppress the “Other attributes” catch-all, and define custom field groups (e.g. for Fedora Account System extensions).

Field metadata lives in webui/src/user/profileFields.ts. The KNOWN_FIELDS array defines ~40 standard IPA attributes with labels, groups, and input types. Unknown IPA attributes render in an “Other attributes” group automatically.

Typed renderers: loginshell and preferredlanguage use FormSelect dropdowns with custom freeform option. manager and secretary use a typeahead user picker (UserPicker.tsx). Email, phone, and URL fields use HTML5 input types for browser-native validation.

See docs/src/developer/self-service-profile.md for the full design spec.

Password change

PasswordSection.tsx displays password expiration from krbpasswordexpiration and a “Change password” button that opens a modal calling POST /api/me/password. IPA password policy errors are displayed inline.

SSH public keys

SshKeysSection.tsx manages ipasshpubkey attributes. Add modal validates OpenSSH key format client-side. Add/remove go through PATCH /api/me/profile.

X.509 certificates

CertificatesSection.tsx manages usercertificate attributes. Add modal accepts PEM text paste or file upload. Add/remove go through PATCH /api/me/profile.

API helpers (src/api.ts)

All fetch calls go through typed helpers in webui/src/api.ts. The pattern:

  1. On 401: redirect to /ui/auth/login?return_to=<current-path>.
  2. On non-OK: throw an Error with the response body as the message.
  3. On success: return the parsed JSON.

All .catch() handlers use (e: unknown) => with instanceof Error checks for type-safe error messages. Silent .catch(() => {}) patterns have been replaced with console.warn() logging.

// Admin API
api.admin.listClients()                 // GET  /api/admin/clients
api.admin.listKeys()                    // GET  /api/admin/keys
api.admin.rotateKey()                   // POST /api/admin/keys/rotate
api.admin.listNodes()                   // GET  /api/admin/nodes
api.admin.listAuditEvents({type?, subject?, outcome?, from?, until?, limit?, offset?})
                                        // GET  /api/admin/audit?type=…&subject=…&…

// Gossip / cluster API (unauthenticated)
api.gossip.getStats()       // GET /api/gossip/stats → NodeStats

// Auth API (includes user self-service for OTP tokens)
api.auth.info()                                 // GET  /api/auth/info → AuthInfo
api.auth.login(username, password)              // POST /api/auth/login
api.auth.loginOtp(username, password, otpCode)  // POST /api/auth/otp
api.auth.listPasskeys()                         // GET  /api/auth/passkeys
api.auth.deletePasskey(id: string)              // DELETE /api/auth/passkeys/{id}
api.auth.passkeyBegin(username)                 // POST /api/auth/passkey/begin
api.auth.passkeyComplete(payload)               // POST /api/auth/passkey/complete
api.auth.passkeyRegisterBegin()                 // POST /api/auth/passkey/register-begin
api.auth.passkeyRegisterComplete(payload)       // POST /api/auth/passkey/register-complete
api.auth.listOtpTokens()                        // GET    /api/me/otp-tokens
api.auth.createOtpToken(params)                 // POST   /api/me/otp-tokens
api.auth.deleteOtpToken(tokenId: string)        // DELETE /api/me/otp-tokens/{token_id}

// Self-service profile API
api.me.profile()                                  // GET    /api/me/profile → UserProfileDetail
api.me.updateProfile(changes)                     // PATCH  /api/me/profile → UserProfileDetail
api.me.changePassword(currentPassword, newPassword)  // POST /api/me/password
api.me.searchUsers(query)                         // GET    /api/me/user-search?q=... → UserSearchResult[]

The StoredPasskey interface returned by listPasskeys:

interface StoredPasskey {
  id: string           // base64url-encoded raw credential ID (no padding)
  name: string | null  // user-supplied label; null for LDAP-sourced credentials
  registered_at: number // Unix timestamp; 0 for LDAP-sourced credentials
}

id is always a string (base64url) for both IPA LDAP users and local DB users. It is used directly as the path segment in deletePasskey.

PatternFly 6 notes

PatternFly 6 has breaking changes from PF5. Key differences that affect this codebase:

  • All CSS variables use PF6 pf-t--global--* design tokens, not the PF5 pf-v5-global--* namespace.
  • EmptyState accepts titleText directly as a prop. There is no EmptyStateHeader component.
  • EmptyState accepts status ("danger", "warning", "success", "info") and icon ("search", "plus") props for icons.
  • LoginPage uses footerListVariants (plural), not footerListVariant.
  • Tables use Table / Thead / Tbody / Tr / Th / Td from @patternfly/react-table, not the deprecated TableComposable.

Custom components in pf.tsx

The codebase defines its own PF6-compatible component library in webui/src/pf.tsx rather than importing @patternfly/react-core directly. Key components and patterns added by the redesign:

  • cx() — utility function replacing .filter(Boolean).join(' ') patterns for conditional CSS class concatenation.
  • NavSection — grouped sidebar navigation with a section title and nested NavList.
  • Breadcrumb / BreadcrumbItem — PF6 breadcrumb components with aria-current="page" on the active item.
  • Pagination — page-size selector and prev/next navigation with aria-label on all interactive elements.
  • FormSelect / FormSelectOption — PF6 <select> wrapper, replaces raw HTML <select>.
  • TextInput — PF6 <input> wrapper, replaces raw HTML <input>.
  • Table / Thead / Tbody / Tr / Th / Td — PF6 table components. Clickable Tr adds tabIndex={0}, role="link", and onKeyDown for Enter/Space keyboard activation.
  • Alertrole attribute is "alert" for danger/warning, "status" for success/info.
  • Modal — focus trapping: saves trigger element, focuses first focusable on open, traps Tab/Shift+Tab cycle, restores focus on close.
  • NavItem — uses <a> element with aria-current="page" for active state (not <button>).

Dark mode

Dark mode is implemented in webui/src/theme.tsx:

  • ThemeProvider wraps the entire app at the main.tsx level and provides useTheme() context.
  • Toggling adds/removes the pf-v6-theme-dark class on <html>.
  • Preference is persisted to localStorage under the key ahdapa-theme.
  • The fallback chain: localStorage > data-default-theme HTML attribute (server-injected) > OS prefers-color-scheme.
  • An inline <script> in index.html applies the theme class before React hydrates to prevent a flash of unstyled content.
  • A moon/sun toggle button is present on every page: AdminLayout, ProfilePage, LoginPage, ConsentPage, and DeviceVerifyPage.
  • The PurgeCSS safelist includes pf-v6-theme-* classes so dark mode styles are not tree-shaken.

Toast notifications

Toast notifications are implemented in webui/src/toast.tsx:

  • ToastProvider wraps the app (inside BrowserRouter, outside App) and provides useToast() context.
  • addToast(variant, title, timeout?) pushes a new toast. Default timeout is 5000 ms.
  • Toasts render as a portal-based PF6 AlertGroup with aria-live="polite" and role="status".
  • Each toast has a close button with aria-label="Close".
  • Used in ProfilePage for passkey registration success feedback (replacing inline Alert).

Plugin system

The WebUI supports runtime plugins that can inject components, replace existing components, add routes, and add navigation items — all without modifying the host application. Plugins are ES modules loaded dynamically from a JSON manifest at startup.

Architecture

flowchart TD
    A["main.tsx bootstrap"] --> B["exposeSharedDependencies()"]
    B --> C["loadPlugins()"]
    C --> D["fetch manifest, validate origins,<br/>import each plugin"]
    D --> E["pluginRegistry.registerPlugin(mod)"]
    E --> F["mod.register(api)<br/>plugin calls api.addComponent(), etc."]
    F --> G["pluginRegistry.runPhase('ready')"]
    G --> H["createRoot().render(App)"]
    A -- ".catch()" --> I["render fallback error page"]

Plugins run inside the host’s Preact tree. They share the host’s React, React Router, PatternFly components, API helpers, theme, and toast systems through window.__AHDAPA_SHARED__. This prevents duplicate framework instances and keeps plugin bundles small.

If bootstrap() fails (e.g. a plugin throws during loading), the application renders a fallback error page with a reload button instead of showing a blank white screen.

Key files

FilePurpose
src/plugins/types.tsTypeScript interfaces for plugin modules, API, configs, and registered entries
src/plugins/extensionPoints.tsNamed extension point constants (ahdapa/<domain>/<location>/v1)
src/plugins/PluginRegistry.tsSingleton registry storing extensions, replacements, routes, nav items
src/plugins/hooks.tsReact hooks: usePluginExtensions(), usePluginReplacement(), usePluginRoutes(), usePluginNavItems()
src/plugins/ExtensionSlot.tsxExtensionSlot (additive) and ReplaceableSlot (substitution) components with PluginErrorBoundary
src/plugins/shared.tsexposeSharedDependencies() — populates window.__AHDAPA_SHARED__
src/plugins/PluginLoader.tsloadPlugins() — fetches manifest, validates origins, imports plugins via fetch+Blob URL
src/plugins/index.tsBarrel re-export

Server-side plugin discovery

In production, the server scans the [webui.plugins] dir directory at startup. Each subdirectory must contain a plugin.json metadata file:

/etc/ahdapa/plugins/
├── corp-sso/
│   ├── plugin.json
│   └── plugin.js
└── community-debug/
    ├── plugin.json
    └── plugin.js

Each plugin.json declares the plugin’s identity:

{
  "id": "corp-sso",
  "name": "Corp SSO Plugin",
  "version": "1.0.0"
}

The server reads every subdirectory, evaluates the enable/disable glob patterns from the config against each plugin’s id, and generates a manifest served at GET /ui/plugins/manifest.json. Plugin static files (JS, CSS, assets) are served from the same directory at /ui/plugins/<subdir>/.

The discovery logic lives in src/routes/plugins.rs:

  • discover_plugins() scans the directory tree and builds the manifest at startup
  • is_plugin_enabled() evaluates enable > disable > default-enabled precedence
  • The generated manifest is cached in memory (no filesystem watches — restart to pick up new plugins)

See docs/src/user/configuration.md for the [webui.plugins] config reference.

Plugin manifest

The frontend fetches the manifest from /ui/plugins/manifest.json at bootstrap. In production the server generates it from discovered plugins. In dev mode the Vite middleware serves dev-plugins/manifest.json directly.

{
  "apiVersion": "1",
  "plugins": [
    {
      "id": "my-plugin",
      "name": "My Plugin",
      "version": "0.1.0",
      "entrypoint": "/ui/plugins/my-plugin/plugin.js",
      "enabled": true
    }
  ]
}

Plugin module format

Each plugin is an ES module with a default export implementing PluginModule:

export default {
  id: 'my-plugin',
  name: 'My Plugin',
  version: '0.1.0',
  register(api) {
    // Use api.addComponent(), api.replaceComponent(),
    // api.addRoute(), api.addNavigationItem(), api.onPhase()
  },
  cleanup() {
    // Optional: called on teardown
  },
}

Plugin API

The register() function receives a PluginAPI object with these methods:

MethodPurpose
addComponent(config)Inject a component into one or more extension points (additive)
replaceComponent(config)Replace the default component at an extension point (highest priority wins)
addRoute(config)Add a client-side route under /ui/admin/ (optional permission field gates access)
addNavigationItem(config)Add a sidebar navigation entry (merged into existing groups or a “Plugins” catch-all)
getAuthInfo()Access the current AuthInfo
getProfile()Access the current UserProfile
onPhase(phase, handler)Register a lifecycle callback ('init', 'ready', or 'cleanup')

Extension points

Extension points are named strings following the pattern ahdapa/<domain>/<location>/v1. Components use ExtensionSlot (renders all registered components for a point) or ReplaceableSlot (renders the highest-priority replacement, falling back to the default).

Extension pointLocationSlot typeContext
ahdapa/login/branding/v1Login page, above formExtension{ info }
ahdapa/login/pre-auth/v1Login page, before formExtension{ info }
ahdapa/login/form/v1Login page, form areaReplaceable{ info, providers, returnTo, oidcParams }
ahdapa/login/post-auth/v1Login page, after formExtension{ info }
ahdapa/login/auth-methods/v1Login page, alt methodsExtension{ info, returnTo, oidcParams }
ahdapa/login/footer/v1Login page, footerExtension{ info }
ahdapa/admin/clients/detail/sections/v1Client detail pageExtension{ client, permissions }
ahdapa/admin/users/detail/sections/v1User detail pageExtension{ user }
ahdapa/admin/groups/detail/sections/v1Group detail pageExtension{ group }
ahdapa/admin/hbac/detail/sections/v1HBAC detail pageExtension{ rule, permissions }
ahdapa/admin/spiffe/detail/sections/v1SPIFFE detail pageExtension{ entry, permissions }
ahdapa/profile/sections/v1Profile pageExtension{ profile, info }
ahdapa/profile/actions/v1Profile pageExtension{ profile, info }
ahdapa/layout/header-tools/v1Admin mastheadExtension{ profile, info }
ahdapa/admin/dashboard/content/v1Reserved for future useExtension

Error isolation

Each plugin component is wrapped in a PluginErrorBoundary. If a plugin component throws during rendering, the boundary catches the error and displays a PF6 danger Alert with the plugin name and a Retry button — the rest of the page continues to function. Clicking Retry resets the error state and re-renders the plugin component. For ReplaceableSlot, a crash in the replacement falls back to the original default component.

Plugin registration is also resilient: if register() throws or rejects, the plugin is removed from the registry and an error is logged. Other plugins continue loading normally.

Shared dependencies

Plugins access host framework singletons through window.__AHDAPA_SHARED__:

KeyContents
ReactPreact/compat (React API)
ReactDOMPreact/compat client
ReactRouterreact-router-dom exports
PFAll components from pf.tsx
ThemeuseTheme(), ThemeIcon
ToastuseToast()
APIapi, apiBase, hasPermission
_SDKExtension point constants (matches @ahdapa/plugin-sdk main entry)

Plugin SDK (@ahdapa/plugin-sdk)

The webui/plugin-sdk/ package provides TypeScript types, extension point constants, shared dependency re-exports, and a Vite build preset for plugin authors:

npm install @ahdapa/plugin-sdk
Import pathContents
@ahdapa/plugin-sdkPluginModule, PluginAPI, config interfaces, extension point constants
@ahdapa/plugin-sdk/pfPatternFly components (re-exported from window.__AHDAPA_SHARED__.PF)
@ahdapa/plugin-sdk/apiapi, apiBase, hasPermission
@ahdapa/plugin-sdk/themeuseTheme, ThemeIcon
@ahdapa/plugin-sdk/toastuseToast
@ahdapa/plugin-sdk/vitepluginViteConfig({ pluginName }) — Vite config preset that externalizes shared deps and rewrites imports to window.__AHDAPA_SHARED__ globals

The SDK proxy modules (/pf, /api, /theme, /toast) log a console.error if the host shared dependencies are not available when the plugin loads.

Dev-time support

During development (vite dev), a custom Vite middleware serves files from webui/dev-plugins/ at /ui/plugins/. Place your plugin directory and manifest there:

webui/dev-plugins/
├── manifest.json
├── hello-world/
│   └── plugin.js
└── demo/
    └── plugin.js

Pre-built plugins (plain .js files using window.__AHDAPA_SHARED__ directly) work without a build step. For TypeScript plugins, use the SDK’s pluginViteConfig() preset and build to a plugin.js output.

The dev middleware includes a path traversal guard (rejects paths that resolve outside dev-plugins/) and proper error handling for file reads.

Security

Plugin entrypoint URLs are validated against the application’s own origin before loading. Entrypoints pointing to a different origin are rejected. Plugins execute in the same origin as the host application — they have full access to cookies, DOM, and application state. Only load plugins from trusted sources.

Reactivity

The PluginRegistry uses a revision counter with useSyncExternalStore for reactivity. When a plugin calls addComponent(), replaceComponent(), addRoute(), or addNavigationItem(), the revision increments and all subscribed hooks (usePluginExtensions, etc.) re-render. This means plugins can register components at any time — including during lifecycle phases — and the UI updates automatically.

The registry caches sorted/sliced results from getExtensions(), getRoutes(), and getNavItems() — the cache is invalidated whenever the registry changes. This prevents unnecessary re-renders in downstream components that depend on referential equality.

Writing a plugin

A minimal plugin that adds a banner to the login page:

const React = window.__AHDAPA_SHARED__.React
const PF = window.__AHDAPA_SHARED__.PF
const h = React.createElement

function Banner() {
  return h(PF.Alert, {
    variant: 'info',
    title: 'Custom banner',
    isInline: true,
  }, 'Injected by a plugin.')
}

export default {
  id: 'my-banner',
  name: 'Banner Plugin',
  version: '0.1.0',
  register(api) {
    api.addComponent({
      targets: ['ahdapa/login/pre-auth/v1'],
      title: 'Custom Banner',
      description: 'Shows a banner on the login page',
      component: Banner,
    })
  },
}

A plugin that adds an admin page with a sidebar nav item:

const React = window.__AHDAPA_SHARED__.React
const PF = window.__AHDAPA_SHARED__.PF
const h = React.createElement

function MyPage() {
  return h(PF.PageSection, null,
    h(PF.Title, { headingLevel: 'h1' }, 'My Plugin Page'),
    h('p', null, 'This page is provided by a plugin.')
  )
}

export default {
  id: 'my-page',
  name: 'Page Plugin',
  version: '0.1.0',
  register(api) {
    api.addRoute({
      path: 'my-page',
      component: MyPage,
      title: 'My Page',
      permission: 'admin:read',  // optional — gates route access
    })
    api.addNavigationItem({
      label: 'My Page',
      path: 'my-page',
      group: 'Plugins',
    })
  },
}

Route paths and nav item paths should be relative (e.g. 'my-page', not '/ui/admin/my-page') — the admin layout prepends /admin/ automatically.

Adding a new page

  1. Create webui/src/<section>/<PageName>.tsx.
  2. Export a default function component.
  3. For admin pages: import and add a <Route> in webui/src/admin/AdminLayout.tsx. For top-level pages: add a <Route> in webui/src/App.tsx.
  4. For admin pages: add a NavItemDef entry to the appropriate group in the NAV_GROUPS array in webui/src/admin/AdminLayout.tsx, specifying the required RBAC permission.
  5. Add API helpers to webui/src/api.ts if the page needs new backend calls.
  6. Run npm run build to verify no TypeScript errors.