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, 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" 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/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:

  1. api.auth.passkeyRegisterBegin()POST /api/auth/passkey/register-begin — obtains a challenge, RP ID, and exclude_credentials list.
  2. navigator.credentials.create() — calls the browser/platform authenticator.
  3. 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 local user_passkeys table 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 qrcode npm 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:

  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(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 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).

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.