Skip to content

Frontend

The Bor web UI is a single-page application written in TypeScript with React and PatternFly 6. It lives in server/web/frontend/, is bundled by webpack into server/web/static/, and is embedded into the server binary. This page describes the source layout, routing, the API client, the PatternFly rules, the shared building blocks every view is expected to use, the accessibility requirements and the build.

Source layout

server/web/frontend/
  package.json           npm scripts: build, dev, lint, lint:fix, typecheck
  webpack.config.js      bundles src/main.tsx into ../static/bor.js
  tsconfig.json
  eslint.config.mjs      accessibility lint gate (eslint-plugin-jsx-a11y)
  src/
    main.tsx             entry point: BrowserRouter, ErrorBoundary, ToastProvider, Shell
    main.html            HTML template (html-webpack-plugin)
    Shell.tsx            login gate, masthead, sidebar navigation, routes
    bor-theme.css        design-token source: brand colors, focus rings, themes
    apiClient/           one module per REST resource (policiesApi.ts, nodesApi.ts, ...)
    components/          shared primitives (BorToolbar, LiveAlert, ConfirmModal, ...)
    views/               one directory per screen (Policies, Nodes, Settings, ...)
    policyTypes/
      registry.tsx       the single list of policy types and everything about them
    generated/proto/     generated from proto/policy/*.proto - never edit by hand
    icons/policyTypes/   flat policy-type marks (sources and licences in README.md)
    assets/              logo, favicon, background
    utils/               small helpers (stepUpPopup.ts)

The main directories:

views/
Screens, grouped by area: Dashboard, Policies, Nodes, NodeGroups, PolicyBindings, Compliance, DiskEncryption, AuditLogs and Settings, plus LoginPage.tsx and MFARequiredGate.tsx. Each directory exports its page from index.ts. views/Policies/ also holds the per-type policy editors, the shared editor building blocks (ListEditor.tsx, PolicyTreePanel.tsx, editors/) and the create wizard (wizard/).
components/
Primitives shared by all views. See Shared building blocks.
apiClient/
Thin fetch wrappers and the TypeScript types of the REST API. Views never call fetch directly.
policyTypes/registry.tsx
The hand-written list of policy types: label, category, wizard copy, icon, documentation link, default content and the client-side "can this be saved" check. See Adding a policy type.
generated/proto/
Output of make proto. <type>.ts holds the message types (generated by ts-proto with onlyTypes=true), and <type>_ui.ts holds the field definitions the tree editors render (generated by the in-repo plugin server/cmd/protoc-gen-bor-ui from the (bor.ui.field) annotations). The directory is excluded from linting and from the token check.

Routing

main.tsx mounts <Shell /> inside a BrowserRouter from react-router. Shell.tsx checks the session (GET /api/v1/auth/me), shows LoginPage when there is none, loads the user's permissions and edition features, and renders the PatternFly Page with masthead and sidebar. The routes are declared in one place:

<Routes>
  <Route index element={<DashboardPage />} />
  <Route path="/policies" element={<PoliciesPage />} />
  <Route path="/policies/new" element={<PolicyCreateWizard />} />
  <Route path="/policies/:policyId/edit" element={<PolicyEditorPage />} />
  <Route path="/nodes" element={<NodesPage />} />
  <Route path="/node-groups" element={<NodeGroupsPage />} />
  <Route path="/policy-bindings" element={<PolicyBindingsPage />} />
  <Route path="/compliance" element={<CompliancePage />} />
  <Route path="/disk-encryption" element={<DiskEncryptionPage />} />
  <Route path="/audit-logs" element={<AuditLogsPage />} />
  <Route path="/settings" element={<SettingsPage />} />
  {/* Unknown paths fall back to the dashboard. */}
  <Route path="*" element={<Navigate to="/" replace />} />
</Routes>

The page title and subtitle come from Shell through PageHeader, not from the individual views, and Shell also updates document.title on every navigation. When you add a screen, add its route, its navigation entry and its title and subtitle in Shell.tsx.

The server answers every non-API path with index.html (api.FrontendHandler), so deep links and a browser refresh on any route work. The bundle is loaded from / (publicPath: "/" in webpack.config.js) for the same reason.

API client and authentication

The web UI authenticates with cookies, never with a token held in JavaScript:

Cookie Set by Purpose
bor_session login, refresh httpOnly JWT access token, sent automatically
bor_refresh login, refresh httpOnly refresh token, used only by POST /api/v1/auth/refresh
bor_csrf login, refresh readable by JavaScript; double-submit CSRF token (Secure, SameSite=Strict)

