portal-mojo 0.2.3 → 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.
Files changed (48) hide show
  1. package/README.md +3 -0
  2. package/docs/ModelTable.md +26 -0
  3. package/docs/README.md +3 -1
  4. package/docs/admin-fleet-configuration.md +50 -0
  5. package/docs/admin-phonehub.md +11 -1
  6. package/docs/admin-push.md +85 -6
  7. package/docs/admin-settings.md +10 -0
  8. package/docs/admin-signin.md +73 -0
  9. package/docs/detail-primitives.md +12 -1
  10. package/docs/feedback.md +60 -1
  11. package/docs/forms.md +41 -0
  12. package/docs/loading.md +3 -2
  13. package/docs/popover.md +3 -3
  14. package/docs/realtime.md +5 -0
  15. package/package.json +1 -1
  16. package/src/admin/credentials/group-api-keys.tsx +20 -0
  17. package/src/admin/credentials/webhook-subscriptions.tsx +21 -0
  18. package/src/admin/domains/identity.ts +7 -1
  19. package/src/admin/domains/operations.ts +8 -1
  20. package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +171 -0
  21. package/src/admin/fleet-configuration/data.ts +89 -0
  22. package/src/admin/identity/members/MemberDetail.tsx +48 -3
  23. package/src/admin/index.ts +3 -0
  24. package/src/admin/messaging/push/PushPage.tsx +61 -13
  25. package/src/admin/messaging/push/PushTestDialog.tsx +59 -0
  26. package/src/admin/messaging/push/api.ts +25 -1
  27. package/src/admin/messaging/push/models.ts +7 -6
  28. package/src/admin/phonehub/PhoneHubPage.tsx +19 -8
  29. package/src/admin/phonehub/api.ts +5 -5
  30. package/src/admin/public/identity.ts +1 -0
  31. package/src/admin/public/operations.ts +1 -0
  32. package/src/admin/registry.ts +4 -2
  33. package/src/admin/settings/SettingDetail.tsx +2 -1
  34. package/src/admin/settings/SettingValue.tsx +37 -0
  35. package/src/admin/settings/SettingsPage.tsx +2 -1
  36. package/src/admin/signin/SigninPage.tsx +397 -0
  37. package/src/admin/signin/api.ts +83 -0
  38. package/src/admin/signin/index.ts +4 -0
  39. package/src/client/fleet-mock.ts +61 -0
  40. package/src/client/mock.ts +240 -11
  41. package/src/client/realtime.ts +12 -0
  42. package/src/ui/FormView.tsx +24 -1
  43. package/src/ui/ModelTable.tsx +12 -4
  44. package/src/ui/detail/MetadataSection.tsx +46 -10
  45. package/src/ui/form-autosave.ts +134 -23
  46. package/src/ui/guardrail.tsx +125 -0
  47. package/src/ui/index.ts +1 -0
  48. package/src/ui/toast.tsx +48 -5
package/README.md CHANGED
@@ -67,6 +67,9 @@ are identical in both forms.
67
67
  (`panel`, `chip`, `tbl`, …) styled by the app's `theme.css` under
68
68
  `data-theme="light"` **and** `"dark"` (reference set:
69
69
  `apps/portal/src/theme.css`).
70
+ This includes the `.guardrail*` rules behind `confirmGuardrail`
71
+ (reference: `apps/portal/src/theme/guardrail.css`) — the package ships no
72
+ CSS, so an app that opens a guardrail must carry that block.
70
73
  - **Tailwind scan of this package.** In an installed app's CSS:
71
74
  `@source "../node_modules/portal-mojo/src";` (adjust the relative path from
72
75
  that CSS file). Utility classes used in package components are generated by
@@ -110,6 +110,32 @@ passed to every run — e.g. collect ONE disable reason) →
110
110
  / `"N succeeded, M failed"` (warning) / `"failed for all N"` → always
111
111
  clear selection + invalidate.
112
112
 
