portal-mojo 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/README.md CHANGED
@@ -19,12 +19,14 @@ everything: run the showcase (`npm run dev:showcase`) → **Develop → Componen
19
19
  | [admin-monitoring.md](admin-monitoring.md) | Monitoring Admin registration, logs, and exact Redis-backed metrics-permission mutations |
20
20
  | [admin-metrics-explorer.md](admin-metrics-explorer.md) | Read-only recorded-metrics discovery, full-slug adapters, KPI/fan-out/scalar contracts, and cache identity |
21
21
  | [admin-settings.md](admin-settings.md) | Runtime settings admin — direction-aware atomic writes, write-only secrets, and global/group scope |
22
+ | [admin-fleet-configuration.md](admin-fleet-configuration.md) | Superuser structured configuration publishing, version restore and per-node application evidence |
22
23
  | [admin-bouncer.md](admin-bouncer.md) | Bouncer decisions, device investigations, and bot-signature administration with token-safe caching |
23
24
  | [admin-security-tickets.md](admin-security-tickets.md) | Ticket queue, KISS detail modal, notes, approvals, and Maestro sync contract |
24
25
  | [admin-incidents.md](admin-incidents.md) | Incident/event triage, forensic detail, merge semantics, and sanitized export boundary |
25
26
  | [admin-rules.md](admin-rules.md) | Always-inactive RuleSet authoring and the lossless, runtime-effective handler-chain builder |
26
27
  | [admin-members.md](admin-members.md) | Global membership table/detail, no-oracle admission, extensible grants, Group-detail composition, and unsupported-operation boundaries |
27
28
  | [admin-identity-users.md](admin-identity-users.md) | Global Users table and 14-section detail, system gates, action contracts, and one-time credential invariants |
29
+ | [admin-signin.md](admin-signin.md) | System sign-in page look & feel plus Google/Apple/GitHub provider credentials — one endpoint, changed-paths saves, write-only secrets |
28
30
  | [admin-devices-geoip.md](admin-devices-geoip.md) | Fleet device triage, the login-location world map with country drill-down, and the GeoIP dossier — expiry-aware enforcement, the never-cached raw provider record, and the backend traps the port encodes |
29
31
  | [admin-jobs.md](admin-jobs.md) | Jobs engine — runner fleet, per-channel queue depth, the segmented job table with armed cancel and republishing retry, the dry-run-first control plane, scheduled tasks with a global-only gate, and the thirteen backend corrections the port encodes |
30
32
  | [admin-network-security.md](admin-network-security.md) | Perimeter control — blocked IPs, the firewall log, kernel IP sets and the geofencing page (posture, rules, simulator, evidence, exemptions), with the DSL projection and every backend correction the port encodes |
@@ -0,0 +1,50 @@
1
+ # Fleet Configuration
2
+
3
+ The Operations → Fleet Configuration page uses `/api/account/admin/fleet`.
4
+ Only literal superusers see the section. An empty permission any-of array uses
5
+ the existing superuser bypass and refuses even the global `admin` grant; the
6
+ page checks `is_superuser === true` before fetching. Backend authority remains
7
+ mandatory, including rejection of diagnostic/key-backed sessions and fresh auth.
8
+
9
+ Applications register their schema in django-mojo. The page renders the returned
10
+ section, label, description, default, type and restart requirement; no application
11
+ setting names are hardcoded. Each field defaults to Keep current value. Set value
12
+ and Remove published override are explicit actions. Clearing falls back to base
13
+ configuration/default; it does not promise erasure of a bootstrap secret.
14
+
15
+ Secret inputs start blank, never hold masked placeholders, and blank replacement
16
+ preserves the existing secret. Values stay in the form until an imperative POST;
17
+ secret-bearing writes bypass TanStack MutationCache. Secret current/default fields
18
+ are removed before Query cache storage. The revision-bound publish/restore/apply
19
+ POSTs use `withFreshAuth` and show failures without claiming success. Schema/type
20
+ validation is repeated by the server; browser validation is only feedback.
21
+
22
+ Publication means published to S3, never applied. Apply now starts a fixed
23
+ asynchronous sync operation and polls its ID until terminal operation status. The
24
+ short job completes after dispatching sync; completed job status does not stop
25
+ polling. Each operation read freshly observes activation until healthy or timed
26
+ out; failed, expired or canceled jobs also stop polling. The expected
27
+ node roster includes missing nodes. Installed, restart requested, restarted and
28
+ healthy remain separate evidence. Only positive installed + restarted + health
29
+ results from every expected node with the explicit
30
+ `request_service_jobs_and_dependencies` health scope justify fleet completion.
31
+ Missing or older health scopes remain unconfirmed, including per-node health and
32
+ restart confirmation, and prompt a backend upgrade. Health covers the
33
+ request service and dependencies where enabled, plus the job engine and scheduler
34
+ with their startup-loaded revision. Worker-only nodes need no request service.
35
+ Draining an old engine is not proof that its replacement has loaded the revision.
36
+ Timed-out, failed, superseded and unknown results stay visible. Refresh status observes the
37
+ normal timer workflow even when Apply now was not used.
38
+
39
+ History contains only version IDs and timestamps. Restore publishes the selected
40
+ version as a new revision using the current expected revision. Secrets are neither
41
+ shown nor compared. Concurrent publication rejects the stale revision; refresh
42
+ before retrying. Settings edits are tied to their loaded publication revision.
43
+
44
+ Verify with `npm run verify:admin-fleet`, `npm run typecheck`, `npm run build`, and
45
+ browser checks of both themes. The Settings showcase includes a partial-convergence
46
+ example. Mock Portal uses representative registered settings and an offline node.
47
+
48
+ Django receives this page only after a canonical Portal artifact is built and
49
+ vendored; follow `docs/admin-artifact.md` and the Django vendor tool. Ordinary
50
+ Vite build output is not the canonical artifact.
@@ -6,38 +6,117 @@ Import `PushPage`, its individual panels/pages, the canonical push models, API h
6
6
 