authHeaders() in apiClient/authApi.ts returns the JSON content type and copies the bor_csrf cookie into the X-CSRF-Token header. The server's CSRFMiddleware (server/internal/api/middleware.go) rejects any POST, PUT, PATCH or DELETE under /api/ whose header does not match the cookie, so every mutating request must use authHeaders():

export async function createPolicy(req: CreatePolicyRequest): Promise<Policy> {
  return apiRequest<Policy>("/api/v1/policies/all", {
    method: "POST",
    headers: authHeaders(),
    body: JSON.stringify(req),
  });
}

Each apiClient module has a small apiRequest<T>() helper that sends the request with credentials: "same-origin", turns a non-2xx response into an Error carrying the server's error message, and parses the JSON body. The helper in authApi.ts additionally handles session expiry:

sequenceDiagram
    participant V as View component
    participant C as apiClient
    participant S as Server REST API
    V->>C: getPolicies()
    C->>S: GET /api/v1/... (cookies, X-CSRF-Token on writes)
    alt 401 Unauthorized
        C->>S: POST /api/v1/auth/refresh (bor_refresh cookie)
        S-->>C: new bor_session (and bor_csrf)
        C->>S: retry with fresh X-CSRF-Token
    end
    alt still 401
        C-->>V: throw Error
        C->>V: bor:session-expired event -> Shell shows login
    else ok
        S-->>C: JSON
        C-->>V: typed result
    end

On a 401 it calls POST /api/v1/auth/refresh once (concurrent refreshes are coalesced), re-reads the possibly new bor_csrf cookie and retries. If the retry still fails with 401, it dispatches the bor:session-expired event (apiClient/session.ts); Shell listens for it and returns the user to the login screen while keeping the current URL, so signing in again lands on the same page.

The permissions and edition features from /api/v1/auth/me are cached by apiClient/permissions.ts. Use hasPermission(), hasGlobalPermission() and hasFeature() to hide or disable controls the user cannot use. This is a convenience only: the server enforces every permission again.

PatternFly 6