113
+ `confirm` is the permission step and takes three forms:
114
+
115
+ | `confirm` | Behaviour |
116
+ |---|---|
117
+ | `string` (default `"<label> N item(s)?"`) | `modal.confirm` with that copy; `danger` styles the button. |
118
+ | `false` | No prompt. |
119
+ | `(rows) => Promise<boolean>` | **Replaces** the built-in confirm — resolve `false` to cancel. `prepare` still runs after it. Open a `confirmGuardrail` here for a batch that takes a tenant dark or can't be undone. |
120
+
121
+ Ask permission in `confirm`, collect input in `prepare` — don't fold a
122
+ guardrail into `prepare` and return `null` to cancel; that conflates
123
+ "nothing collected" with "declined".
124
+
125
+ ```tsx
126
+ {
127
+ key: 'revoke', label: 'Remove access', danger: true,
128
+ confirm: (rows) => confirmGuardrail({
129
+ title: `Remove access for ${rows.length} members?`,
130
+ effect: <>They lose all access the moment each call lands.</>,
131
+ why: [<>Accounts stay; grants go — re-inviting restores a fresh preset, not the old unlocks.</>],
132
+ confirmText: `Remove ${rows.length}`,
133
+ typeToConfirm: rows.length >= 5 ? String(rows.length) : undefined,
134
+ }),
135
+ run: (row) => revoke(row),
136
+ }
137
+ ```
138
+
113
139
  `runBatch` executes exactly once for selection-wide operations such as incident
114
140
  merge. A rejection produces one actionable error; cancellation mutates
115
141
  nothing. Selection clears and the endpoint invalidates after a non-cancelled
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 |
@@ -43,7 +45,7 @@ everything: run the showcase (`npm run dev:showcase`) → **Develop → Componen
43
45
  | [tabs-and-form-wizard.md](tabs-and-form-wizard.md) | Accessible Tabs variants and shared-state FormWizard, async finish, reset/roster semantics, modal helper |
44
46
  | [admin-modals.md](admin-modals.md) | Admin modal inventory, presentation, resource lifecycle and retained exception policy |
45
47
  | [DetailView.md](DetailView.md) | The UserView-style detail surface + row/section primitives |
46
- | [feedback.md](feedback.md) | Awaitable `modal.*`, `toast.*` |
48
+ | [feedback.md](feedback.md) | Awaitable `modal.*`, `toast.*`, `confirmGuardrail` (the are-you-sure-and-here-is-why stop) |
47
49
  | [idioms.md](idioms.md) | ArmedButton (two-step confirm), `undoToast` (grace-period undo), `progressToast` — the dangerous-action trio |
48
50
  | [file-upload-ux.md](file-upload-ux.md) | Pure file validation, accessible picker/drop controls, bounded component-local upload queues, recovery, and completion callbacks |
49
51
  | [image-editor.md](image-editor.md) | Full-resolution crop/transform/filter composition, bounded history, PNG Blob results, modal helper, and ImageField pre-upload editing |
@@ -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.
@@ -12,6 +12,16 @@ Import `PhoneHubPage`, the three individual pages, models, permission clauses, a
12
12
  - A successful HTTP envelope can contain a `failed` or `undelivered` SMS record: render that status as failure. Other returned statuses remain literal; only `delivered` confirms delivery. The default graph does not expose `is_test`, so a `sent` receipt does not claim a real delivery. Transport/malformed-receipt failures retain the draft and ask the operator to check SMS Audit before retrying. Every attempted send invalidates the audit list; refresh failure cannot turn a completed send into a retry. Provider refusal is demonstrated in the mock with a recipient ending in `0000`; no mock request contacts a provider.
13
13
  - `PhoneConfigModel` reads sanitized scalar/default-graph rows. Saves and `test_connection` use imperative, cache-free calls. The editor follows web-mojo's provider-conditional layout: selecting Twilio, AWS SNS, or Mojo Remote shows and submits only that provider's connection fields. Mojo Remote uses `https://api.mojoverify.com` when the saved URL is unset or the editor is switched to Mojo; an explicitly configured custom URL is preserved. Secret inputs are DOM refs, not React or Query state: blank means untouched and is omitted; replacement must be non-empty; clear sends `null` only after a separate confirmation. Switching providers resets pending clears, and hidden provider credentials remain untouched. Responses are allowlisted before cache reconciliation. This surface edits existing provider credentials; it does not provision Django user/group API keys.
