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,AuditLogsandSettings, plusLoginPage.tsxandMFARequiredGate.tsx. Each directory exports its page fromindex.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
fetchwrappers and the TypeScript types of the REST API. Views never callfetchdirectly. 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>.tsholds the message types (generated byts-protowithonlyTypes=true), and<type>_ui.tsholds the field definitions the tree editors render (generated by the in-repo pluginserver/cmd/protoc-gen-bor-uifrom 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 examplevar(--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 inbor-theme.css(--bor--masthead--...,--bor--sidebar--...). Raw hex values belong only inbor-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-corehas 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.
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 throughonChange(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, throughonValidityChange. 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"andtreeitemmarkup,aria-expandedandaria-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,onRemoveand a per-rowremoveAriaLabel, and it adds the remove buttons and the add control. - The registry
POLICY_TYPES,POLICY_TYPE_LIST,policyTypesByCategory(),policyTypeLabel(),defaultContentForType()andvalidatePolicyContent()inpolicyTypes/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:
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:
Two consequences follow:
- The bundle is committed.
server/web/static/is tracked in git, andmake serverdoes not build the frontend; it embeds whatever is instatic/. When you change anything undersrc/, runnpm run buildand 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. Withnpm run devrunning, restartmake run-serverafter 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¶
npm run typecheckpasses.npm run lintpasses (accessibility).make check-frontend-tokenspasses.npm run buildwas run and the updatedserver/web/static/is part of the change.- 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.