7
7
  The page has six possible tabs: Your stats, Global metrics, Devices, Deliveries, Templates, and Config. Stats are authenticated caller data. Every other tab is independently filtered by its exact permission clause, and only the active authorized panel mounts, so hidden/inactive surfaces issue no requests.
8
8
 
9
- `push_surface` is registered with `registerNonFilterParams` at module load. It survives table writes but never becomes a Django lookup or filter pill. Changing tabs replaces the flat URL params with only `push_surface`, intentionally clearing search, sort, paging, and filters that belong to the prior model.
9
+ `push_surface` is registered with `registerNonFilterParams` at module load. It survives table writes but never becomes a Django lookup or filter pill. Changing tabs retains only `push_surface` and the showcase host's `demo` selector, clearing search, sort, paging, and filters belonging to the prior model. Showcase registers `demo` as a non-filter param at boot, so table writes keep the selected demo without sending it as a model lookup.
10
10
 
11
11
  ## Exact permissions
12
12
 
13
- All UI clauses carry the `sys.` namespace and are ANY-of:
13
+ All UI clauses carry the `sys.` namespace. Alternatives within a clause are ANY-of:
14
14
 
15
15
  - Devices view: `view_devices | manage_devices | comms | owner | manage_users`; save: `manage_devices | comms | owner`.
16
16
  - Deliveries view: `view_notifications | manage_notifications | comms | owner | manage_users`; save: `manage_notifications | comms`.
17
17
  - Templates view: `manage_notifications | manage_groups | comms | owner | manage_users`; save: `manage_notifications | manage_groups | comms`.
18
18
  - Config view/save: `manage_push_config | manage_groups | comms`.
19
19
  - Config connection test: `manage_push_config | comms`.
20
+ - Device test: `send_notifications | comms` **AND** the Devices view clause above.
20
21
  - Global metrics: `view_metrics | metrics`, with `account="global"`.
21
22
 
22
23
  `sys.users` is deliberately absent: it is not in `RegisteredDevice.RestMeta.VIEW_PERMS`. The Stats endpoint requires authentication only and always scopes all five keys to `request.user`.
23
24
 
25
+ Device inspection or config management alone never enables a test send. The backend separately enforces global `send_notifications | comms` and visibility of the selected registration; invisible and missing devices both return 404. Config checks require global `manage_push_config | comms`, independently of config editing. Group/member grants, API keys, and key-backed sessions cannot satisfy these test-action gates.
26
+
24
27
  ## Projection and ownership boundaries
25
28
 
26
29
  Every model pins a `basic` or `default` graph, allowlists list params, and positively projects rows before Query or Mutation cache reconciliation. RegisteredDevice never retains `device_token`; Delivery never retains `platform_data`, and its arbitrary `data_payload` is not retained by the UI projection. Related users are reduced to id plus a short display label—never email, phone, metadata, or an expanded user graph. Template JSON objects accept only a bounded shallow scalar projection on reads.
27
30
 
31
+ The additional evidence retained by the UI is:
32
+
33
+ | Model / result | Retained fields and fallback |
34
+ |---|---|
35
+ | `PushDeviceRow` | `is_active` is boolean or null; an older backend with no field shows “Activity unknown”. |
36
+ | `PushConfigRow` | `fcm_project_id` (max 200 characters), `fcm_client_email` (max 254), and boolean/null `has_fcm_credentials`. Missing metadata is “Not reported” / “Presence not reported”; credential presence is not verification. |
37
+ | `PushDeliveryRow` | `push_outcome` allowlists `accepted`, `delivered`, `simulated`, `rejected`, `blocked`, `unknown`, `pending`, `sent`, and `failed`; otherwise null, with display falling back to stored `status`. |
38
+ | `PushTestConfig` | Positive safe-integer `id`, `name` (max 100), `group` (safe-integer id/name up to 120 characters, or explicit null for system default), `fcm_project_id` (max 200), `test_mode`, `is_active`, `has_fcm_credentials`. No service-account email or secret material. |
39
+ | `PushTestResult` | `success`, `outcome`, local `message`, allowlisted/null `error_code`, success-only `message_id` (max 500), positive safe-integer/null `delivery_id` and `device_id`, and projected/null `config`. |
40
+
41
+ Test results live in the open dialog, outside Query and Mutation caches. Provider messages and raw error bodies are replaced by local copy; unknown error codes are discarded. `PushReadiness` retains only `ready`, local `message`, `device_id`, and projected `config`.
42
+
28
43
  `PushDeviceModel` now lives canonically in `admin/messaging/push`. The former `admin/identity/users/models.ts` definition is a compatibility re-export of the same object, so User Detail and the global Push page share one endpoint definition and Query key.
29
44
 
30
45
  ## FCM credentials and actions
31
46
 
32
47
  FCM writes are imperative and cache-free. A service account textarea is an uncontrolled DOM ref and never prefills. Blank means untouched and is omitted. Replacement must parse to an object with `type="service_account"`, `project_id`, `client_email`, and a PEM `private_key`. Clear sends `null` only after explicit confirmation. Empty replacement is impossible, and server responses are sanitized before cache reconciliation.