14
14
 
15
+ Connection tests read the provider's `success` verdict, not the outer REST
16
+ `status`. A failed or missing verdict is an error; `test_mode` is a warning
17
+ that the provider was not contacted. Results persist inside the detail modal
18
+ on both sections, alongside a toast. Scope is labeled in the header and
19
+ Connection section. Editing is disabled while a connection test is pending, so an old response
20
+ cannot certify replacement credentials. Saving clears the previous test result.
21
+ The stored URL is shown literally; a saved URL plus `missing_credentials` for
22
+ Mojo means the API key is absent. Re-enter it in Edit configuration and save;
23
+ blank password inputs preserve existing values and do not prove a key exists.
24
+
15
25
  ## Exact permissions
16
26
 
17
27
  Every exported array is an ANY-of clause and retains the `sys.` UI namespace. Number view is `view_phone_numbers | manage_phone_numbers | comms | manage_users`; save is `manage_phone_numbers | comms | manage_users`; delete is only `manage_phone_numbers`. SMS view is `view_sms | manage_sms | comms | owner | manage_notifications`; save is `manage_sms | comms | manage_notifications`; delete is `manage_sms | manage_notifications`. Config view/save is `manage_phone_config | manage_groups | comms`; delete is `manage_phone_config | manage_groups`. These delete clauses describe low-level API compatibility; built-in number,
@@ -24,7 +34,7 @@ Group choices are fetched only with a separate global group-directory clause and
24
34
 
25
35
  ## Provider and deletion reality
26
36
 
27
- Phone lookup is always the global Twilio Lookup integration; it is not group-configured. Stored Twilio and AWS credentials can be tested, but the current SMS send implementation only consults a per-group configuration for `provider="mojo"`. Twilio/AWS configurations otherwise fall through to global Twilio send settings, and AWS sending is not wired. `test_mode` only short-circuits connection testing; it does not block sending.
37
+ Phone lookup uses the global Twilio Lookup integration. Mojo SMS delivery uses the selected PhoneConfig’s saved remote URL and encrypted API key. Twilio SMS delivery uses a complete saved credential pair from the selected config, rejects a partial pair, and falls back to global settings only when neither credential is stored. Its sender uses the config’s from number when set, otherwise the global default. AWS credentials can be stored and tested, but AWS delivery is not wired. `test_mode` only short-circuits connection testing; it does not block sending.
28
38
 
29
39
  An active group config wins; otherwise `PhoneConfig.get_for_group` falls back to the first active system default. Deactivating a group config restores that fallback; Admin exposes this reversible lifecycle. Deleting a group cascades its config and SMS rows. Deleting a user cascades their SMS rows. Deleting a config does not delete SMS audit rows. Deleting an SMS row affects only local audit storage and cannot recall provider delivery.
30
40
 
@@ -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`.
@@ -109,6 +109,7 @@ value. They coexist happily — `<DataView data={record} />` for the record,
109
109
  <MetadataSection
110
110
  endpoint="/api/user" id={user.id} metadata={user.metadata}
111
111
  onSaved={(next) => qc.setQueryData<User>(['/api/user', 'one', user.id], (p) => p && { ...p, metadata: next })}
112
+ beforeSave={async (next, prev) => …} // optional owner gate — see below
112
113
  />
113
114
  ```
114
115
 
@@ -133,7 +134,17 @@ Invariants:
133
134
  Object values then display **read-only** — a string editor round-trips
134
135
  them lossily; remove and re-add to change one.
135
136
 
