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 +3 -0
- package/docs/ModelTable.md +26 -0
- package/docs/README.md +1 -1
- package/docs/admin-phonehub.md +11 -1
- package/docs/detail-primitives.md +12 -1
- package/docs/feedback.md +60 -1
- package/docs/forms.md +41 -0
- package/docs/loading.md +3 -2
- package/docs/popover.md +3 -3
- package/package.json +1 -1
- package/src/admin/credentials/group-api-keys.tsx +20 -0
- package/src/admin/credentials/webhook-subscriptions.tsx +21 -0
- package/src/admin/identity/members/MemberDetail.tsx +48 -3
- package/src/admin/phonehub/PhoneHubPage.tsx +19 -8
- package/src/admin/phonehub/api.ts +5 -5
- package/src/client/mock.ts +1 -1
- package/src/ui/FormView.tsx +24 -1
- package/src/ui/ModelTable.tsx +12 -4
- package/src/ui/detail/MetadataSection.tsx +46 -10
- package/src/ui/form-autosave.ts +134 -23
- package/src/ui/guardrail.tsx +125 -0
- package/src/ui/index.ts +1 -0
- 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
|
package/docs/ModelTable.md
CHANGED
|
@@ -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 |
|
package/docs/admin-phonehub.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
242
|
-
|
|
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
|
-
- **
|
|
121
|
-
|
|
122
|
-
|
|
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
|
+
"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
|
-
?
|
|
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 '
|
|
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
|
-
|
|
296
|
+
setTestResult(result);
|
|
297
|
+
toast[result.testMode ? 'warning' : result.status ? 'success' : 'error'](result.message);
|
|
295
298
|
} catch (reason) {
|
|
296
|
-
|
|
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"
|
|
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
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
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
|
-
|
|
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});
|
package/src/client/mock.ts
CHANGED
|
@@ -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?{
|
|
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>{
|
package/src/ui/FormView.tsx
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/ui/ModelTable.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
60
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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');
|
package/src/ui/form-autosave.ts
CHANGED
|
@@ -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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
218
|
-
);
|
|
260
|
+
</>, host,
|
|
261
|
+
) : null;
|
|
219
262
|
}
|