33
48
 
34
- Connection testing calls only `POST /api/account/devices/push/config/<id>/test` with an empty body. The backend uses its dummy token to validate credentials. The portal never accepts a device token. It also does not expose `/send`, caller `/test`, delivery retry, device registration/unregistration, or any delete action.
49
+ ### Saved configuration check
50
+
51
+ `testPushConfigConnection(id)` calls `POST /api/account/devices/push/config/<id>/test` with `{}`. The backend authenticates with FCM and submits a validation-only message, including when `test_mode=true` or the selected config is inactive. It sends no device notification and does not activate the config. The portal never accepts a raw device token.
52
+
53
+ The adapter accepts success only when the envelope has not failed, `success === true`, `outcome === "validated"`, `validation_only === true`, and `message_id` matches `projects/<project>/messages/<reference>`. Missing/malformed verdicts, simulated legacy responses, and provider rejections cannot display a verified connection. Backend HTTP 400 failures retain safe diagnostic data; the adapter returns a failed `PushTestResult` for the dialog to display.
54
+
55
+ “Check FCM connection” lives in the config header menu. The result and check time remain visible in Overview and Connection for that saved record/revision. Checks lock duplicate checks, editing, and active-state changes while pending. Editing or changing active state clears the result; record revision, permission, caller, or mount changes invalidate late completions. This is evidence from the current view, not a persisted credential-verification flag.
56
+
57
+ ### One registered device
58
+
59
+ The device header's “Send test push” opens a focused dialog. The API helpers use the existing test endpoint:
60
+
61
+ | Helper | Request | Purpose |
62
+ |---|---|---|
63
+ | `fetchPushDeviceReadiness(42)` | `GET /api/account/devices/push/test?device_id=42` | Local readiness; no FCM traffic. |
64
+ | `sendPushDeviceTest(42, title, message)` | `POST /api/account/devices/push/test` with `{"device_id":42,"title":"Push test","message":"This is a test notification."}` | One real notification to the selected registration. |
65
+
66
+ `device_id` here is the numeric database `id`, not the app-provided string `device_id` used for registration. The portal never calls the caller-wide POST variant without a selector. The backend resolves the device owner's active org config, falling back to the active system default; the dialog shows the device, user, config ID/name, scope, and FCM project actually reported by readiness or the send result.
67
+
68
+ Readiness must explicitly match the requested device and include an active config with credentials present and simulation disabled. Inactive registration, push disabled, an opted-out `test` category, missing token/config/credentials, or simulation prevents sending. Readiness does not verify provider credentials or device reachability. Unknown/malformed readiness disables Send rather than guessing.
69
+
70
+ The dialog defaults to title `Push test` and message `This is a test notification.`; inputs must be nonblank and fit 200/1000 characters. A single pending guard prevents duplicate submissions and locks fields, Cancel, Escape, and backdrop dismissal during sending. Each dialog permits one completed attempt; reopen it to obtain fresh readiness before another. Permission loss hides stale results; settled requests still unlock dismissal. Every attempt invalidates device, delivery, and caller-stat queries in `finally`.
71
+
72
+ Device results require explicit `success === true`, `outcome === "accepted"`, a provider message reference, and the requested `device_id`. Handled backend failures may be HTTP 200 with `data.success=false`; inspect the projected result. A failed envelope cannot establish success. Uncertain transport/malformed responses become `unknown`, and known HTTP 400/401/403/404 refusals without structured evidence become blocked. There is no automatic resend.
73
+
74
+ The portal exposes no general `/send`, caller-wide test, delivery retry, device registration/unregistration, or delete action.
75
+
76
+ All four REST models have live `CAN_DELETE = false`; the mock returns 403 without removing or cascading anything. Templates/configs can be created and edited under their save clauses. Device record editing and delivery mutation remain absent from this page.
77
+
78
+ ## Outcomes and delivery evidence
79
+
80
+ Test dialogs keep a visible result label, local explanation, check time, and delivery record ID when available:
81
+
82
+ | Outcome | Meaning |
83
+ |---|---|
84
+ | `validated` | FCM connection verified; no notification delivered. |
85
+ | `accepted` | FCM accepted the notification; the operator must check the selected device for receipt. |
86
+ | `rejected` | FCM refused the request; show the safe diagnostic. |
87
+ | `blocked` | Readiness, credentials, authentication, or request refusal prevented a successful test. |
88
+ | `unknown` | Acceptance is uncertain. Check the device and delivery history before another test; do not label it failed or retry automatically. |
35
89
 
36
- All four REST models have live `CAN_DELETE = false`; the mock returns 403 without removing or cascading anything. Templates/configs can be created and edited under their save clauses. Devices and deliveries remain investigative here.
90
+ Delivery lists/details prefer `push_outcome` over `status`. `simulated` is explicitly shown as no FCM send, even though the stored status is `sent`. `unknown` remains pending; provider acceptance alone does not mark a record delivered. Older `sent` records are described as lacking acceptance/receipt evidence. Status filtering and sorting still use the stored status field, not the derived outcome. The caller-stat tile is “Recorded sent” because its count may include simulations.
37
91
 
38
92
  ## Modal lifecycle convention
39
93
 
40
94
  See [Admin modal policy](admin-modals.md) for header actions, content-only
41
95
  sections, supported lifecycle fields and retained irreversible exceptions.