136
- Deletion confirms through `modal.confirm`; success toasts.
137
+ **Removing a top-level key stops on a guardrail** (`confirmGuardrail`), not a
138
+ plain confirm: the POST carries the whole blob, so one write can wipe
139
+ `auth_config` / `geofence` / `redemption_policy`. The stop names every key
140
+ about to leave and how many remain; removing more than one key at once asks
141
+ the operator to type the count. `commit()` computes the removed set from
142
+ `metadata` vs `next` — every write path, not just the trash button, hits it.
143
+
144
+ `beforeSave(next, prev)` runs after that built-in stop and before the POST.
145
+ Resolve `false` to cancel — nothing posts, no error, the editor keeps its
146
+ draft. Owners diff their own protected keys here (a `changedKeys`-style
147
+ compare on the values of `auth_config`, say). Success toasts.
137
148
 
138
149
  ## StackTraceView — JS **and** Python tracebacks
139
150
 
package/docs/feedback.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # feedback — awaitable modals + toasts
2
2
 
3
3
  ```ts
4
- import { modal, formModal, toast, ModalHost, ToastHost } from 'portal-mojo/ui';
4
+ import { modal, formModal, toast, confirmGuardrail, ModalHost, ToastHost } from 'portal-mojo/ui';
5
5
  ```
6
6
 
7
7
  Mount `<ModalHost />` and `<ToastHost />` once in the app shell. Modals are
@@ -24,6 +24,51 @@ Patterns:
24
24
  input; resolve null to cancel the whole thing.
25
25
  - Stacked dialogs are fine (disable-reason form over a detail modal).
26
26
 
27
+ ## confirmGuardrail — the "are you sure, and here is why" stop
28
+
29
+ ```tsx
30
+ if (!(await confirmGuardrail({
31
+ title: 'Disable webhook to hooks.example.com?', // question form, names the target
32
+ effect: <>Deliveries of <b>order.paid</b> stop the moment this saves.</>,
33
+ why: [ // one concrete consequence per bullet
34
+ <>Nothing re-registers them — it stays off until someone turns it back on.</>,
35
+ <>Consumers see silence, not an error.</>,
36
+ ],
37
+ undo: 'Re-enable it here to resume deliveries.', // or say that you can't
38
+ confirmText: 'Disable webhook',
39
+ typeToConfirm: 'DISABLE', // optional: button disabled until typed
40
+ danger: true, // default; false = primary button + warn tint
41
+ }))) return;
42
+ ```
43
+
44
+ Resolves `true` only on the armed confirm button — `false` on Escape,
45
+ backdrop and Cancel. Opens on `modal.open(…, { size: 'sm' })`, so it stacks
46
+ over a `formModal` that is still open. `typeToConfirm` compares trimmed and
47
+ case-insensitively; Enter in that input confirms once it matches.
48
+
49
+ **Which stop.** Three idioms, one rule each:
50
+
51
+ | Idiom | When |
52
+ |---|---|
53
+ | `modal.confirm` | Reversible / low blast radius: a yes-no is enough. |
54
+ | `ArmedButton` | Irreversible, needs no input, inline (row actions) — see idioms.md. |
55
+ | `confirmGuardrail` | Anything that **takes a tenant dark, moves money, or can't be undone** — the operator must read *why* before the button arms. |
56
+
57
+ Copy is written from what the backend does, not adjectives: "every
58
+ integration holding this key gets 401 on its next call", not "this is
59
+ dangerous". Where it is wired in the package: API-key deactivate, webhook
60
+ disable, member `manage_group` / `manage_members` grants (through
61
+ `FormView.beforeSave`), and MetadataSection's removed-key stop.
62
+
63
+ `changedKeys(initial, next, watch)` is the diff helper for config-form saves:
64
+ which of the watched keys changed (null/'' normalised), so a save confirms
65
+ ONLY the fields that break things and passes silently otherwise.
66
+
67
+ Styles: `apps/portal/src/theme/guardrail.css` (`.guardrail`,
68
+ `.guardrail-effect/-why/-why-head/-undo/-type`, `.guardrail-icon`) over
69
+ `--bad/--bad-soft` (danger) and `--warn/--warn-soft` (`danger: false`). The
70
+ package ships no CSS — a consuming app carries that block (README).
71
+
27
72
  ## modal.drawer
28
73
 