The UI uses PatternFly 6: @patternfly/react-core, @patternfly/react-table, @patternfly/react-icons and the @patternfly/patternfly stylesheet, with React 19 and React Router (react-router). Check package.json for the exact versions.

  • PatternFly 6 class names use the pf-v6- prefix and its design tokens are CSS variables named --pf-t--..., for example var(--pf-t--global--text--color--subtle). The legacy PatternFly 5 variables (--pf-v5-...) do not resolve in PatternFly 6 and are banned.
  • Colors in component code always come from a --pf-t-- token or from a semantic property defined in bor-theme.css (--bor--masthead--..., --bor--sidebar--...). Raw hex values belong only in bor-theme.css, which is where the brand colors and focus rings are defined. See Tokens, not hex.
  • Prefer a PatternFly component to a hand-rolled one. When @patternfly/react-core has no component for a need, the project convention is to use PatternFly Elements (https://patternflyelements.org, npm @patternfly/elements), the web-component layer of the PatternFly ecosystem, which follows the same design tokens and themes and can be used as a JSX tag. It is not a current dependency, so adding it is a dependency change to discuss in the pull request. Do not pull in a different UI library.

Shared building blocks

There is one blessed way to do each common thing. Use the primitive before writing your own; reviewers will ask for it.

Need Use Location
Page title and subtitle PageHeader, rendered by Shell per route components/PageHeader.tsx
Search, filters and bulk actions above a list BorToolbar components/BorToolbar.tsx
Empty list or no filter results BorEmptyState components/BorEmptyState.tsx
Per-row actions PatternFly ActionsColumn kebab menu @patternfly/react-table
Inline status or form error LiveAlert components/LiveAlert.tsx
Success feedback that survives navigation useToast() components/ToastHost.tsx
Confirmation of destructive actions ConfirmModal components/ConfirmModal.tsx
Dashboard metric with drill-down StatCard components/StatCard.tsx
Repeatable add/remove list ListEditor<T> views/Policies/ListEditor.tsx
Master-detail tree in a policy editor PolicyTreePanel<T> views/Policies/PolicyTreePanel.tsx
Editing a policy's content PolicyContentEditor views/Policies/editors/PolicyContentEditor.tsx
Anything about a policy type the registry policyTypes/registry.tsx
Policy-type icon, or icon with label PolicyTypeIcon, PolicyTypeLabel components/
De-emphasized text .bor-text-secondary class bor-theme.css

BorToolbar

The search and Clear all filters shell for list pages. Filter chips and bulk-action controls go in as children.

<BorToolbar
  searchValue={search}
  onSearchChange={setSearch}
  searchAriaLabel="Search bindings"
  onClearAll={clearAllFilters}
>
  {/* optional ToolbarFilter, bulk-action Dropdown, ... */}
</BorToolbar>

BorEmptyState

Covers both "no data yet" and "no results for these filters" through the isEmptyData flag. Pass onClearFilters to offer a clear-filters action in the filtered case.

<BorEmptyState
  isEmptyData={policies.length === 0}
  itemsLabel="policies"
  emptyTitle="No policies configured"
  emptyBody='Click "Create a policy" to get started.'
  action={<Button variant="primary" onClick={handleCreate}>Create a policy</Button>}
  onClearFilters={clearAllFilters}
/>

Row actions

Per-row actions are a kebab menu in a trailing action cell, built from an IAction[]. The toggle always has an aria-label naming the row (Actions for policy ${p.name}). An action that cannot run right now stays visible with isAriaDisabled and a tooltip explaining why, instead of disappearing.

LiveAlert and useToast

Never render a bare PatternFly <Alert> for status. LiveAlert wraps it in a live region that is always present in the DOM: danger and warning use aria-live="assertive", success and info use aria-live="polite". It renders nothing while message is empty, so leave it mounted and toggle the message.

<TextInput
  id="role-name"
  value={name}
  onChange={(_e, v) => setName(v)}
  aria-invalid={error ? true : undefined}
  aria-describedby={error ? "err-create-role" : undefined}
/>
<LiveAlert id="err-create-role" message={error} variant="danger" isInline />

For "it worked" feedback, especially when the view is about to unmount (save and navigate), use a toast. A single ToastProvider in main.tsx backs all toasts; success and info toasts dismiss themselves after six seconds, danger and warning toasts stay until closed.

const { addToast } = useToast();
addToast({ variant: "success", title: "Binding created" });

ConfirmModal and type-to-confirm

Deletes and other hard-to-reverse actions are confirmed with ConfirmModal. Setting confirmPhrase turns on type-to-confirm: the confirm button stays disabled until the user has typed exactly that text (the resource name, or Yes for bulk actions). Resource deletes always use type-to-confirm. Choose a phrase the user can type; do not use symbols that are not on a keyboard.

<ConfirmModal
  isOpen={confirm !== null}
  title="Delete this policy?"
  isDanger
  confirmLabel="Delete"
  confirmPhrase={policy.name}
  confirmPhraseLabel={`Type "${policy.name}" to confirm`}
  isBusy={deleting}
  error={deleteError}
  onConfirm={doDelete}
  onCancel={() => setConfirm(null)}
>
  This permanently deletes the policy and all its bindings.
</ConfirmModal>

The modal shows error in a LiveAlert and disables both buttons while isBusy is set. The Policies, Nodes and Node Groups list pages still render their own delete dialogs with the same type-to-confirm behavior; new code uses ConfirmModal. It is the client half of a pair: the server keeps its own guards (for example, it refuses to delete a policy that still has enabled bindings, or the last Super Admin).

StatCard

A dashboard metric. With href, the whole card becomes a link into a filtered list. Colors map to PatternFly status tokens.

<StatCard title="Released policies" value={releasedCount} color="green" href="/policies?state=released" />

Policy editor building blocks

PolicyContentEditor
The one seam between a policy's content string and the UI that edits it. Both the Configuration tab of the policy editor and the configuration step of the create wizard render it. It switches on the policy type; the contract is {contentRaw, onChange, isDisabled?, onValidityChange?}. Editors push every change up through onChange (content never lives only in editor state) and report a value that cannot be saved yet, such as a JSON field in the middle of an edit, through onValidityChange. Unknown types fall back to a raw JSON text area.
PolicyTreePanel<T>
The accessible master-detail tree used by the tree-based editors (Firefox, Thunderbird, Chrome, Edge, Dconf, Firewalld). It owns the role="tree" and treeitem markup, aria-expanded and aria-selected, the configured-key indicator and per-item removal. Selecting a key only previews it; it enters the content when edited or added.
ListEditor<T>
A repeatable list: you supply items, renderItem, onAdd, onRemove and a per-row removeAriaLabel, and it adds the remove buttons and the add control.
The registry
POLICY_TYPES, POLICY_TYPE_LIST, policyTypesByCategory(), policyTypeLabel(), defaultContentForType() and validatePolicyContent() in policyTypes/registry.tsx. The wizard tiles, the list's Type column and filter, the dashboard and the editor all read it. Never keep a local list of type strings.

Wizard pattern

Guided creation uses the PatternFly 6 Wizard in the page, not in a modal. views/Policies/wizard/PolicyCreateWizard.tsx is the reference: isVisitRequired, one concern per step, per-step footer={{ isNextDisabled }} validation, a last step named Review, a ConfirmModal on cancel when there is unsaved work, and a success EmptyState offering the next actions. "Pick one of N kinds" is a Gallery of selectable card tiles next to a description panel that follows the selection. Contextual help opens in an inline drawer beside the step (WizardHelpDrawer.tsx), not in a tooltip or a modal.

Tokens, not hex

color: "var(--pf-t--global--text--color--subtle)"
borderTop: "1px solid var(--pf-t--global--border--color--default)"

scripts/check-frontend-tokens.sh (run by make check-frontend-tokens, part of make lint and of CI) fails on any --pf-v5- variable under src/ and ratchets raw hex colors in *.ts and *.tsx files against scripts/frontend-hex-baseline.txt: the count of each file and hex value may go down but not up. bor-theme.css and src/generated/ are exempt. A fixed color that is genuinely needed is justified in review and then accepted into the baseline:

scripts/check-frontend-tokens.sh --update

The policy-type icons are single-color and inherit currentColor; color them by wrapping them in an element that sets a token, never with hex.

Accessibility

The web UI targets WCAG 2.2 Level AA. Every change must keep it there.

Rule How
Status messages are announced Use LiveAlert (inline) or useToast(), never a bare <Alert>
Spinners have a name <Spinner aria-label="Loading" /> on every spinner
Icon-only buttons have a name aria-label on every button or menu toggle without visible text
Form errors are linked to their input aria-invalid={error ? true : undefined} and aria-describedby={error ? "<id>" : undefined} on the input; the same id on the LiveAlert
Focus returns after dialogs PatternFly <Modal> restores focus to the trigger on close; do not pass disableFocusTrap. Only hand-rolled overlays need a manual ref.current?.focus()
Acronyms are expanded Wrap the first use per page in <abbr title="Multi-Factor Authentication">MFA</abbr>
Color is never the only signal Pair every colored status with text or an icon
Focus is visible Never remove or weaken focus rings; bor-theme.css provides rings of at least 2 px and 3:1 contrast in all themes
Tables have a name aria-label on every Table

Shell and bor-theme.css already provide the skip link (Skip to main content), the document title per screen, the light, dark and high-contrast themes, and support for prefers-reduced-motion and forced-colors mode. Keep new screens inside that frame.

eslint-plugin-jsx-a11y (recommended rules) runs over src/**/*.{ts,tsx} through npm run lint or make lint-frontend, and violations fail the command. jsx-a11y/no-autofocus is only a warning because the app deliberately moves focus into freshly opened modals and onto the login form. Automated checks do not replace a keyboard pass: every flow must work without a mouse.

Building

cd server/web/frontend
npm install            # or: npm ci
npm run build          # production bundle into ../static/
npm run dev            # development build in watch mode
npm run typecheck      # tsc --noEmit
npm run lint           # accessibility lint

make frontend runs npm install and npm run build.

webpack compiles TypeScript with babel-loader (@babel/preset-typescript and the automatic React runtime), inlines CSS with style-loader, emits images and fonts as hashed files, and writes bor.js, index.html and the assets to server/web/static/. server/web/web.go embeds that directory:

//go:embed static/*
var StaticFiles embed.FS

Two consequences follow:

  • The bundle is committed. server/web/static/ is tracked in git, and make server does not build the frontend; it embeds whatever is in static/. When you change anything under src/, run npm run build and commit the rebuilt bundle in the same change as the source.
  • The server must be rebuilt (or restarted with go run) to serve a new bundle. With npm run dev running, restart make run-server after a rebuild to see the change.

Typecheck

Babel only strips types, so the webpack build succeeds even with type errors. npm run typecheck (tsc --noEmit) is the real typecheck, and CI runs it before the build. Run it locally before you push.

Checklist for a frontend change

  1. npm run typecheck passes.
  2. npm run lint passes (accessibility).
  3. make check-frontend-tokens passes.
  4. npm run build was run and the updated server/web/static/ is part of the change.
  5. The flow works with the keyboard alone and in the dark theme.

See also Testing and linting and Writing documentation for screenshots of new screens.