42
- Config/template active-state controls live in the detail header. Config saves
43
- retain write-only credential handling, and test sends keep their separate gate.
96
+ Config/template active-state controls live in the detail header. Device details
97
+ use Overview/Preferences with active and push-state chips; delivery details use
98
+ Notification/Delivery with outcome explanations. Templates use
99
+ Overview/Notification/Variables; configs use Overview/Connection with scope,
100
+ send-mode, credential-presence, and service-account metadata.
101
+
102
+ Config/template editors keep one pending save guard and protect Escape/backdrop
103
+ dismissal as well as Cancel. Config saves retain write-only credential handling;
104
+ device test sends use their separate permission AND and dismissal lock.
105
+
106
+ ## Showcase and verification
107
+
108
+ Open `#/?demo=admin-push` in Showcase. Its banner explicitly identifies
109
+ local fixtures: connection checks and test pushes there never contact FCM or
110
+ deliver notifications. Fixtures exercise system/org config selection, disabled
111
+ push, simulation blockers, safe metadata, and a selected-device accepted result
112
+ with a delivery row and no delivered timestamp. Switching Push tabs preserves
113
+ `demo=admin-push`; model filtering never consumes that host selector.
114
+
115
+ `npm run verify:admin-push` runs both `scripts/verify-admin-push.mjs` (mock wire,
116
+ permissions, projections, credentials, and target selection) and
117
+ `scripts/verify-push-tests.mjs` (mounted dialog/adapters). The latter covers strict
118
+ provider verdicts, malformed/mismatched responses, revision/permission ownership,
119
+ persistent results, duplicate-send prevention, pending dismissal, and cache
120
+ isolation. JSDOM assertions do not prove native dialog layout or stacking;
121
+ complete browser verification in both themes and a narrow viewport, including
122
+ the device test, configuration check, and nested editors.
@@ -33,6 +33,16 @@ queries `/api/group`, and preserves that scope on save. New settings default to
33
33
  Global. Delete is intentionally absent because live `Setting.RestMeta` does not
34
34
  declare deletion.
35
35
 
36
+ ## Viewing values
37
+
38
+ JSON objects and arrays show a compact type/count summary in the list. Long or
39
+ multiline text shows a single-line preview, capped at 80 characters. Select the
40
+ row to open the full value in the existing detail
41
+ modal. JSON uses the shared `JsonBlock` viewer, expanded initially, with syntax
42
+ highlighting, Show/Hide, Copy, and a scrollable body. Other text preserves line
43
+ breaks in a scrollable area; invalid JSON remains plain text. Secret values stay
44
+ masked in both views.
45
+
36
46
  ## Atomic write contract
37
47
 
38
48
  Use `saveSettingAtomic()` / `buildSettingPayload()` rather than a generic
@@ -0,0 +1,73 @@
1
+ # Sign-in admin (#5547)
2
+
3
+ Import `SigninPage`, `SIGNIN_ADMIN_SECTION`, the permission clause and the API
4
+ helpers from `portal-mojo/admin` (or `portal-mojo/admin/identity`). The section
5
+ registers one global route, `/system/sign-in`, under **Identity & Access**,
6
+ gated by `SIGNIN_ADMIN_PERMISSIONS` = `['sys.manage_settings', 'sys.admin']`
7
+ (superusers pass). Admin global scope masks any outer product group.
8
+
9
+ The page has two parts:
10
+
11
+ - **Look & feel** — the SYSTEM hosted login/registration page: branding,
12
+ layout/appearance/accent color, hero panel, sign-in heading/copy/methods,
13
+ registration, custom CSS. It edits the same keys a group admin edits in the
14
+ group **Configure Auth** dialog; a group's own settings still override these.
15
+ - **Providers** — one card each for Google, Apple and GitHub: an Enabled
16
+ switch, a read-only callback URL with a Copy button, the credential fields,
17
+ a Ready / Needs setup badge, and a link to the provider's console.
18
+
19
+ KISS by design: trusted admins, no step-up auth, no confirmation beyond a
20
+ browser `confirm()` before clearing a stored value.
21
+
22
+ ## Wire contract
23
+
24
+ One endpoint, both methods, django-mojo envelope.
25
+
26
+ `GET /api/account/admin/signin` → `{schema_version, auth, editable, options, providers}`
27
+
28
+ - `auth` is `public_auth_config` of the resolved system config
29
+ (`{theme, login, registration}`).
30
+ - `editable` lists the flat dotted paths the POST accepts. The page renders
31
+ only fields whose path is listed, so the backend can narrow it.
32
+ - `options` carries the allowed tokens for methods, layouts, appearances,
33
+ hero image positions and passkey prompts. Legacy layouts `card` /
34
+ `fullscreen` display as `compact` / `branded-panel`; any other unknown select
35
+ value falls back to the first option with a `console.warn`.
36
+ - `providers[]`: `{name, label, enabled, ready, missing, callback_url,
37
+ console_url, help, fields[]}`; each field is `{key, label, secret,
38
+ multiline, configured, source, value, hint}`. `source` is `admin`,
39
+ `deployment` (shown as "Set in server config") or `none`. Secret fields
40
+ never carry `value`; `hint` (e.g. `…a1b2`) is shown when present.
41
+
42
+ `POST /api/account/admin/signin` returns the same full payload, which the page
43
+ writes straight into its query cache (`SIGNIN_QUERY_KEY`).
44
+
45
+ - Look & feel: `{auth: {"theme.app_title": "…", "login.methods": […]}}` —
46
+ only the paths whose value differs from the loaded payload, sent by the
47
+ explicit **Save** button. Password is locked on in the sign-in methods picker;
48
+ the server rejects a list without it (400).
49
+ - Provider: `{provider, values, enabled}`. In `values`, an omitted key or
50
+ `""` keeps the stored value and `null` clears it. The card's **Save** sends
51
+ only fields the admin typed into; **Clear…** (shown for `source: "admin"`)
52
+ confirms, then sends `null` at once. The **Enabled** switch saves
53
+ immediately with `{provider, enabled}`, which adds/removes the provider from
54
+ both `login.methods` and `registration.methods`.
55
+ - Errors are `{status: false, error}` (HTTP 400). The client rejects them; the
56
+ page shows the message inline and as a toast and keeps the unsaved edits.
57
+
58
+ Secrets are entered in password inputs (the Apple `.p8` key in a textarea)
59
+ and cleared from local state after a successful save.
60
+
61
+ ## Mock
62
+
63
+ `src/client/mock.ts` implements the endpoint: global `manage_settings` /
64
+ `admin` gate (401 anonymous, 403 otherwise), `editable`-only writes with the
65
+ option and password rules, and credential storage. Seed: Apple has Team ID and
66
+ Services ID stored in the Admin (missing Key ID and private key), Google has
67
+ nothing, GitHub is configured by the deployment. Saved look & feel lands in a
68
+ system override that `/api/auth/config` layers between the deployment config
69
+ and group overrides, so the public config reflects saves. An Admin-stored
70
+ credential wins over the deployment value; clearing it falls back to the
71
+ deployment value.
72
+
73
+ Verify with `npm run verify:admin-signin`.
package/docs/realtime.md CHANGED
@@ -19,6 +19,11 @@ disable this shared provider.
19
19
  `auth_success`. An auth error closes and suspends the connection until the
