From ea30b564594f450f887068b1266568cd363b1186 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 2 May 2026 12:01:51 +0000 Subject: [PATCH] docs(web-ui): document SecurityStatusBar, uiConfig, and read-only UI mode from v1.0.2 - Routing table: add Notes column; document VITE_FLIGHTDECK_UI_READ_ONLY redirect behavior for #/actions - Component tree: add SecurityStatusBar to the diagram; note UI_READ_ONLY redirect - AppShell section: mention SecurityStatusBar mount point and Promote nav suppression - New uiConfig.ts section: UI_READ_ONLY boolean and clientMutationTokenConfigured() helper - New SecurityStatusBar section: all render conditions (read-only, fetch error, bearer mismatch, normal) - api.ts section: add HealthPayload type and fetchHealth() helper used by SecurityStatusBar - CSS table: expand fd-alert modifiers (--info, --warn); add fd-security-strip and fd-security-strip__msg - Environment variables table: add VITE_FLIGHTDECK_UI_READ_ONLY; expand token note with whitespace-trim behavior Co-authored-by: Gottam Sai Bharath --- docs/web-ui.md | 83 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 72 insertions(+), 11 deletions(-) diff --git a/docs/web-ui.md b/docs/web-ui.md index a5e05c1..568d1e1 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -14,15 +14,20 @@ The app uses **HashRouter** (`react-router-dom`) so all navigation stays within `index.html` that FastAPI's static file mount serves. URLs look like `http://127.0.0.1:8765/#/diff`. No server-side route matching is required. -| Hash path | Component | HTTP calls | -|-----------|-----------|-----------| -| `#/` | `OverviewPage` | `GET /v1/releases`, `GET /v1/promoted`, `GET /v1/actions` (parallel) | -| `#/diff` | `DiffPage` | `POST /v1/diff` | -| `#/actions` | `ActionsPage` | `POST /v1/promote` or `POST /v1/rollback` | -| `#/*` (any other) | — | Redirects to `#/` | +| Hash path | Component | HTTP calls | Notes | +|-----------|-----------|-----------|-------| +| `#/` | `OverviewPage` | `GET /v1/releases`, `GET /v1/promoted`, `GET /v1/actions` (parallel) | | +| `#/diff` | `DiffPage` | `POST /v1/diff` | | +| `#/actions` | `ActionsPage` | `POST /v1/promote` or `POST /v1/rollback` | Redirects to `#/` when `VITE_FLIGHTDECK_UI_READ_ONLY=true` | +| `#/*` (any other) | — | Redirects to `#/` | | `App.tsx` declares the route tree. `AppShell` is the layout wrapper rendered for all routes. +When `VITE_FLIGHTDECK_UI_READ_ONLY=true` is set at build time, the `#/actions` route +renders a `` rather than `ActionsPage`, and the nav link for +**Promote** is suppressed. The read-only mode is for demos and shared screens where +promote/rollback capability should be unavailable regardless of network placement. + --- ## Component tree @@ -31,9 +36,10 @@ The app uses **HashRouter** (`react-router-dom`) so all navigation stays within App (HashRouter) └── AppShell (layout: header + nav) └── TimelineRefreshProvider (context) + ├── SecurityStatusBar (below header, above main content) ├── OverviewPage (route: #/) ├── DiffPage (route: #/diff) - └── ActionsPage (route: #/actions) + └── ActionsPage (route: #/actions; redirects → #/ when UI_READ_ONLY) ``` --- @@ -42,10 +48,12 @@ App (HashRouter) Renders the top header with brand name and primary nav links, then an `` for the active page. Wraps the entire subtree in `TimelineRefreshProvider` so any descendant can -access the refresh context. +access the refresh context. Mounts `SecurityStatusBar` between the header and the main +content area. Nav links use `NavLink` from `react-router-dom` with an `fd-nav__link--active` class applied -when the route is active. +when the route is active. The **Promote** nav link is suppressed when `UI_READ_ONLY` is +`true` (see [`uiConfig.ts`](#uiconfigts-websrcuiconfigts) below). --- @@ -77,6 +85,40 @@ Throws if called outside `TimelineRefreshProvider`. --- +## `uiConfig.ts` (`web/src/uiConfig.ts`) + +Build-time configuration helpers read from `import.meta.env`: + +| Export | Type | Description | +|--------|------|-------------| +| `UI_READ_ONLY` | `boolean` | `true` when `VITE_FLIGHTDECK_UI_READ_ONLY === "true"`. Hides the Promote nav link, redirects `#/actions` to `#/`, and causes `SecurityStatusBar` to show a read-only banner instead of the auth status. | +| `clientMutationTokenConfigured()` | `() => boolean` | Returns `true` when `VITE_FLIGHTDECK_LOCAL_API_TOKEN` is set to a non-empty, non-whitespace string in the build env. Used by `SecurityStatusBar` to detect a mismatch between the server's bearer requirement and the client's token configuration. | + +--- + +## `SecurityStatusBar` (`web/src/components/SecurityStatusBar.tsx`) + +Mounted by `AppShell` between the header and the main content area. Fetches `GET /health` +on mount to read `mutation_auth` (`"bearer"` or `"loopback"`), then renders an info or +warning strip: + +| Condition | What is shown | +|-----------|---------------| +| `UI_READ_ONLY=true` | Info banner: "Read-only UI: navigation to promote and rollback is disabled." | +| `/health` fetch failed | Warning banner: "Could not load server security mode." (with error detail) | +| `mutation_auth === null` (unknown value) | Nothing (renders `null`) | +| Server `"bearer"` + client has no token | **Warning**: token mismatch — promote/rollback will be rejected until the UI token matches the server | +| Normal (no mismatch) | Info strip with two lines: server mode description and client token status | + +The component never displays the token value itself. It uses the `role="status"` ARIA role +for live-region accessibility. + +**Token mismatch detection:** when `mutation_auth` is `"bearer"` and +`clientMutationTokenConfigured()` is `false`, the strip warns that mutation requests will +fail. This is a configuration hint only — the server enforces the actual gate. + +--- + ## `OverviewPage` (`web/src/pages/OverviewPage.tsx`) Read-only dashboard. Renders three tables from `loadTimeline()` output: @@ -177,6 +219,12 @@ type ActionRow = { }; type TimelinePayload = { releases: ReleaseRow[]; promoted: PromotedRow[]; actions: ActionRow[]; }; + +type HealthPayload = { + status: string; + /** Present on current servers; "bearer" when FLIGHTDECK_LOCAL_API_TOKEN is set. */ + mutation_auth?: "bearer" | "loopback"; +}; ``` ### `fetchJson(path, init?): Promise` @@ -190,6 +238,11 @@ Thin wrapper around `fetch`: 3. On non-2xx, extracts `response.json().detail` (string or array) and throws `Error(detail)`. 4. On JSON parse failure, falls back to `{}` before checking `res.ok`. +### `fetchHealth(): Promise` + +Calls `fetchJson("/health")`. Used by `SecurityStatusBar` to discover the +server's mutation-auth mode (`"bearer"` or `"loopback"`) without exposing secret values. + ### `loadTimeline(): Promise` Fires three `GET` requests in parallel via `Promise.all`: @@ -270,7 +323,9 @@ All tokens are CSS custom properties on `:root`: | `fd-form-grid` | CSS Grid layout for form fields | | `fd-field` | Label + input pair; `--full` modifier spans both grid columns | | `fd-input` | Styled text input | -| `fd-alert` | Inline alert box; `--error` modifier | +| `fd-alert` | Inline alert box; `--error`, `--info`, `--warn` modifiers | +| `fd-security-strip` | Full-width strip below the header; wraps `SecurityStatusBar` output | +| `fd-security-strip__msg` | Message paragraph inside the security strip (zero margin) | | `fd-json-panel` | Collapsible JSON viewer container | | `fd-metric-grid` | Grid of metric cards for diff output | | `fd-metric` | Single metric card (label, baseline → candidate, delta) | @@ -285,13 +340,19 @@ All tokens are CSS custom properties on `:root`: | Variable | Where set | Effect | |----------|-----------|--------| -| `VITE_FLIGHTDECK_LOCAL_API_TOKEN` | `.env.local` or build env | Injected as `Authorization: Bearer …` on every `fetchJson` call | +| `VITE_FLIGHTDECK_LOCAL_API_TOKEN` | `.env.local` or build env | Injected as `Authorization: Bearer …` on every `fetchJson` call. Must match `FLIGHTDECK_LOCAL_API_TOKEN` on the server when the server token gate is active. | +| `VITE_FLIGHTDECK_UI_READ_ONLY` | `.env.local` or build env | Set to `"true"` to enable read-only mode: hides the Promote nav link, redirects `#/actions` to `#/`, and shows a read-only banner in `SecurityStatusBar`. Intended for demo / shared-screen deployments. | | `VITE_DEV_PROXY_TARGET` | `.env.local` | Overrides the Vite dev proxy target (default: `http://127.0.0.1:8765`) | These are **build-time** variables (`import.meta.env`). They are baked into the JavaScript bundle at build time; the production `static/` bundle does not read them from the server at runtime. +**Note on whitespace-only tokens:** `VITE_FLIGHTDECK_LOCAL_API_TOKEN` is treated as +unset (and no `Authorization` header is sent) when the value is empty or whitespace only. +The server applies the same whitespace trim to `FLIGHTDECK_LOCAL_API_TOKEN` — a +whitespace-only server token is treated as no token (`mutation_auth: "loopback"`). + --- ## Adding a new page