portal-mojo 0.2.3 → 0.2.4

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/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
@@ -43,7 +43,7 @@ everything: run the showcase (`npm run dev:showcase`) → **Develop → Componen
43
43
  | [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
44
  | [admin-modals.md](admin-modals.md) | Admin modal inventory, presentation, resource lifecycle and retained exception policy |
45
45
  | [DetailView.md](DetailView.md) | The UserView-style detail surface + row/section primitives |
46
- | [feedback.md](feedback.md) | Awaitable `modal.*`, `toast.*` |
46
+ | [feedback.md](feedback.md) | Awaitable `modal.*`, `toast.*`, `confirmGuardrail` (the are-you-sure-and-here-is-why stop) |
47
47
  | [idioms.md](idioms.md) | ArmedButton (two-step confirm), `undoToast` (grace-period undo), `progressToast` — the dangerous-action trio |
48
48
  | [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
49
  | [image-editor.md](image-editor.md) | Full-resolution crop/transform/filter composition, bounded history, PNG Blob results, modal helper, and ImageField pre-upload editing |
@@ -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
 
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
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');
@@ -8,6 +8,7 @@ import {
8
8
  FlatRow,
9
9
  ModelTable,
10
10
  SchemaForm,
11
+ confirmGuardrail,
11
12
  fmt,
12
13
  modal,
13
14
  toast,
@@ -135,6 +136,23 @@ const WEBHOOK_FIELDS: Field[] = [
135
136
  { name: 'is_active', type: 'switch', label: 'Active', help: 'Inactive subscriptions are skipped.' },
136
137
  ];
137
138
 
139
+ /** The stop in front of `is_active: false` — copy states what the backend does. */
140
+ function confirmDisableSubscription(row: WebhookSubscriptionRow): Promise<boolean> {
141
+ let host = row.url;
142
+ try { host = new URL(row.url).host || row.url; } catch { /* keep the raw url */ }
143
+ const events = row.events.length ? row.events.join(', ') : 'all events';
144
+ return confirmGuardrail({
145
+ title: `Disable webhook to ${host}?`,
146
+ effect: <>Deliveries of <b>{events}</b> to <b>{row.url}</b> stop the moment this saves.</>,
147
+ why: [
148
+ <>Deliveries stop for these events and nothing re-registers them — the subscription stays off until someone switches it back on here.</>,
149
+ <>Consumers see <b>silence, not an error</b>: the receiver gets no failed delivery and no notice, so anything downstream that depends on these events degrades quietly.</>,
150
+ ],
151
+ undo: 'Re-enable it here to resume deliveries for new events.',
152
+ confirmText: 'Disable webhook',
153
+ });
154
+ }
155
+
138
156
  function useWebhookActions(permission: PermSpec) {
139
157
  const save = WebhookSubscriptionModel.useSave();
140
158
 
@@ -206,6 +224,7 @@ function useWebhookActions(permission: PermSpec) {
206
224
  if (events.join('\u0000') !== row.events.join('\u0000')) changes.events = events;
207
225
  if (isActive !== row.is_active) changes.is_active = isActive;
208
226
  if (!Object.keys(changes).length) return;
227
+ if (changes.is_active === false && !(await confirmDisableSubscription(row))) return;
209
228
  try {
210
229
  await save.mutateAsync({ id: row.id, changes });
211
230
  toast.success('Webhook subscription updated');
@@ -216,6 +235,8 @@ function useWebhookActions(permission: PermSpec) {
216
235
 
217
236
  const toggleSubscription = async (row: WebhookSubscriptionRow, next: boolean) => {
218
237
  if (!can) return;
238
+ // Only the OFF direction is guarded — re-enabling restores deliveries.
239
+ if (!next && !(await confirmDisableSubscription(row))) return;
219
240
  try {
220
241
  await save.mutateAsync({ id: row.id, changes: { is_active: next } });
221
242
  } catch (error) {
@@ -1,9 +1,9 @@
1
- import { useSyncExternalStore } from 'react';
1
+ import { useSyncExternalStore, type ReactNode } from 'react';
2
2
  import { useQueryClient } from '@tanstack/react-query';
3
3
  import { useCan, type PermSpec } from '../../../client/runtime';
4
4
  import {
5
5
  Badge, DetailView, Eyebrow, FlatRow, FormView, JsonBlock, MetricCard,
6
- ModelTable, fmt, formModal, toast, type Column, type DetailMenuEntry,
6
+ ModelTable, confirmGuardrail, fmt, formModal, toast, type Column, type DetailMenuEntry,
7
7
  } from '../../../ui';
8
8
  import { LOGS_ADMIN_PERMISSIONS, LogModel, type LogRow } from '../../monitoring';
9
9
  import {
@@ -51,6 +51,43 @@ function AuditTable({ memberId }: { memberId: number }) {
51
51
  );
52
52
  }
53
53
 
54
+ /**
55
+ * The two grants that let a member reach past its own row. A switch flip
56
+ * autosaves with no save button, so the stop lives in FormView's
57
+ * `beforeSave`: only these keys, only the ON direction — everything else
58
+ * stays one click. (`admin` is not an editable member grant here —
59
+ * `isEditableMemberPermission` excludes it — so it cannot be flipped from
60
+ * this form and needs no guard.)
61
+ */
62
+ const ESCALATING_GRANTS: Record<string, { label: string; why: ReactNode }> = {
63
+ manage_group: {
64
+ label: 'Manage Group',
65
+ why: <><code>manage_group</code> unlocks the group record itself — its settings and policy metadata, its API keys, webhook subscriptions and integrations — every write the group's credential and identity panels gate on.</>,
66
+ },
67
+ manage_members: {
68
+ label: 'Manage Members',
69
+ why: <><code>manage_members</code> lets the holder change any member's grants, including its own, and admit or remove members — a grant that can reproduce itself.</>,
70
+ },
71
+ };
72
+
73
+ /** beforeSave gate: stops only when an escalating grant flips ON. */
74
+ function confirmGrantEscalation(member: MemberRow, changes: Record<string, unknown>): Promise<boolean> | true {
75
+ const perms = changes.permissions;
76
+ if (!perms || typeof perms !== 'object') return true;
77
+ const granting = Object.keys(ESCALATING_GRANTS).filter((key) => (perms as Record<string, unknown>)[key] === true);
78
+ if (granting.length === 0) return true;
79
+ const who = member.user?.display_name || member.user?.email || member.user?.username || `User #${member.user?.id ?? member.id}`;
80
+ const group = member.group?.name || 'this group';
81
+ const labels = granting.map((key) => ESCALATING_GRANTS[key]!.label).join(' + ');
82
+ return confirmGuardrail({
83
+ title: `Grant ${labels} to ${who}?`,
84
+ effect: <><b>{who}</b> holds <b>{granting.join(' and ')}</b> in <b>{group}</b> the moment this saves — there is no save button and no second step.</>,
85
+ why: granting.map((key) => ESCALATING_GRANTS[key]!.why),
86
+ undo: 'Switch it off here to revoke. Anything done while the grant was held stays done.',
87
+ confirmText: `Grant ${labels}`,
88
+ });
89
+ }
90
+
54
91
  function GrantSummary({ member }: { member: MemberRow }) {
55
92
  const raw = rawMemberGrants(member.permissions);
56
93
  const effective = effectiveMemberGrants(member.permissions);
@@ -201,7 +238,15 @@ export function MemberDetail({
201
238
  <Eyebrow>Per-group authorization</Eyebrow>
202
239
  <p className="dim">Role labels are not authorization. Protected changes may be rejected by <code>MEMBER_PERMS_PROTECTION</code>; failed autosaves revert and show the server error.</p>
203
240
  {canSave
204
- ? <FormView model={MemberModel} row={member} fields={memberPermissionFields()} onSaved={() => { void MemberModel.invalidate(qc); }} />
241
+ ? (
242
+ <FormView
243
+ model={MemberModel}
244
+ row={member}
245
+ fields={memberPermissionFields()}
246
+ beforeSave={({ changes }) => confirmGrantEscalation(member, changes)}
247
+ onSaved={() => { void MemberModel.invalidate(qc); }}
248
+ />
249
+ )
205
250
  : <GrantSummary member={member} />}
206
251
  {deploymentOnly.length > 0 && (
207
252
  <p className="dim">Deployment-specific stored grants without registered editors remain read-only: <code>{deploymentOnly.join(', ')}</code>.</p>
@@ -82,7 +82,7 @@ const providerCredentialFields: Record<PhoneProvider, PhoneSecretField[]> = {
82
82
  function providerOperationalNote(provider: PhoneProvider): string {
83
83
  if (provider === 'mojo') return 'Mojo Remote is used by the group-aware SMS delivery path.';
84
84
  if (provider === 'aws') return 'Stored AWS credentials can be tested here. AWS outbound delivery is not wired yet.';
85
- return 'Stored Twilio credentials can be tested here. Outbound delivery currently uses the global Twilio settings.';
85
+ return 'SMS delivery uses the saved Twilio credential pair, or global settings when neither credential is stored.';
86
86
  }
87
87
 
88
88
  function providerVisibleRows(row: PhoneConfigRow): Array<[string, string]> {
@@ -276,6 +276,7 @@ function ConfigDetail({ id, onClose }: { id: number; onClose: () => void }) {
276
276
 
277
277
 
278
278
  const [testing, setTesting] = useState(false);
279
+ const [testResult, setTestResult] = useState<Awaited<ReturnType<typeof testPhoneConfigImperative>> | null>(null);
279
280
  if (!query.data) return <div className="modal-pad">{query.isLoading ? 'Loading provider configuration…' : 'Configuration unavailable'}<button className="btn" onClick={onClose}>Close</button></div>;
280
281
  const row = query.data;
281
282
  const toggle = async (next: boolean) => {
@@ -286,30 +287,40 @@ function ConfigDetail({ id, onClose }: { id: number; onClose: () => void }) {
286
287
  finally { setSaving(false); }
287
288
  };
288
289
  const meta = providerMeta[row.provider];
289
- const edit = () => void modal.open((close) => <ConfigEditor row={row} close={async (saved) => { close(saved); if (saved) await query.refetch(); }} />, { size: 'lg' });
290
+ const edit = () => void modal.open((close) => <ConfigEditor row={row} close={async (saved) => { close(saved); if (saved) { setTestResult(null); await query.refetch(); } }} />, { size: 'lg' });
290
291
  const test = async () => {
291
292
  setTesting(true);
293
+ setTestResult(null);
292
294
  try {
293
295
  const result = await testPhoneConfigImperative(id);
294
- toast[result.status ? 'success' : 'error'](result.message);
296
+ setTestResult(result);
297
+ toast[result.testMode ? 'warning' : result.status ? 'success' : 'error'](result.message);
295
298
  } catch (reason) {
296
- toast.error(reason instanceof Error ? reason.message : 'Connection test failed');
299
+ const message = reason instanceof Error ? reason.message : 'Connection test failed';
300
+ setTestResult({ status: false, message, testMode: false, errorCode: null });
301
+ toast.error(message);
297
302
  } finally {
298
303
  setTesting(false);
299
304
  }
300
305
  };
301
306
 
307
+ const resultNotice = testResult && <div className={testResult.status && !testResult.testMode ? 'known-card' : 'form-alert'} role={testResult.status ? 'status' : 'alert'}>
308
+ <h3>Connection test: {testResult.testMode ? 'Not tested' : testResult.status ? 'Passed' : 'Failed'}</h3>
309
+ <p>{testResult.message}</p>
310
+ {testResult.errorCode === 'missing_credentials' && <p>Use Edit configuration to save the provider credentials, then test again. Blank credential fields keep any saved value unchanged.</p>}
311
+ </div>;
312
+
302
313
  return <DetailView
303
314
  title={row.name}
304
- subtitle={relationLabel(row.group)}
315
+ subtitle={`Scope: ${relationLabel(row.group)}`}
305
316
  icon={meta.icon}
306
317
  chips={[{ text: meta.label, tone: 'info' }, { text: row.is_active ? 'Active' : 'Inactive', tone: row.is_active ? 'success' : 'muted' }, ...(row.test_mode ? [{ text: 'Test mode', tone: 'warning' as const }] : [])]}
307
318
  onClose={onClose}
308
319
  active={canManage ? { value: row.is_active, disabled: saving, onChange: next => void toggle(next) } : undefined}
309
- contextMenu={canManage ? [{ label: 'Edit configuration', onSelect: edit }, { label: 'Test stored connection', disabled: testing, onSelect: () => void test() }, { label: row.is_active ? 'Deactivate' : 'Reactivate', disabled: saving, onSelect: () => void toggle(!row.is_active) }] : []}
320
+ contextMenu={canManage ? [{ label: 'Edit configuration', disabled: testing, onSelect: edit }, { label: 'Test stored connection', disabled: testing, onSelect: () => void test() }, { label: row.is_active ? 'Deactivate' : 'Reactivate', disabled: saving, onSelect: () => void toggle(!row.is_active) }] : []}
310
321
  sections={[
311
- { key: 'overview', label: 'Overview', icon: 'bi-sliders', render: () => <div className="detail-section"><div className="phonehub-provider-summary"><i className={`bi ${meta.icon}`} /><div><span className="eyebrow">Provider</span><h3>{meta.label}</h3><p>{meta.description}</p></div></div><div className="phonehub-summary-grid"><div className="known-card"><span>Scope</span><strong>{relationLabel(row.group)}</strong></div><div className="known-card"><span>Automatic lookup</span><strong>{row.lookup_enabled ? `Enabled · ${row.lookup_cache_days} days` : 'Disabled'}</strong></div><div className="known-card"><span>Last modified</span><strong>{fmt.datetime(row.modified)}</strong></div></div><p className="dim">An active group configuration wins; otherwise Phone Hub falls back to the first active system default.</p></div> },
312
- { key: 'connection', label: 'Connection', icon: 'bi-plug', render: () => <div className="detail-section"><div className="section-eyebrow">{meta.label} settings</div>{providerVisibleRows(row).map(([label, value]) => <FlatRow key={label} label={label}>{value}</FlatRow>)}<FlatRow label="Credentials"><span><i className="bi bi-shield-lock" /> Write-only; edit to replace or clear</span></FlatRow><div className={`phonehub-provider-note${row.provider === 'mojo' ? '' : ' is-warning'}`}><i className={`bi ${row.provider === 'mojo' ? 'bi-info-circle' : 'bi-exclamation-triangle'}`} /><span>{providerOperationalNote(row.provider)}</span></div>{row.test_mode && <div className="phonehub-provider-note is-warning"><i className="bi bi-beaker" /><span>Test mode only short-circuits the connection test. It does not prevent SMS delivery.</span></div>}<FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow></div> }
322
+ { key: 'overview', label: 'Overview', icon: 'bi-sliders', render: () => <div className="detail-section">{resultNotice}<div className="phonehub-provider-summary"><i className={`bi ${meta.icon}`} /><div><span className="eyebrow">Provider</span><h3>{meta.label}</h3><p>{meta.description}</p></div></div><div className="phonehub-summary-grid"><div className="known-card"><span>Scope</span><strong>{relationLabel(row.group)}</strong></div><div className="known-card"><span>Automatic lookup</span><strong>{row.lookup_enabled ? `Enabled · ${row.lookup_cache_days} days` : 'Disabled'}</strong></div><div className="known-card"><span>Last modified</span><strong>{fmt.datetime(row.modified)}</strong></div></div><p className="dim">An active group configuration wins; otherwise Phone Hub falls back to the first active system default.</p></div> },
323
+ { key: 'connection', label: 'Connection', icon: 'bi-plug', render: () => <div className="detail-section">{resultNotice}<FlatRow label="Scope">{relationLabel(row.group)}</FlatRow><div className="section-eyebrow">{meta.label} settings</div>{providerVisibleRows(row).map(([label, value]) => <FlatRow key={label} label={label}>{value}</FlatRow>)}<FlatRow label="Credentials"><span><i className="bi bi-shield-lock" /> Write-only; edit to replace or clear</span></FlatRow><div className={`phonehub-provider-note${row.provider === 'mojo' ? '' : ' is-warning'}`}><i className={`bi ${row.provider === 'mojo' ? 'bi-info-circle' : 'bi-exclamation-triangle'}`} /><span>{providerOperationalNote(row.provider)}</span></div>{row.test_mode && <div className="phonehub-provider-note is-warning"><i className="bi bi-beaker" /><span>Test mode only short-circuits the connection test. It does not prevent SMS delivery.</span></div>}<FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow></div> }
313
324
  ]}
314
325
  />;
315
326
  }
@@ -42,13 +42,13 @@ export async function savePhoneConfigImperative(qc:QueryClient,id:number|null,sc
42
42
  const row=sanitizePhoneConfigRow(response.data as PhoneConfigRow);
43
43
  qc.setQueryData(PhoneConfigModel.keys.one(row.id),row);await qc.invalidateQueries({queryKey:PhoneConfigModel.keys.root});return row;
44
44
  }
45
- // Deliberately NOT client/action-result's `mojoAction`: `status` here is the
46
- // connection test's VERDICT (a result datum), not an action refusal — a failed
47
- // test must resolve so the panel can render it.
48
- export async function testPhoneConfigImperative(id:number):Promise<{status:boolean;message:string}>{
45
+ // PhoneConfig returns `success` as its provider verdict; envelope `status`
46
+ // only says the REST action ran. A failed test resolves for inline display.
47
+ export async function testPhoneConfigImperative(id:number):Promise<{status:boolean;message:string;testMode:boolean;errorCode:string|null}>{
49
48
  const response=await mojoCall(`${PhoneConfigModel.endpoint}/${id}`,{method:'POST',body:{test_connection:1}});
50
49
  const data=(response.data??response) as Record<string,unknown>;
51
- return {status:data.status!==false,message:typeof data.message==='string'?data.message:'Connection test completed.'};
50
+ const status=data.success===true;
51
+ return {status,testMode:data.test_mode===true,errorCode:typeof data.error==='string'?data.error:null,message:typeof data.message==='string'?data.message:status?'Connection verified.':'The server did not confirm the provider connection.'};
52
52
  }
53
53
  export async function fetchPhoneGroupChoices():Promise<PhoneRelation[]>{
54
54
  const result=await mojoList<Record<string,unknown>>('/api/group',{graph:'basic',sort:'name',start:0,size:PHONE_GROUP_CHOICE_LIMIT});
@@ -7676,7 +7676,7 @@ async function phoneHubFetch(path:string,opts:MockFetchOpts):Promise<unknown|und
7676
7676
  const smsMatch=path.match(/^\/api\/phonehub\/sms(?:\/(\d+))?$/);
7677
7677
  if(smsMatch){const id=smsMatch[1]?Number(smsMatch[1]):null;if(method==='DELETE'){if(!hasGlobalPermission(caller,SMS_DELETE_GRANTS))return permissionDenied();if(id==null)return {status:false,error:'Method not allowed',error_code:405};db.sms=db.sms.filter(row=>row.id!==id);return {status:'deleted'};}if(method==='POST'&&!hasGlobalPermission(caller,SMS_SAVE_GRANTS))return permissionDenied();if(method==='GET'&&!hasGlobalPermission(caller,SMS_VIEW_GRANTS))return permissionDenied();if(id!=null){const row=db.sms.find(item=>item.id===id);if(!row)return {status:false,error:'SMS not found',error_code:404};if(method==='POST'){for(const key of ['status','error_message','delivered_at'] as const)if(key in (opts.body??{}))(row as unknown as Record<string,unknown>)[key]=opts.body?.[key];row.modified=Math.floor(Date.now()/1000);}return {status:true,data:smsWire(row),graph:'default'};}if(method==='POST'){const body=opts.body??{};const now=Math.floor(Date.now()/1000);const row:MockSms={id:Math.max(0,...db.sms.map(item=>item.id))+1,created:now,modified:now,direction:String(body.direction??'outbound'),from_number:String(body.from_number??''),to_number:String(body.to_number??''),body:String(body.body??''),status:String(body.status??'queued'),provider:body.provider?String(body.provider):null,provider_message_id:null,error_code:null,error_message:null,metadata:{},is_test:Boolean(body.is_test),sent_at:null,delivered_at:null,user:body.user==null?null:Number(body.user),group:body.group==null?null:Number(body.group)};db.sms.unshift(row);return {status:true,data:smsWire(row),graph:'default'};}if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};const params=messagingParams(opts.params??{},['direction','status','provider','group','user'],['created','direction','status','provider','sent_at','delivered_at'],'default','-created',true);return messagingList(db.sms as unknown as Record<string,unknown>[],params,row=>`${row.from_number} ${row.to_number} ${row.body}`,'default',row=>smsWire(row as unknown as MockSms),'-created');}
7678
7678
  const configMatch=path.match(/^\/api\/phonehub\/config(?:\/(\d+))?$/);
7679
- if(configMatch){const id=configMatch[1]?Number(configMatch[1]):null;if(method==='DELETE'){if(!hasGlobalPermission(caller,PHONE_CONFIG_DELETE_GRANTS))return permissionDenied();if(id==null)return {status:false,error:'Method not allowed',error_code:405};db.phoneConfigs=db.phoneConfigs.filter(row=>row.id!==id);db.phoneConfigSecrets.delete(id);return {status:'deleted'};}if(!hasGlobalPermission(caller,PHONE_CONFIG_VIEW_GRANTS))return permissionDenied();if(id!=null){const row=db.phoneConfigs.find(item=>item.id===id);if(!row)return {status:false,error:'Phone config not found',error_code:404};if(method==='POST'&&opts.body?.test_connection){if(row.test_mode)return {status:true,message:'Config is in test mode - provider not tested'};const secrets=db.phoneConfigSecrets.get(id)??{};const ready=row.provider==='twilio'?Boolean(secrets.twilio_account_sid&&secrets.twilio_auth_token):row.provider==='aws'?Boolean(secrets.aws_access_key_id&&secrets.aws_secret_access_key):Boolean(secrets.mojo_api_key&&row.mojo_remote_url);return ready?{status:true,message:`${row.provider} connection succeeded`}:{status:false,error:`${row.provider} credentials are incomplete`,error_code:400};}if(method==='POST'){const nextGroup='group'in(opts.body??{})?(opts.body?.group==null?null:Number(opts.body.group)):row.group;if(nextGroup!=null&&db.phoneConfigs.some(item=>item.id!==id&&item.group===nextGroup))return {status:false,error:'A configuration already exists for this group',error_code:400};applyPhoneConfig(row,opts.body??{});saveMockPhoneSecrets(id,opts.body??{});}else if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};return {status:true,data:phoneConfigWire(row),graph:'default'};}if(method==='POST'){const body=opts.body??{};const group=body.group==null?null:Number(body.group);if(group!=null&&!db.groups.some(item=>item.id===group))return {status:false,error:'Group not found',error_code:404};if(group!=null&&db.phoneConfigs.some(item=>item.group===group))return {status:false,error:'A configuration already exists for this group',error_code:400};const now=Math.floor(Date.now()/1000);const row:MockPhoneConfig={id:Math.max(0,...db.phoneConfigs.map(item=>item.id))+1,created:now,modified:now,group,name:String(body.name??''),is_active:body.is_active!==false,provider:['twilio','aws','mojo'].includes(String(body.provider))?String(body.provider) as MockPhoneConfig['provider']:'twilio',twilio_from_number:null,aws_region:null,aws_sender_id:null,mojo_remote_url:null,lookup_enabled:body.lookup_enabled!==false,lookup_cache_days:Number(body.lookup_cache_days??90),test_mode:Boolean(body.test_mode)};applyPhoneConfig(row,body);db.phoneConfigs.push(row);saveMockPhoneSecrets(row.id,body);return {status:true,data:phoneConfigWire(row),graph:'default'};}if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};const params=messagingParams(opts.params??{},['provider','is_active','group','test_mode'],['name','provider','is_active','created','modified'],'default','name');return messagingList(db.phoneConfigs as unknown as Record<string,unknown>[],params,row=>String(row.name),'default',row=>phoneConfigWire(row as unknown as MockPhoneConfig),'name');}
7679
+ if(configMatch){const id=configMatch[1]?Number(configMatch[1]):null;if(method==='DELETE'){if(!hasGlobalPermission(caller,PHONE_CONFIG_DELETE_GRANTS))return permissionDenied();if(id==null)return {status:false,error:'Method not allowed',error_code:405};db.phoneConfigs=db.phoneConfigs.filter(row=>row.id!==id);db.phoneConfigSecrets.delete(id);return {status:'deleted'};}if(!hasGlobalPermission(caller,PHONE_CONFIG_VIEW_GRANTS))return permissionDenied();if(id!=null){const row=db.phoneConfigs.find(item=>item.id===id);if(!row)return {status:false,error:'Phone config not found',error_code:404};if(method==='POST'&&opts.body?.test_connection){if(row.test_mode)return {status:true,data:{success:true,test_mode:true,message:'Config is in test mode - provider not tested'}};const secrets=db.phoneConfigSecrets.get(id)??{};const ready=row.provider==='twilio'?Boolean(secrets.twilio_account_sid&&secrets.twilio_auth_token):row.provider==='aws'?Boolean(secrets.aws_access_key_id&&secrets.aws_secret_access_key):Boolean(secrets.mojo_api_key&&row.mojo_remote_url);return {status:true,data:ready?{success:true,message:`${row.provider} connection succeeded`}:{success:false,message:`${row.provider} credentials are incomplete`,error:'missing_credentials'}};}if(method==='POST'){const nextGroup='group'in(opts.body??{})?(opts.body?.group==null?null:Number(opts.body.group)):row.group;if(nextGroup!=null&&db.phoneConfigs.some(item=>item.id!==id&&item.group===nextGroup))return {status:false,error:'A configuration already exists for this group',error_code:400};applyPhoneConfig(row,opts.body??{});saveMockPhoneSecrets(id,opts.body??{});}else if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};return {status:true,data:phoneConfigWire(row),graph:'default'};}if(method==='POST'){const body=opts.body??{};const group=body.group==null?null:Number(body.group);if(group!=null&&!db.groups.some(item=>item.id===group))return {status:false,error:'Group not found',error_code:404};if(group!=null&&db.phoneConfigs.some(item=>item.group===group))return {status:false,error:'A configuration already exists for this group',error_code:400};const now=Math.floor(Date.now()/1000);const row:MockPhoneConfig={id:Math.max(0,...db.phoneConfigs.map(item=>item.id))+1,created:now,modified:now,group,name:String(body.name??''),is_active:body.is_active!==false,provider:['twilio','aws','mojo'].includes(String(body.provider))?String(body.provider) as MockPhoneConfig['provider']:'twilio',twilio_from_number:null,aws_region:null,aws_sender_id:null,mojo_remote_url:null,lookup_enabled:body.lookup_enabled!==false,lookup_cache_days:Number(body.lookup_cache_days??90),test_mode:Boolean(body.test_mode)};applyPhoneConfig(row,body);db.phoneConfigs.push(row);saveMockPhoneSecrets(row.id,body);return {status:true,data:phoneConfigWire(row),graph:'default'};}if(method!=='GET')return {status:false,error:'Method not allowed',error_code:405};const params=messagingParams(opts.params??{},['provider','is_active','group','test_mode'],['name','provider','is_active','created','modified'],'default','name');return messagingList(db.phoneConfigs as unknown as Record<string,unknown>[],params,row=>String(row.name),'default',row=>phoneConfigWire(row as unknown as MockPhoneConfig),'name');}
7680
7680
  return undefined;
7681
7681
  }
7682
7682
  async function messagingFetch(path:string,opts:MockFetchOpts):Promise<unknown|undefined>{
@@ -26,6 +26,7 @@ import { toast } from './toast';
26
26
  import {
27
27
  useFormAutosave,
28
28
  type AutosaveBatchInfo,
29
+ type AutosaveBeforeSave,
29
30
  type FieldStatus,
30
31
  type FormAutosaveApi,
31
32
  } from './form-autosave';
@@ -104,13 +105,34 @@ export interface FormViewProps<T extends { id: number | string }> {
104
105
  savedFlashMs?: number;
105
106
  onSaved?: (info: AutosaveBatchInfo & { row: T }) => void;
106
107
  onSaveError?: (info: AutosaveBatchInfo & { error: Error }) => void;
108
+ /**
109
+ * Pre-save gate, once per batch. `false` drops the batch (its fields
110
+ * revert, no toast); an object replaces the wire body; true/undefined
111
+ * proceeds. Put a `confirmGuardrail` here for grants/identity fields.
112
+ */
113
+ beforeSave?: AutosaveBeforeSave<T>;
107
114
  /** True while any FileField transfer is active. Owners can block dismissal. */
108
115
  onPendingChange?: (pending: boolean) => void;
109
116
  className?: string;
110
117
  }
111
118
 
119
+ const saveOwnerIds = new WeakMap<object, number>();
120
+ let nextSaveOwnerId = 0;
121
+
112
122
  export function FormView<T extends { id: number | string }>(props: FormViewProps<T>) {
113
- const { model, row, fields, tabs, debounceMs, savedFlashMs, onSaved, onSaveError, className } = props;
123
+ // A TanStack mutation observer updates its mutationFn on rerender.
124
+ // Keep the observer and reducer with the model/record that created them,
125
+ // so a confirmation already in flight cannot migrate to another owner.
126
+ let modelId = saveOwnerIds.get(props.model.useSave);
127
+ if (modelId === undefined) {
128
+ modelId = ++nextSaveOwnerId;
129
+ saveOwnerIds.set(props.model.useSave, modelId);
130
+ }
131
+ return <OwnedFormView key={JSON.stringify([modelId, props.row.id])} {...props} />;
132
+ }
133
+
134
+ function OwnedFormView<T extends { id: number | string }>(props: FormViewProps<T>) {
135
+ const { model, row, fields, tabs, debounceMs, savedFlashMs, onSaved, onSaveError, beforeSave, className } = props;
114
136
  const save = model.useSave();
115
137
 
116
138
  // Registry-named tabsets re-render on registration; inline defs pass through.
@@ -164,6 +186,7 @@ export function FormView<T extends { id: number | string }>(props: FormViewProps
164
186
  save: (changes) => save.mutateAsync({ id: row.id, changes } satisfies SaveVars),
165
187
  debounceMs,
166
188
  savedFlashMs,
189
+ beforeSave,
167
190
  onSaved: (info) => {
168
191
  publishOwnerResult(info.fields, 'success', info.changes, info.row);
169
192
  onSaved?.(info);
@@ -56,12 +56,18 @@ interface BatchActionBase<T> {
56
56
  danger?: boolean;
57
57
  /** Row-state eligibility. Ineligible selected rows are never submitted. */
58
58
  eligible?: (row: T) => boolean;
59
- /** Confirm copy; false skips the prompt. Default: "<label> N item(s)?" */
60
- confirm?: string | false;
59
+ /**
60
+ * The permission step. A string is the `modal.confirm` copy; false skips
61
+ * the prompt; a function REPLACES the built-in confirm — resolve false
62
+ * to cancel (open a `confirmGuardrail` here for batches that take a
63
+ * tenant dark or can't be undone). Default: "<label> N item(s)?"
64
+ */
65
+ confirm?: string | false | ((rows: T[]) => Promise<boolean>);
61
66
  /**
62
67
  * Optional once-per-batch step (collect a reason, pick a value…) run
63
68
  * AFTER the confirm. Resolve null to cancel the batch; the resolved
64
- * value is passed to every run() call.
69
+ * value is passed to every run() call. Ask permission in `confirm`,
70
+ * not here — a null from prepare means "nothing collected".
65
71
  */
66
72
  prepare?: (rows: T[]) => Promise<unknown | null>;
67
73
  }
@@ -449,7 +455,9 @@ export function ModelTable<T extends { id: RowId }>({
449
455
  const runBatch = async (action: BatchAction<T>) => {
450
456
  const targets = rows.filter((r) => selected.has(r.id) && (!action.eligible || action.eligible(r)));
451
457
  if (targets.length === 0) return;
452
- if (action.confirm !== false) {
458
+ if (typeof action.confirm === 'function') {
459
+ if (!(await action.confirm(targets))) return;
460
+ } else if (action.confirm !== false) {
453
461
  const ok = await modal.confirm({
454
462
  title: action.label,
455
463
  message: action.confirm ?? `${action.label} ${targets.length} item(s)?`,
@@ -18,9 +18,14 @@
18
18
  // · Controlled on `metadata` (architecture rule 5): the component never
19
19
  // mirrors server state. `onSaved` is REQUIRED — it is how the owner
20
20
  // (TanStack cache, parent state) learns about the write.
21
+ // · Every save posts the WHOLE blob, so one bad write can wipe a protected
22
+ // key (auth_config / geofence / redemption_policy) in a single POST.
23
+ // Removing a top-level key therefore stops on a guardrail that lists
24
+ // the keys about to vanish; owners add `beforeSave(next, prev)` to diff
25
+ // their own protected keys on top.
21
26
  import { useState } from 'react';
22
27
  import { mojoCall } from '../../client/client';
23
- import { modal } from '../modal';
28
+ import { confirmGuardrail } from '../guardrail';
24
29
  import { toast } from '../toast';
25
30
 
26
31
  type Draft =
@@ -49,13 +54,42 @@ function parseValue(raw: string): unknown {
49
54
  }
50
55
  }
51
56
 
52
- export function MetadataSection({ endpoint, id, metadata, onSaved, title = 'Metadata' }: {
57
+ /**
58
+ * Default stop: a top-level key is about to leave the blob. Names every key
59
+ * so a wipe of `auth_config` reads as exactly that, not "Remove entry?".
60
+ */
61
+ function confirmRemovedKeys(removed: string[], next: Record<string, unknown>): Promise<boolean> {
62
+ const one = removed.length === 1;
63
+ const remaining = Object.keys(next).length;
64
+ return confirmGuardrail({
65
+ title: one ? `Remove metadata key ${removed[0]}?` : `Remove ${removed.length} metadata keys?`,
66
+ effect: <>
67
+ {removed.map((key, i) => <span key={key}>{i > 0 ? ', ' : ''}<code>{key}</code></span>)}
68
+ {one ? ' leaves' : ' leave'} the record the moment this saves — the POST carries the whole blob, {remaining === 0 ? 'and it will be empty' : `${remaining} key${remaining === 1 ? '' : 's'} remain`}.
69
+ </>,
70
+ why: [
71
+ <>Anything that reads {one ? 'this key' : 'these keys'} (policy, auth configuration, geofences, redemption rules) sees it missing on its next read, with no error raised here.</>,
72
+ <>The value is not kept anywhere in this portal — re-adding the key means re-entering it by hand.</>,
73
+ ],
74
+ undo: 'There is no undo; re-add the key with its previous value.',
75
+ confirmText: one ? 'Remove key' : `Remove ${removed.length} keys`,
76
+ typeToConfirm: removed.length > 1 ? String(removed.length) : undefined,
77
+ });
78
+ }
79
+
80
+ export function MetadataSection({ endpoint, id, metadata, onSaved, beforeSave, title = 'Metadata' }: {
53
81
  /** Collection endpoint — the save posts to `<endpoint>/<id>`. */
54
82
  endpoint: string;
55
83
  id: number | string;
56
84
  metadata: Record<string, unknown>;
57
85
  /** Called with the new blob after the server accepted it. */
58
86
  onSaved: (next: Record<string, unknown>) => void;
87
+ /**
88
+ * Owner gate, after the built-in removed-key stop: resolve false to
89
+ * cancel the write (no error, editor state kept). Diff protected keys
90
+ * between `next` and `prev` here.
91
+ */
92
+ beforeSave?: (next: Record<string, unknown>, prev: Record<string, unknown>) => Promise<boolean> | boolean;
59
93
  title?: string;
60
94
  }) {
61
95
  const [draft, setDraft] = useState<Draft>(null);
@@ -65,8 +99,16 @@ export function MetadataSection({ endpoint, id, metadata, onSaved, title = 'Meta
65
99
  const keys = Object.keys(metadata).sort();
66
100
  const busy = pending !== null;
67
101
 
68
- /** The one write path. Resolves true only when the server accepted it. */
102
+ /**
103
+ * The one write path. Resolves true only when the server accepted it;
104
+ * false when a gate declined (nothing posted, no error) or the POST
105
+ * rejected (banner). Gates run BEFORE pending so the dialog is not
106
+ * stacked over disabled controls.
107
+ */
69
108
  async function commit(next: Record<string, unknown>, token: string, message: string): Promise<boolean> {
109
+ const removed = Object.keys(metadata).filter((key) => !Object.prototype.hasOwnProperty.call(next, key));
110
+ if (removed.length > 0 && !(await confirmRemovedKeys(removed, next))) return false;
111
+ if (beforeSave && !(await beforeSave(next, metadata))) return false;
70
112
  setPending(token);
71
113
  setError(null);
72
114
  try {
@@ -101,13 +143,7 @@ export function MetadataSection({ endpoint, id, metadata, onSaved, title = 'Meta
101
143
  }
102
144
 
103
145
  async function removeKey(key: string) {
104
- const confirmed = await modal.confirm({
105
- title: 'Remove entry',
106
- message: <>Remove metadata key <b>{key}</b>?</>,
107
- confirmText: 'Remove',
108
- danger: true,
109
- });
110
- if (!confirmed) return;
146
+ // The removed-key guardrail inside commit() is the confirm.
111
147
  const next = { ...metadata };
112
148
  delete next[key];
113
149
  await commit(next, `delete:${key}`, 'Metadata entry removed');
@@ -20,6 +20,12 @@
20
20
  // · zod failure: the save of that field is blocked client-side; the message
21
21
  // occupies the same error slot. Hidden (showWhen) fields never queue and
22
22
  // their transient state clears.
23
+ // · beforeSave (optional): runs once per batch BEFORE the POST, with the
24
+ // wire body. While it awaits the batch counts as in flight (the timer
25
+ // cannot re-fire; a commit meanwhile joins the NEXT batch). `false`
26
+ // DROPS the batch: its names revert to the server snapshot and go idle
27
+ // — no error, no toast (a guardrail the operator declined is not a
28
+ // failure). An object REPLACES the body. true/undefined proceeds.
23
29
  //
24
30
  // Shape: ALL machine state lives in ONE reducer (draft values, server
25
31
  // snapshot, per-field status, the pending batch, the in-flight flag) so every
@@ -168,6 +174,8 @@ export type AutosaveAction =
168
174
  | { type: 'SAVE_OK'; names: string[]; sent: Record<string, FieldValue>; server: FieldValues }
169
175
  /** Batch rejected: REVERT names to the snapshot, pin the server message. */
170
176
  | { type: 'SAVE_FAIL'; names: string[]; error: string }
177
+ /** beforeSave vetoed: REVERT names to the snapshot, status idle, no error. */
178
+ | { type: 'BATCH_DROP'; names: string[] }
171
179
  /** The ~1.5s saved flash ended. */
172
180
  | { type: 'FLASH_DONE'; name: string }
173
181
  /** The row prop changed (refetch/invalidate) or fields arrived late. */
@@ -366,6 +374,22 @@ export function autosaveReducer(state: AutosaveState, action: AutosaveAction): A
366
374
  return { ...state, draft, pending, fieldState, fieldError, inflight: false };
367
375
  }
368
376
 
377
+ case 'BATCH_DROP': {
378
+ const draft = { ...state.draft };
379
+ const fieldState = { ...state.fieldState };
380
+ const fieldError = { ...state.fieldError };
381
+ for (const n of action.names) {
382
+ // A field re-committed while beforeSave awaited belongs to the
383
+ // NEXT batch (SAVE_OK precedent) — the veto covers only what
384
+ // this batch carried.
385
+ if (n in state.pending) continue;
386
+ if (n in state.server) draft[n] = state.server[n]!;
387
+ fieldState[n] = 'idle';
388
+ fieldError[n] = null;
389
+ }
390
+ return { ...state, draft, fieldState, fieldError, inflight: false };
391
+ }
392
+
369
393
  case 'FLASH_DONE': {
370
394
  if (state.fieldState[action.name] !== 'saved') return state;
371
395
  return { ...state, fieldState: { ...state.fieldState, [action.name]: 'idle' } };
@@ -387,6 +411,83 @@ export function autosaveReducer(state: AutosaveState, action: AutosaveAction): A
387
411
  }
388
412
  }
389
413
 
414
+ // ── The batch runner ──────────────────────────────────────────────────
415
+ // Pure orchestration over the reducer — exported so the contract (veto /
416
+ // amend / concurrent commit / no-hook parity) is unit-testable without a
417
+ // DOM (scripts/verify-form-autosave.mjs). The hook binds it to its refs.
418
+
419
+ export interface AutosaveBatchRunnerDeps<T> {
420
+ /** The state snapshot at fire time. */
421
+ state: AutosaveState;
422
+ /** Current options, captured once for this batch and its settlement. */
423
+ opts: () => Pick<UseFormAutosaveOptions<T>, 'fields' | 'row' | 'save' | 'beforeSave' | 'onSaved' | 'onSaveError'>;
424
+ dispatch: (action: AutosaveAction) => void;
425
+ /** Re-arm the batch window (drain anything committed mid-flight). */
426
+ armBatchTimer: () => void;
427
+ /** Start a field's saved flash. */
428
+ armFlashTimer: (name: string) => void;
429
+ }
430
+
431
+ /**
432
+ * Fire one batch from `state.pending`. Resolves when the batch has settled
433
+ * (saved, failed, or dropped); resolves immediately when nothing fires
434
+ * (in flight, or nothing pending).
435
+ */
436
+ export function runAutosaveBatch<T>(deps: AutosaveBatchRunnerDeps<T>): Promise<void> {
437
+ const { state: s, opts, dispatch, armBatchTimer, armFlashTimer } = deps;
438
+ if (s.inflight) return Promise.resolve(); // completion re-arms the timer (serialize)
439
+ const names = Object.keys(s.pending);
440
+ if (names.length === 0) return Promise.resolve();
441
+ const sent: Record<string, FieldValue> = { ...s.pending };
442
+ const changes = expandDotted(sent);
443
+ // The approval, write target and callbacks belong to the same batch.
444
+ // Reading opts again after a gate can redirect an approved write to a
445
+ // different row/model rendered while the confirmation was open.
446
+ const batch = opts();
447
+ dispatch({ type: 'BATCH_START', names });
448
+
449
+ const post = (body: Record<string, unknown>): Promise<void> => batch.save(body).then(
450
+ (savedRow) => {
451
+ const server = serverValuesFor(batch.fields, savedRow);
452
+ dispatch({ type: 'SAVE_OK', names, sent, server });
453
+ for (const n of names) armFlashTimer(n);
454
+ batch.onSaved?.({ changes: body, fields: names, row: savedRow });
455
+ armBatchTimer(); // drain anything committed mid-flight
456
+ },
457
+ (err: unknown) => fail(body, err),
458
+ );
459
+ const fail = (body: Record<string, unknown>, err: unknown) => {
460
+ const error = err instanceof Error ? err : new Error('Save failed');
461
+ dispatch({ type: 'SAVE_FAIL', names, error: error.message });
462
+ batch.onSaveError?.({ changes: body, fields: names, error });
463
+ armBatchTimer();
464
+ };
465
+
466
+ const beforeSave = batch.beforeSave;
467
+ if (!beforeSave) return post(changes); // no hook: the pre-existing path, unchanged
468
+
469
+ // The gate. BATCH_START already flagged inflight, so the window cannot
470
+ // re-fire while this awaits and commits meanwhile queue the NEXT batch.
471
+ let verdict: Promise<boolean | void | Record<string, unknown>>;
472
+ try {
473
+ verdict = Promise.resolve(beforeSave({ changes, names, row: batch.row }));
474
+ } catch (err) {
475
+ verdict = Promise.reject(err);
476
+ }
477
+ return verdict.then(
478
+ (v) => {
479
+ if (v === false) {
480
+ dispatch({ type: 'BATCH_DROP', names });
481
+ armBatchTimer(); // a commit made during the await fires next
482
+ return;
483
+ }
484
+ const body = v !== null && typeof v === 'object' ? v : changes;
485
+ return post(body);
486
+ },
487
+ (err: unknown) => fail(changes, err),
488
+ );
489
+ }
490
+
390
491
  // ── The hook ──────────────────────────────────────────────────────────
391
492
 
392
493
  export interface AutosaveBatchInfo {
@@ -396,6 +497,24 @@ export interface AutosaveBatchInfo {
396
497
  fields: string[];
397
498
  }
398
499
 
500
+ export interface AutosaveBeforeSaveInfo<T> {
501
+ /** The wire body about to POST (dotted names expanded). */
502
+ changes: Record<string, unknown>;
503
+ /** Flat field names the batch carries (`permissions.manage_group`, …). */
504
+ names: string[];
505
+ /** The server row the batch edits. */
506
+ row: T;
507
+ }
508
+
509
+ /**
510
+ * Pre-save gate. `false` drops the batch (fields revert to server values,
511
+ * status → idle, no error toast); an object replaces `changes`; `true` /
512
+ * `undefined` proceeds. A thrown/rejected hook is a bug, not a veto — it is
513
+ * surfaced like a failed save (revert + pinned error + onSaveError).
514
+ */
515
+ export type AutosaveBeforeSave<T> = (info: AutosaveBeforeSaveInfo<T>) =>
516
+ boolean | void | Record<string, unknown> | Promise<boolean | void | Record<string, unknown>>;
517
+
399
518
  export interface UseFormAutosaveOptions<T> {
400
519
  /** EVERY saveable field — flat fields plus every tab's fields. */
401
520
  fields: Field[];
@@ -403,6 +522,14 @@ export interface UseFormAutosaveOptions<T> {
403
522
  row: T;
404
523
  /** One batch POST. MUST reject on failure (defineModel useSave contract). */
405
524
  save: (changes: Record<string, unknown>) => Promise<T>;
525
+ /**
526
+ * Runs before a batch POSTs. Return false to drop the batch (fields
527
+ * revert to server values, status → idle, no error toast); return an
528
+ * object to replace `changes`; true/undefined to proceed. While it awaits
529
+ * the batch is in flight: the timer cannot re-fire and a commit meanwhile
530
+ * joins the NEXT batch.
531
+ */
532
+ beforeSave?: AutosaveBeforeSave<T>;
406
533
  /** Batch window after the last commit (web-mojo: 300). */
407
534
  debounceMs?: number;
408
535
  /** How long the 'saved' check shows before returning to idle. */
@@ -485,29 +612,13 @@ export function useFormAutosave<T>(opts: UseFormAutosaveOptions<T>): FormAutosav
485
612
 
486
613
  // ── the batch runner ──────────────────────────────────────────────
487
614
  const fireBatch = useCallback(() => {
488
- const s = stateRef.current;
489
- if (s.inflight) return; // completion re-arms the timer (serialize)
490
- const names = Object.keys(s.pending);
491
- if (names.length === 0) return;
492
- const sent: Record<string, FieldValue> = { ...s.pending };
493
- const changes = expandDotted(sent);
494
- dispatch({ type: 'BATCH_START', names });
495
-
496
- optsRef.current.save(changes).then(
497
- (savedRow) => {
498
- const server = serverValuesFor(optsRef.current.fields, savedRow);
499
- dispatch({ type: 'SAVE_OK', names, sent, server });
500
- for (const n of names) armFlashTimer(n);
501
- optsRef.current.onSaved?.({ changes, fields: names, row: savedRow });
502
- armBatchTimer(); // drain anything committed mid-flight
503
- },
504
- (err: unknown) => {
505
- const error = err instanceof Error ? err : new Error('Save failed');
506
- dispatch({ type: 'SAVE_FAIL', names, error: error.message });
507
- optsRef.current.onSaveError?.({ changes, fields: names, error });
508
- armBatchTimer();
509
- },
510
- );
615
+ void runAutosaveBatch<T>({
616
+ state: stateRef.current,
617
+ opts: () => optsRef.current,
618
+ dispatch,
619
+ armBatchTimer,
620
+ armFlashTimer,
621
+ });
511
622
  }, [armBatchTimer, armFlashTimer]);
512
623
  fireBatchRef.current = fireBatch;
513
624
 
@@ -0,0 +1,125 @@
1
+ // Guardrail — the "are you sure, and here is WHY" stop in front of a change
2
+ // that takes a tenant dark, moves money, or cannot be undone if made by
3
+ // accident. One shape for every breaking knob so the operator learns to read
4
+ // it: what happens the moment it lands, why that is dangerous (concrete
5
+ // consequences, not adjectives), how to get back — and, for the sharpest
6
+ // knobs, a typed literal before the button arms.
7
+ //
8
+ // Promoted from wmx-admin-v2 `src/lib/guardrail.tsx` (bc3e900) verbatim in
9
+ // contract and markup so the app's copy keeps working when it swaps the
10
+ // import. Built on the native-<dialog> `modal.open` stack, so it stacks
11
+ // correctly over a formModal that is still open.
12
+ //
13
+ // Which stop to reach for:
14
+ // · `modal.confirm` — reversible / low blast radius; a yes-no.
15
+ // · `ArmedButton` — irreversible, needs no input, inline (row actions).
16
+ // · `confirmGuardrail` — anything that takes a tenant dark, moves money, or
17
+ // can't be undone — the operator must read WHY.
18
+ //
19
+ // Usage — `if (!(await confirmGuardrail({...}))) return;` before the mutation.
20
+ // The dialog resolves false on Escape / backdrop / Cancel, true only on the
21
+ // armed confirm button.
22
+ import { useEffect, useRef, useState, type ReactNode } from 'react';
23
+ import { modal } from './modal';
24
+
25
+ export interface Guardrail {
26
+ /** Question form, naming the target: "Disable Alea for Club Axo?" */
27
+ title: string;
28
+ /** What happens the moment this lands — present tense, one or two sentences. */
29
+ effect: ReactNode;
30
+ /** Why it is dangerous — each item one concrete consequence. */
31
+ why: ReactNode[];
32
+ /** How to get back, or that you can't. */
33
+ undo?: ReactNode;
34
+ confirmText: string;
35
+ cancelText?: string;
36
+ /**
37
+ * Highest tier: the operator must type this literal (the brand name, a
38
+ * count, "DISABLE") before the confirm button arms. Compared trimmed and
39
+ * case-insensitively.
40
+ */
41
+ typeToConfirm?: string;
42
+ /** false = primary button + warn tint for a stop that is not destructive. Default true. */
43
+ danger?: boolean;
44
+ }
45
+
46
+ function GuardrailBody({ g, close }: { g: Guardrail; close: (v: boolean) => void }) {
47
+ const [typed, setTyped] = useState('');
48
+ const inputRef = useRef<HTMLInputElement>(null);
49
+ const needsTyping = Boolean(g.typeToConfirm);
50
+ const armed = !needsTyping || typed.trim().toLowerCase() === g.typeToConfirm!.trim().toLowerCase();
51
+ const danger = g.danger !== false;
52
+
53
+ useEffect(() => {
54
+ if (needsTyping) inputRef.current?.focus();
55
+ }, [needsTyping]);
56
+
57
+ return (
58
+ <div className={danger ? 'modal-pad guardrail' : 'modal-pad guardrail guardrail-warn'}>
59
+ <h2 className="modal-title">
60
+ <i className={`bi ${danger ? 'bi-exclamation-octagon-fill' : 'bi-exclamation-triangle-fill'} guardrail-icon`} /> {g.title}
61
+ </h2>
62
+ <div className="modal-message">
63
+ <p className="guardrail-effect">{g.effect}</p>
64
+ <div className="guardrail-why">
65
+ <div className="guardrail-why-head">Why this is dangerous</div>
66
+ <ul>
67
+ {g.why.map((w, i) => <li key={i}>{w}</li>)}
68
+ </ul>
69
+ </div>
70
+ {g.undo && (
71
+ <p className="guardrail-undo"><i className="bi bi-arrow-counterclockwise" /> {g.undo}</p>
72
+ )}
73
+ {needsTyping && (
74
+ <label className="guardrail-type">
75
+ <span>Type <b>{g.typeToConfirm}</b> to confirm</span>
76
+ <input
77
+ ref={inputRef}
78
+ className="input"
79
+ value={typed}
80
+ autoComplete="off"
81
+ spellCheck={false}
82
+ onChange={(e) => setTyped(e.target.value)}
83
+ onKeyDown={(e) => { if (e.key === 'Enter' && armed) close(true); }}
84
+ />
85
+ </label>
86
+ )}
87
+ </div>
88
+ <div className="modal-actions">
89
+ <button type="button" className="btn" onClick={() => close(false)}>{g.cancelText ?? 'Cancel'}</button>
90
+ <button
91
+ type="button"
92
+ className={danger ? 'btn btn-danger' : 'btn btn-primary'}
93
+ disabled={!armed}
94
+ onClick={() => close(true)}
95
+ >
96
+ {g.confirmText}
97
+ </button>
98
+ </div>
99
+ </div>
100
+ );
101
+ }
102
+
103
+ /** Open the guardrail and resolve true only when the operator confirms. */
104
+ export function confirmGuardrail(g: Guardrail): Promise<boolean> {
105
+ return modal.open<boolean>((close) => <GuardrailBody g={g} close={close} />, { size: 'sm' })
106
+ .then((v) => v === true);
107
+ }
108
+
109
+ /**
110
+ * Diff helper for config-form saves: which of the watched keys changed
111
+ * between what the form was seeded with and what came back. Lets a save
112
+ * confirm ONLY the fields that break things (environment, currency codes…)
113
+ * and pass silently otherwise.
114
+ */
115
+ export function changedKeys(
116
+ initial: Record<string, unknown>,
117
+ next: Record<string, unknown>,
118
+ watch: readonly string[],
119
+ ): string[] {
120
+ return watch.filter((k) => {
121
+ const a = initial[k]; const b = next[k];
122
+ const norm = (v: unknown) => (v == null || v === '' ? '' : String(v));
123
+ return norm(a) !== norm(b);
124
+ });
125
+ }
package/src/ui/index.ts CHANGED
@@ -9,6 +9,7 @@ export * from './RequiresGroup';
9
9
  export * from './menu-registry';
10
10
  export * from './SidebarNav';
11
11
  export * from './armed-button';
12
+ export * from './guardrail';
12
13
  export * from './loading';
13
14
  export * from './CollectionMultiSelect';
14
15
  export * from './CollectionSelect';
package/src/ui/toast.tsx CHANGED
@@ -18,7 +18,8 @@
18
18
  // Visual spec from web-mojo's toast.css (left accent bar, compact card,
19
19
  // bottom-right); idiom styles live in the app's theme/idioms.css — tokens
20
20
  // only, both themes.
21
- import { useSyncExternalStore } from 'react';
21
+ import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
22
+ import { createPortal } from 'react-dom';
22
23
 
23
24
  type Level = 'success' | 'error' | 'info' | 'warning';
24
25
 
@@ -187,8 +188,50 @@ export function ToastHost() {
187
188
  (cb) => { listeners.add(cb); return () => listeners.delete(cb); },
188
189
  () => toasts,
189
190
  );
190
- return (
191
- <div className="toast-host" role="status" aria-live="polite">
191
+ // Keep one portal target so moving above a new modal preserves live cards.
192
+ const [host] = useState(() => typeof document === 'undefined' ? null : document.createElement('div'));
193
+ const visible = useRef(false);
194
+ visible.current = items.length > 0;
195
+ const syncHost = useRef<() => void>(() => {});
196
+ useLayoutEffect(() => {
197
+ if (!host) return;
198
+ host.className = 'toast-host';
199
+ host.setAttribute('role', 'status');
200
+ host.setAttribute('aria-live', 'polite');
201
+ const supportsPopover = typeof host.showPopover === 'function';
202
+ if (supportsPopover) host.setAttribute('popover', 'manual');
203
+ let dialogs: HTMLDialogElement[] = [];
204
+ const sync = (records: MutationRecord[] = []) => {
205
+ const open = Array.from(document.querySelectorAll('dialog')).filter(dialog => dialog.matches(':modal'));
206
+ dialogs = dialogs.filter(dialog => open.includes(dialog));
207
+ for (const dialog of open) if (!dialogs.includes(dialog)) dialogs.push(dialog);
208
+ // Opening order, not DOM order, determines the active native modal.
209
+ for (const record of records) {
210
+ if (record.type !== 'attributes' || record.oldValue !== null || !(record.target instanceof HTMLDialogElement) || !open.includes(record.target)) continue;
211
+ dialogs = [...dialogs.filter(dialog => dialog !== record.target), record.target];
212
+ }
213
+ const parent = dialogs.at(-1) ?? document.body;
214
+ const moved = host.parentElement !== parent;
215
+ if (supportsPopover && host.matches(':popover-open') && (moved || !visible.current)) host.hidePopover();
216
+ // A body-level popover paints above dialogs but remains inert.
217
+ // Modal-local ancestry is required for Undo/Cancel and keyboard use.
218
+ if (moved) parent.appendChild(host);
219
+ if (supportsPopover && visible.current && !host.matches(':popover-open')) host.showPopover();
220
+ };
221
+ syncHost.current = sync;
222
+ const observer = new MutationObserver(sync);
223
+ observer.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['open'], attributeOldValue: true });
224
+ sync();
225
+ return () => {
226
+ observer.disconnect();
227
+ syncHost.current = () => {};
228
+ if (supportsPopover && host.matches(':popover-open')) host.hidePopover();
229
+ host.remove();
230
+ };
231
+ }, [host]);
232
+ useLayoutEffect(() => { syncHost.current(); }, [items.length > 0]);
233
+ return host ? createPortal(
234
+ <>
192
235
  {items.map((t) => {
193
236
  if (t.kind === 'basic') {
194
237
  return (
@@ -214,6 +257,6 @@ export function ToastHost() {
214
257
  }
215
258
  return <ProgressCard key={t.id} t={t} />;
216
259
  })}
217
- </div>
218
- );
260
+ </>, host,
261
+ ) : null;
219
262
  }