20
20
  token value changes.
21
21
  - Heartbeat is a `ping` every 20 seconds with a 10-second `pong` deadline.
22
+ A server-initiated `{type:'ping'}` (django-mojo 1.32+ sends one every 20
23
+ seconds and culls a socket silent for 90) is answered at once with
24
+ `{type:'pong'}` while ready, clears the pending `pong` deadline, and is never
25
+ dispatched to consumers. Answering from the message handler keeps the socket
26
+ alive while a background tab throttles the client's own ping timer.
22
27
  Reconnect uses capped jittered backoff. Pre-accept close code 4429 enforces at
23
28
  least 60 seconds, and focus/visibility cannot bypass that deadline.
24
29
  - Desired topics are refcounted and replay after authentication. Operations are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -2,6 +2,7 @@ import type { AdminSection } from '../core';
2
2
  import { GLOBAL_CREDENTIAL_PERMS } from '../credentials/models';
3
3
  import { MEMBER_READ_PERMISSIONS } from '../identity/members/models';
4
4
  import { USER_VIEW_PERMISSIONS } from '../identity/users/models';
5
+ import { SIGNIN_ADMIN_PERMISSIONS } from '../signin/api';
5
6
 
6
7
  export const USERS_ADMIN_SECTION: AdminSection = {
7
8
  id: 'users', basePath: '', title: 'Users', icon: 'bi-people', navigationGroup: 'identity-access', permissions: USER_VIEW_PERMISSIONS,
@@ -21,4 +22,9 @@ export const CREDENTIALS_ADMIN_SECTION: AdminSection = {
21
22
  ],
22
23
  };
23
24
 
24
- export const IDENTITY_ADMIN_SECTIONS = [USERS_ADMIN_SECTION, MEMBERS_ADMIN_SECTION, CREDENTIALS_ADMIN_SECTION] as const;
25
+ export const SIGNIN_ADMIN_SECTION: AdminSection = {
26
+ id: 'signin', basePath: 'system/sign-in', title: 'Sign-in', icon: 'bi-box-arrow-in-right', navigationGroup: 'identity-access', permissions: SIGNIN_ADMIN_PERMISSIONS,
27
+ routes: [{ path: '', loadComponent: () => import('../signin/SigninPage').then(({ SigninPage }) => ({ default: SigninPage })), permissions: SIGNIN_ADMIN_PERMISSIONS }],
28
+ };
29
+
30
+ export const IDENTITY_ADMIN_SECTIONS = [USERS_ADMIN_SECTION, MEMBERS_ADMIN_SECTION, CREDENTIALS_ADMIN_SECTION, SIGNIN_ADMIN_SECTION] as const;
@@ -6,6 +6,13 @@ export const SETTINGS_ADMIN_SECTION: AdminSection = {
6
6
  id: 'settings', title: 'Settings', icon: 'bi-gear', navigationGroup: 'operations', permissions: SETTINGS_PERMISSIONS,
7
7
  routes: [{ path: '', loadComponent: () => import('../settings/SettingsPage').then(({ SettingsPage }) => ({ default: SettingsPage })), permissions: SETTINGS_PERMISSIONS }],
8
8
  };