29
74
  ```ts
@@ -79,6 +124,20 @@ bottom-right cards, max 5, 3.5s. Conventions:
79
124
  - `response: 'payload'` actions toast the SERVER's payload message
80
125
  (`outcome.body.message`), not client copy.
81
126
 
127
+ `ToastHost` uses a manual native popover above dialog backdrops. Its stable
128
+ portal target follows the most recently opened modal (including nested dialogs
129
+ and drawers), then returns to the page when the last modal closes. Modal-local
130
+ ancestry keeps Undo and progress Cancel usable despite native dialog inertness.
131
+ Moving the host preserves card state and timers and does not focus the toast.
132
+ Mount it once; do not add per-dialog toast hosts or solve this with `z-index`.
133
+ Custom app themes must include the updated toast CSS from
134
+ `apps/portal/src/theme.css`: reset the popover's UA inset/margin/border/padding
135
+ and background, hide closed popovers, and keep only the cards pointer-active.
136
+ Updating the TypeScript package alone does not update a consumer's own theme.
137
+ The showcase's **Toasts inside modal** demo covers errors, Undo, progress,
138
+ and nested opening/closing. `node scripts/verify-toast-modal.mjs` checks the
139
+ mounted lifecycle; native visibility and interaction require browser checks.
140
+
82
141
  ## Pitfall
83
142
 
84
143
  An open `<dialog>` pauses ModelTable's autoRefresh by design — don't build
package/docs/forms.md CHANGED
@@ -105,10 +105,51 @@ if (!data) return; // cancelled → null
105
105
  savedFlashMs={1500} // saved-check duration (default 1500)
106
106
  onSaved={({ changes, fields, row }) => …}
107
107
  onSaveError={({ changes, fields, error }) => …}
108
+ beforeSave={({ changes, names, row }) => …} // once per batch, BEFORE the POST (below)
108
109
  onPendingChange={(uploading) => setDetailDismissLocked(uploading)}
109
110
  />
110
111
  ```
111
112
 
113
+ ### `beforeSave` — the pre-save gate
114
+
115
+ ```tsx
116
+ beforeSave={({ changes, names, row }) => {
117
+ // changes: the wire body (dotted names expanded: {permissions: {manage_group: true}})
118
+ // names: the flat field names in the batch ('permissions.manage_group')
119
+ const perms = changes.permissions as Record<string, unknown> | undefined;
120
+ if (perms?.manage_group !== true) return true; // proceed
121
+ return confirmGuardrail({ title: `Grant Manage Group to ${row.email}?`, … }); // false → drop
122
+ }}
123
+ ```
124
+
125
+ Runs once per batch with the body about to POST. Three results:
126
+
127
+ | Return | Effect |
128
+ |---|---|
129
+ | `false` | **Drop the batch.** Its fields revert to the server snapshot and go idle — no error, no toast. A guardrail the operator declined is not a failure. |
130
+ | an object | **Replace the body.** The object POSTs instead of `changes` (`onSaved.changes` reports what was sent). |
131
+ | `true` / `undefined` | Proceed unchanged. |
132
+
133
+ While it awaits, the batch is **in flight**: the 300ms window cannot re-fire
134
+ and a commit made meanwhile joins the **next** batch (which gets its own
135
+ `beforeSave`). A `false` reverts only the names this batch carried — a field
136
+ re-committed during the await keeps its new value for the next batch. A hook
137
+ that throws or rejects is a bug, not a veto: it surfaces exactly like a
138
+ failed save (revert + pinned error + `onSaveError` toast). Without the prop
139
+ the path is the pre-existing one, unchanged.
140
+
141
+ Each batch captures its save target and result callbacks before confirmation.
142
+ Rerendering with another record while the gate awaits cannot redirect that
143
+ approved write or its callbacks to the new record.
144
+ FormView also isolates its mutation observer and draft state by model and row
145
+ ID. Switching either starts a fresh form; a refetch of the same row preserves
146
+ pending edits. Pass a stable model definition, as returned by `defineModel`.
147
+
148
+ Use it for the fields where a typo takes a tenant dark and there is no save
149
+ button to hesitate over: identity fields (`uuid`, `auth_domain`), escalating
150
+ grants (MemberDetail guards `manage_group` / `manage_members` this way). The
151
+ contract is pinned by `npm run verify:form-autosave`.
152
+
112
153
  ### The autosave lifecycle (the machine: `useFormAutosave`)
