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
| Component | Library |
|---|---|
| Framework | Preact 10 (React 19 API via preact/compat) |
| Language | TypeScript |
| UI components | PatternFly 6 (PF6 pf-t--global--* design tokens) |
| Router | React Router 7 (basename="/ui") |
| Build tool | Vite 6 |
| Dark mode | ThemeProvider / useTheme() (webui/src/theme.tsx) — persists to localStorage, respects prefers-color-scheme, falls back to server-configured default_theme |
| Toast notifications | ToastProvider / 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.
| Page | Route | Purpose |
|---|---|---|
| Login | /ui/auth/login | SPNEGO 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/consent | Displays 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/device | Two-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/error | Displays 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.
| Page | Route | Purpose |
|---|---|---|
| Clients | /ui/admin/clients | List, create, update, and delete OAuth2 client registrations. Includes a text search filter in the toolbar. |
| Scopes | /ui/admin/scopes | List and manage custom OAuth2 scope definitions. |
| Identity HBAC | /ui/admin/hbac | List, create, and manage Identity HBAC policy rules. |
| SPIFFE Workloads | /ui/admin/spiffe | List and manage SPIFFE workload registrations. |
| Users | /ui/admin/users | List users and view user details. |
| Groups | /ui/admin/groups | List groups and view group memberships. |
| Federated Accounts | /ui/admin/federated-accounts | List and manage federated account linkages. |
| IPA Upstream IdPs | /ui/admin/ipa-idps | List and configure IPA-sourced upstream IdP registrations. |
| Signing Keys | /ui/admin/keys | List signing keys; trigger key rotation via POST /api/admin/keys/rotate. |
| Cluster Nodes | /ui/admin/nodes | List 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/audit | Lists audit events from GET /api/admin/audit. Shows time, event type, subject, client, and detail columns. 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"andautoComplete="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/user/profile)
webui/src/user/ProfilePage.tsx lets authenticated users manage their own passkey credentials. It includes a branded masthead with optional logo (from info.logo_url), display_name, and a theme toggle button. Session information (authentication method, groups, roles) is displayed using PatternFly Table, Label, DescriptionList, and FormSelect components (no raw HTML elements). Success feedback uses toast notifications (useToast()) rather than inline alerts.
The page shows the current list of enrolled passkeys (name, registration date, and a delete button) and a “Register new passkey” button that drives the full WebAuthn attestation flow:
api.auth.passkeyRegisterBegin()→POST /api/auth/passkey/register-begin— obtains a challenge, RP ID, andexclude_credentialslist.navigator.credentials.create()— calls the browser/platform authenticator.api.auth.passkeyRegisterComplete(payload)→POST /api/auth/passkey/register-complete— submits the attestation response. The backend writes the credential to FreeIPA LDAP for IPA users, or to the localuser_passkeystable for non-IPA users.
Deleting a passkey sends api.auth.deletePasskey(id) (DELETE /api/auth/passkeys/{id}). The id is the base64url-encoded raw credential ID.
OTP tokens section
Below the passkeys section, ProfilePage.tsx renders an “OTP tokens” section. It fetches
the token list via api.auth.listOtpTokens() (GET /api/me/otp-tokens) and displays a
PatternFly Table with columns: label, type (TOTP/HOTP), algorithm, digits, period, and status.
Add OTP token: clicking “Add OTP token” calls api.auth.createOtpToken(params)
(POST /api/me/otp-tokens). The create form uses PatternFly FormSelect components for token type, algorithm, and digit count. On success, a PatternFly Modal opens showing:
- An inline SVG QR code generated by the
qrcodenpm package (SVG mode), wrapped in a<div style="padding: 16px; background: #fff">for scanner contrast. - The raw
otpauth://URI as copyable text. - A single “I’ve scanned it, close” button — the only way to dismiss the modal. This is intentional: the secret is shown once and not stored by ahdapa, so the dialog must not be dismissible by accident.
Delete OTP token: calls api.auth.deleteOtpToken(tokenId) (DELETE /api/me/otp-tokens/{token_id}).
The amrLabel() helper in the profile page now returns "Password + OTP" when the
session amr array contains "otp".
New dependency: qrcode ^1 was added to webui/package.json for SVG QR code generation.
API helpers (src/api.ts)
All fetch calls go through typed helpers in webui/src/api.ts. The pattern:
- On
401: redirect to/ui/auth/login?return_to=<current-path>. - On non-OK: throw an
Errorwith the response body as the message. - 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(offset = 0) // GET /api/admin/audit?offset=<n>
// 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}
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 PF5pf-v5-global--*namespace. EmptyStateacceptstitleTextdirectly as a prop. There is noEmptyStateHeadercomponent.EmptyStateacceptsstatus("danger","warning","success","info") andicon("search","plus") props for icons.LoginPageusesfooterListVariants(plural), notfooterListVariant.- Tables use
Table / Thead / Tbody / Tr / Th / Tdfrom@patternfly/react-table, not the deprecatedTableComposable.
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 nestedNavList.Breadcrumb/BreadcrumbItem— PF6 breadcrumb components witharia-current="page"on the active item.Pagination— page-size selector and prev/next navigation witharia-labelon 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. ClickableTraddstabIndex={0},role="link", andonKeyDownfor Enter/Space keyboard activation.Alert—roleattribute 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 witharia-current="page"for active state (not<button>).
Dark mode
Dark mode is implemented in webui/src/theme.tsx:
ThemeProviderwraps the entire app at themain.tsxlevel and providesuseTheme()context.- Toggling adds/removes the
pf-v6-theme-darkclass on<html>. - Preference is persisted to
localStorageunder the keyahdapa-theme. - The fallback chain:
localStorage>data-default-themeHTML attribute (server-injected) > OSprefers-color-scheme. - An inline
<script>inindex.htmlapplies 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, andDeviceVerifyPage. - 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:
ToastProviderwraps the app (insideBrowserRouter, outsideApp) and providesuseToast()context.addToast(variant, title, timeout?)pushes a new toast. Default timeout is 5000 ms.- Toasts render as a portal-based PF6
AlertGroupwitharia-live="polite"androle="status". - Each toast has a close button with
aria-label="Close". - Used in
ProfilePagefor passkey registration success feedback (replacing inlineAlert).
Adding a new page
- Create
webui/src/<section>/<PageName>.tsx. - Export a default function component.
- For admin pages: import and add a
<Route>inwebui/src/admin/AdminLayout.tsx. For top-level pages: add a<Route>inwebui/src/App.tsx. - For admin pages: add a
NavItemDefentry to the appropriate group in theNAV_GROUPSarray inwebui/src/admin/AdminLayout.tsx, specifying the required RBAC permission. - Add API helpers to
webui/src/api.tsif the page needs new backend calls. - Run
npm run buildto verify no TypeScript errors.