9
+ // Empty any-of permissions admit only the existing literal-superuser bypass.
10
+ // A global admin permission alone must not expose this configuration surface.
11
+ export const FLEET_CONFIGURATION_ADMIN_SECTION: AdminSection = {
12
+ id: 'fleet-configuration', title: 'Fleet Configuration', icon: 'bi-hdd-network',
13
+ navigationGroup: 'operations', permissions: [],
14
+ routes: [{ path: '', loadComponent: () => import('../fleet-configuration/FleetConfigurationPage').then(({ FleetConfigurationPage }) => ({ default: FleetConfigurationPage })) }],
15
+ };
9
16
  export const JOBS_ADMIN_SECTION: AdminSection = {
10
17
  id: 'jobs', title: 'Jobs', icon: 'bi-cpu', navigationGroup: 'operations', permissions: [...new Set([...JOBS_VIEW_PERMS, ...SCHEDULED_TASK_VIEW_PERMS])],
11
18
  routes: [
@@ -15,4 +22,4 @@ export const JOBS_ADMIN_SECTION: AdminSection = {
15
22
  { path: 'scheduled-tasks', label: 'Scheduled Tasks', loadComponent: () => import('../jobs/ScheduledTasksPage').then(({ ScheduledTasksPage }) => ({ default: ScheduledTasksPage })), permissions: SCHEDULED_TASK_VIEW_PERMS },
16
23
  ],
17
24
  };
18
- export const OPERATIONS_ADMIN_SECTIONS = [SETTINGS_ADMIN_SECTION, JOBS_ADMIN_SECTION] as const;
25
+ export const OPERATIONS_ADMIN_SECTIONS = [SETTINGS_ADMIN_SECTION, FLEET_CONFIGURATION_ADMIN_SECTION, JOBS_ADMIN_SECTION] as const;
@@ -0,0 +1,171 @@
1
+ import { useRef, useState } from 'react';
2
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import { useMe } from '../../client/runtime';
4
+ import { modal } from '../../ui';
5
+ import {
6
+ FLEET_ENDPOINT, FLEET_HEALTH_SCOPE, displayFleetValue, fleetChange, fleetIsHealthy, operationFinished,
7
+ readFleet, readFleetHistory, readFleetOperation, writeFleet,
8
+ type FleetChanges, type FleetEntry, type FleetOperation, type FleetPublication, type FleetReport, type FleetState,
9
+ } from './data';
10
+
11
+ function SettingInput({ entry, disabled }: { entry: FleetEntry; disabled: boolean }) {
12
+ const [mode, setMode] = useState('keep');
13
+ const current = entry.sensitive ? (entry.configured ? 'Configured (hidden)' : 'Not configured') : displayFleetValue(entry.current);
14
+ const initial = entry.sensitive ? '' : entry.value_type === 'string' ? String(entry.current ?? '')
15
+ : entry.value_type === 'boolean' ? String(entry.current === true)
16
+ : entry.current == null ? '' : JSON.stringify(entry.current);
17
+ const control = { name: `${entry.key}:value`, className: 'input', disabled, defaultValue: initial };
18
+ return <div className="panel panel-pad" style={{ minWidth: 0 }}>
19
+ <h3>{entry.label}</h3><p className="dim">{entry.description}</p>
20
+ <p style={{ overflowWrap: 'anywhere' }}><b>Current:</b> {current}</p>
21
+ {!entry.sensitive && <p className="dim">Default: {displayFleetValue(entry.default)}</p>}
22
+ <p><span className="badge">{entry.restart_required ? 'Restart required' : 'No restart required'}</span></p>
23
+ <label className="field"><span className="field-label">Proposed change for {entry.label}</span>
24
+ <select className="input" name={`${entry.key}:mode`} value={mode} disabled={disabled} onChange={(event) => setMode(event.target.value)}>
25
+ <option value="keep">Keep current value</option><option value="set">{entry.sensitive ? 'Replace secret' : 'Set value'}</option>
26
+ {entry.overridden && <option value="clear">Remove published override</option>}
27
+ </select>
28
+ </label>
29
+ {mode === 'set' && <label className="field"><span className="field-label">{entry.sensitive ? 'Secret replacement' : 'Proposed value'}</span>
30
+ {entry.value_type === 'boolean' ? <select {...control}><option value="true">True</option><option value="false">False</option></select>
31
+ : entry.value_type === 'list' || entry.value_type === 'object' ? <textarea {...control} rows={4} autoComplete="off" spellCheck={false} />
32
+ : <input {...control} type={entry.sensitive ? 'password' : entry.value_type === 'integer' ? 'number' : 'text'}
33
+ autoComplete="off" spellCheck={false} min={entry.min_value ?? undefined} max={entry.max_value ?? undefined} maxLength={entry.max_length} step={1} />}
34
+ <span className="field-help">{entry.sensitive ? 'Leave blank to preserve the existing secret. Secrets are never shown in history.'
35
+ : entry.value_type === 'list' || entry.value_type === 'object' ? `Enter a JSON ${entry.value_type}; at most ${entry.max_items ?? 100} entries.` : 'Validated again by the server before publication.'}</span>
36
+ </label>}
37
+ {mode === 'clear' && <p className="text-warn">Removes this published override; the node’s base configuration or application default will apply.</p>}
38
+ </div>;
39
+ }
40
+
41
+ function FleetEditor({ state, busy, onPublish }: { state: FleetState; busy: boolean; onPublish: (changes: FleetChanges) => Promise<void> }) {
42
+ const form = useRef<HTMLFormElement>(null);
43
+ const [error, setError] = useState('');
44
+ const sections = [...new Set(state.entries.map((entry) => entry.section))];
45
+ const submit = async (event: React.FormEvent) => {
46
+ event.preventDefault(); setError('');
47
+ try {
48
+ const fields = new FormData(form.current!);
49
+ const changes: FleetChanges = {};
50
+ for (const entry of state.entries) {
51
+ const change = fleetChange(entry, String(fields.get(`${entry.key}:mode`) ?? 'keep'), String(fields.get(`${entry.key}:value`) ?? ''));
52
+ if (change) changes[entry.key] = change;
53
+ }
54
+ if (!Object.keys(changes).length) { setError('Choose at least one change before publishing.'); return; }
55
+ await onPublish(changes);
56
+ } catch (reason) { setError(reason instanceof Error ? reason.message : 'Could not publish configuration'); }
57
+ };
58
+ return <form ref={form} onSubmit={submit}>
59
+ <fieldset disabled={busy} style={{ border: 0, padding: 0, margin: 0, minWidth: 0 }}>
60
+ {sections.map((section) => <section key={section} style={{ marginBottom: 20 }}><h2>{section}</h2>
61
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 320px), 1fr))', gap: 12 }}>
62
+ {state.entries.filter((entry) => entry.section === section).map((entry) => <SettingInput key={entry.key} entry={entry} disabled={busy} />)}
63
+ </div></section>)}
64
+ {!state.entries.length && <p>No settings have been registered and delegated for this application.</p>}
65
+ {error && <p className="form-alert" role="alert">{error}</p>}
66
+ <button className="btn btn-primary" type="submit" disabled={busy || !state.publish_configured || !state.entries.length}>Publish</button>
67
+ </fieldset>
68
+ </form>;
69
+ }
70
+
71
+ export function FleetNodeStatus({ report, revision }: { report: FleetReport; revision: string | null }) {
72
+ const healthy = fleetIsHealthy(report);
73
+ const jobsCovered = report.health_scope === FLEET_HEALTH_SCOPE;
74
+ return <section className="panel panel-pad" aria-label="Fleet convergence" style={{ marginTop: 20 }}>
75
+ <h2>Fleet convergence</h2><p role="status" className={healthy ? 'text-ok' : 'text-warn'}>
76
+ {healthy ? 'Applied and healthy on every expected node' : `Not confirmed healthy everywhere — ${report.status}`}</p>
77
+ <p className="dim" style={{ overflowWrap: 'anywhere' }}>Target revision: {revision ?? 'Not published'}{report.observed_at ? ` · Observed ${report.observed_at}` : ''}</p>
78
+ {report.error_code && <p className="form-alert">{report.error_code}</p>}
79
+ {!report.nodes.length ? <p>No node results are available. Publication alone does not establish fleet health.</p>
80
+ : <div className="tbl-scroll"><table className="tbl"><thead><tr><th>Node</th><th>Published</th><th>Downloaded / installed</th><th>Restart</th><th>Healthy</th><th>Result</th></tr></thead>
81
+ <tbody>{report.nodes.map((node) => <tr key={node.hostname}><td>{node.hostname}</td><td>{node.published ? 'Yes' : 'Unconfirmed'}</td>
82
+ <td>{node.installed ? 'Yes' : 'Unconfirmed'}</td><td>{jobsCovered && node.restarted ? 'Confirmed' : node.restart_requested ? 'Requested' : 'Unconfirmed'}</td>
83
+ <td>{jobsCovered && node.healthy && node.installed && node.restarted && !node.error_code ? 'Yes' : 'Unconfirmed'}</td><td>{node.error_code ?? (!jobsCovered && node.status === 'healthy' ? 'Scope unverified' : node.status)}</td></tr>)}</tbody></table></div>}
84
+ {!jobsCovered && <p className="text-warn">This report does not verify job engine and scheduler activation. Refresh after upgrading the backend to confirm fleet health.</p>}
85
+ <p className="dim">Health verifies the request service where enabled, plus the job engine and scheduler. Worker-only nodes do not require a request service. Offline or missing nodes remain unconfirmed.</p>
86
+ </section>;
87
+ }
88
+
89
+ function FleetContent({ userId }: { userId: number }) {
90
+ const queryClient = useQueryClient();
91
+ const [busy, setBusy] = useState(false);
92
+ const [error, setError] = useState('');
93
+ const [notice, setNotice] = useState('');
94
+ const [editorEpoch, setEditorEpoch] = useState(0);
95
+ const [operationId, setOperationId] = useState<string | null>(null);
96
+ const queryKey = [FLEET_ENDPOINT, userId];
97
+ const stateQuery = useQuery({ queryKey, queryFn: readFleet, refetchOnWindowFocus: false, retry: false });
98
+ const history = useQuery({ queryKey: [...queryKey, 'history'], queryFn: readFleetHistory, retry: false });
99
+ const operation = useQuery({ queryKey: [...queryKey, 'operation', operationId], enabled: operationId != null,
100
+ queryFn: () => readFleetOperation(operationId!), retry: false,
101
+ refetchInterval: (query) => query.state.error || (query.state.data && operationFinished(query.state.data)) ? false : 3000 });
102
+ const state = stateQuery.data;
103
+ const refresh = async () => { await Promise.all([stateQuery.refetch(), history.refetch(), ...(operationId ? [operation.refetch()] : [])]); };
104
+ const run = async (task: () => Promise<void>) => {
105
+ setBusy(true); setError(''); setNotice('');
106
+ try { await task(); } catch (reason) { setError(reason instanceof Error ? reason.message : 'Fleet operation failed'); }
107
+ finally { setBusy(false); }
108
+ };
109
+ const publish = async (changes: FleetChanges) => {
110
+ if (!state || !await modal.confirm({ title: 'Publish fleet configuration?',
111
+ message: `Publish changes to ${Object.keys(changes).join(', ')}? Config-sync timers will download the new configuration and may restart services. Publication does not confirm that nodes are healthy.`, confirmText: 'Publish' })) return;
112
+ await run(async () => {
113
+ const result = await writeFleet<FleetPublication>({ action: 'publish', expected_revision: state.revision, changes });
114
+ if (result.published !== true) throw new Error('Publication was not confirmed. Reload to check the current revision.');
115
+ setOperationId(null); setEditorEpoch((value) => value + 1); setNotice('Published to S3. Node application and health are still pending.'); await refresh();
116
+ });
117
+ };
118
+ const apply = async () => {
119
+ if (!state || !await modal.confirm({ title: 'Apply configuration now?', message: 'Ask every expected node to run its configured sync service now. Services may restart. Results will appear below.', confirmText: 'Apply now' })) return;
120
+ await run(async () => {
121
+ const result = await writeFleet<FleetOperation>({ action: 'apply', expected_revision: state.revision });
122
+ if (!result.operation_id) throw new Error('No apply operation was returned. Refresh node status before retrying.');
123
+ queryClient.setQueryData([...queryKey, 'operation', result.operation_id], result);
124
+ setOperationId(result.operation_id); setNotice('Apply requested. Waiting for each node to confirm its result.');
125
+ });
126
+ };
127
+ const restore = async (versionId: string) => {
128
+ if (!state || !await modal.confirm({ title: 'Restore previous version?', message: 'Publish this previous configuration as a new revision, including its stored secret values. Config-sync timers may restart services. Node health must be checked separately.', confirmText: 'Restore previous version' })) return;
129
+ await run(async () => {
130
+ const result = await writeFleet<FleetPublication>({ action: 'restore', expected_revision: state.revision, version_id: versionId });
131
+ if (result.published !== true) throw new Error('Restore was not confirmed. Reload to check the current revision.');
132
+ setOperationId(null); setEditorEpoch((value) => value + 1); setNotice('Previous version published as a new revision. Node application and health are still pending.'); await refresh();
133
+ });
134
+ };
135
+ if (!state) return <div className="panel panel-pad"><h1>Fleet Configuration</h1><p role="alert">{stateQuery.error?.message ?? 'Loading configuration…'}</p>
136
+ {stateQuery.error && <button className="btn" onClick={() => void refresh()}>Retry</button>}</div>;
137
+ const tracked = operationId && operation.data?.revision === state.revision ? operation.data : null;
138
+ const evidence = tracked && (!operationFinished(tracked) || Date.parse(tracked.observed_at ?? '') > Date.parse(state.fleet.observed_at ?? '')) ? tracked : state.fleet;
139
+ const report = stateQuery.error || operation.error ? { ...evidence, status: 'observation_unavailable', healthy_everywhere: false,
140
+ nodes: evidence.nodes.map((node) => ({ ...node, healthy: false })) } : evidence;
141
+ return <div className="page" style={{ minWidth: 0, overflowWrap: 'anywhere' }}><header className="page-header"><div><h1>Fleet Configuration</h1>
142
+ <p className="dim">Publish registered application settings and track their application across your nodes.</p></div></header>
143
+ <div className="panel panel-pad" style={{ marginBottom: 20 }}><p style={{ overflowWrap: 'anywhere' }}><b>Published to S3:</b> {state.published ? state.revision : 'Not yet published'}</p>
144
+ <p className="dim">This request’s node loaded revision: {state.loaded_revision ?? 'Not reported'}</p>
145
+ {!state.publish_configured && <p className="form-alert">Publishing is unavailable until the fleet configuration location, encryption and delegated settings are configured.</p>}
146
+ <button className="btn" disabled={busy || stateQuery.isFetching} onClick={() => void refresh()}>Refresh status</button>{' '}
147
+ <button className="btn" disabled={busy || !state.revision || (operation.data != null && !operationFinished(operation.data))} onClick={() => void apply()}>Apply now</button>
148
+ {notice && <p role="status">{notice}</p>}{(error || stateQuery.error) && <p className="form-alert" role="alert">{error || stateQuery.error?.message}</p>}
149
+ </div>
150
+ <FleetEditor key={`${state.revision ?? 'unpublished'}:${editorEpoch}`} state={state} busy={busy} onPublish={publish} />
151
+ {operation.error && <p className="form-alert" role="alert">Apply status could not be refreshed: {operation.error.message}. Refresh status to check nodes; prior results are not current confirmation.</p>}
152
+ {tracked && <p role="status">Apply operation: {tracked.status}</p>}
153
+ <FleetNodeStatus report={report} revision={state.revision} />
154
+ <section className="panel panel-pad" style={{ marginTop: 20 }}><h2>Version history</h2>
155
+ {history.error && <p className="form-alert">{history.error.message}</p>}
156
+ {history.isPending && <p>Loading versions…</p>}
157
+ {history.data?.versions.map((version) => <div key={version.version_id} style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginBottom: 12, alignItems: 'center' }}>
158
+ <span style={{ overflowWrap: 'anywhere', minWidth: 0 }}>{version.published_at ?? 'Time unavailable'} · {version.version_id}{version.current ? ' (current)' : ''}</span>
159
+ {!version.current && <button className="btn" disabled={busy || !state.publish_configured} onClick={() => void restore(version.version_id)}>Restore previous version</button>}
160
+ </div>)}
161
+ {history.data?.versions.length === 0 && <p>No previous versions are available.</p>}
162
+ {history.data?.truncated && <p className="dim">Only the most recent available versions are shown.</p>}
163
+ </section></div>;
164
+ }
165
+
166
+ export function FleetConfigurationPage() {
167
+ const { data: me, isLoading } = useMe();
168
+ if (isLoading) return <p>Checking access…</p>;
169
+ if (me?.is_superuser !== true) return <div className="panel panel-pad">Fleet Configuration requires a superuser account.</div>;
170
+ return <FleetContent key={me.id} userId={me.id} />;
171
+ }