113
154
 
114
155
  1. **Commit, never keystroke.** Text/textarea update the draft per keystroke
package/docs/loading.md CHANGED
@@ -238,5 +238,6 @@ clicked away mid-save, they keep where they went.
238
238
  - **The overlay is `dialog[open]`.** ModelTable's `autoRefresh` skips ticks
239
239
  while any `dialog[open]` exists, so auto-refresh politely pauses during a
240
240
  busy operation. That is intended; don't "fix" it.
241
- - **Toasts render under the overlay** (`.toast-host` is z-index 60, the
242
- overlay is in the top layer). Toast *after* closing the handle, not during.
241
+ - **Toasts remain above the overlay.** The single [ToastHost](feedback.md#toast)
242
+ follows the active native dialog, keeping notifications and their actions
243
+ available during a blocking operation.
package/docs/popover.md CHANGED
@@ -117,9 +117,9 @@ baked in 20px padding + 260px min-width for its one tenant, the calendar
117
117
  - **Don't wrap the shell in `overflow: hidden` styling assumptions** —
118
118
  it's in the top layer; nothing ancestral clips or covers it. Anything
119
119
  that must paint ABOVE an open popover has to join the top layer later.
120
- - **Toasts render under open modals** (`.toast-host` is z-index 60, not
121
- top layer). Don't signal from popover content inside a modal via toast;
122
- show it in the modal body.
120
+ - **Use the single ToastHost for modal notifications.** It follows the active
121
+ dialog into the native top layer; see [feedback](feedback.md#toast). Keep
122
+ actionable errors in the modal body too when they need to outlast a toast.
123
123
  - **One value pipeline:** the popover is presentation only. Commit
124
124
  semantics (`change` on select/Enter/blur, never per keystroke) belong
125
125
  to the consuming control.
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.3",
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",
@@ -14,6 +14,7 @@ import {
14
14
  FlatRow,
15
15
  ModelTable,
16
16
  SchemaForm,
17
+ confirmGuardrail,
17
18
  fmt,
18
19
  modal,
19
20
  toast,
@@ -112,6 +113,20 @@ function TokenFooter({ permissions }: { permissions: string[] }) {
112
113
  );
113
114
  }
114
115
 
116
+ /** The stop in front of `is_active: false` — copy states what the backend does. */
117
+ function confirmDeactivateKey(row: GroupApiKeyRow): Promise<boolean> {
118
+ return confirmGuardrail({
119
+ title: `Deactivate API key "${row.name}"?`,
120
+ effect: <>The key stops authenticating the moment this saves.</>,
121
+ why: [
122
+ <>Every integration holding this key gets <b>401</b> on its next call — the caller sees an auth failure, not a notice.</>,
123
+ <>The key is <b>not rotated</b>: the same secret stays on file, so anyone who holds it regains access the instant it is re-enabled.</>,
124
+ ],
125
+ undo: 'Re-enabling restores the same key; no integration needs a new secret.',
126
+ confirmText: 'Deactivate key',
127
+ });
128
+ }
129
+
115
130
  function useGroupApiKeyActions(permission: PermSpec) {
116
131
  const renderedPermissions = useRenderedPermissions();
117
132
  const create = useCreateGroupApiKey();
@@ -241,6 +256,11 @@ function useGroupApiKeyActions(permission: PermSpec) {
241
256
  if (Object.keys(permissionChanges).length) changes.permissions = permissionChanges;
242
257
  if (!Object.keys(changes).length) return;
243
258
 
259
+ // Flipping a key inactive is the one edit here with a blast radius
260
+ // outside this portal — every caller holding the key. Guardrail it;
261
+ // the other edits in the same form ride along only if it is confirmed.
262
+ if (changes.is_active === false && !(await confirmDeactivateKey(row))) return;
263
+
244
264
  try {
245
265
  await save.mutateAsync({ id: row.id, changes });
246
266
  toast.success('API key updated');