portal-mojo 0.2.2 → 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.
Files changed (82) hide show
  1. package/README.md +3 -0
  2. package/docs/DetailView.md +19 -15
  3. package/docs/ModelTable.md +26 -0
  4. package/docs/README.md +2 -1
  5. package/docs/admin-assistant.md +3 -1
  6. package/docs/admin-credentials.md +7 -0
  7. package/docs/admin-dashboard.md +1 -1
  8. package/docs/admin-dns.md +8 -2
  9. package/docs/admin-incidents.md +26 -3
  10. package/docs/admin-jobs.md +7 -4
  11. package/docs/admin-messaging.md +6 -5
  12. package/docs/admin-modals.md +122 -0
  13. package/docs/admin-network-security.md +4 -4
  14. package/docs/admin-phonehub.md +24 -6
  15. package/docs/admin-push.md +7 -0
  16. package/docs/admin-rules.md +8 -0
  17. package/docs/admin-shortlinks.md +2 -1
  18. package/docs/admin-storage.md +2 -1
  19. package/docs/auth.md +28 -0
  20. package/docs/client.md +12 -0
  21. package/docs/detail-primitives.md +12 -1
  22. package/docs/feedback.md +70 -1
  23. package/docs/forms.md +51 -0
  24. package/docs/loading.md +3 -2
  25. package/docs/popover.md +18 -3
  26. package/package.json +1 -1
  27. package/src/admin/assistant/api.ts +1 -1
  28. package/src/admin/assistant/pages.tsx +71 -17
  29. package/src/admin/assistant/skill-lifecycle.ts +9 -0
  30. package/src/admin/core/index.ts +7 -2
  31. package/src/admin/credentials/group-api-keys.tsx +67 -43
  32. package/src/admin/credentials/webhook-subscriptions.tsx +49 -40
  33. package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
  34. package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
  35. package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
  36. package/src/admin/dns/setup.ts +1 -1
  37. package/src/admin/domains/observability.ts +1 -1
  38. package/src/admin/identity/members/MemberDetail.tsx +48 -3
  39. package/src/admin/identity/users/UserDetail.tsx +36 -20
  40. package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
  41. package/src/admin/identity/users/sections/actions.tsx +20 -9
  42. package/src/admin/incidents/EventDetail.tsx +147 -5
  43. package/src/admin/incidents/IncidentDetail.tsx +181 -8
  44. package/src/admin/incidents/models.ts +1 -1
  45. package/src/admin/jobs/RunnerDetail.tsx +5 -8
  46. package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
  47. package/src/admin/jobs/index.ts +1 -1
  48. package/src/admin/jobs/purge-preview.ts +25 -0
  49. package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
  50. package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
  51. package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
  52. package/src/admin/messaging/MailboxesPage.tsx +3 -3
  53. package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
  54. package/src/admin/messaging/push/PushPage.tsx +20 -8
  55. package/src/admin/monitoring/LogInspector.tsx +3 -19
  56. package/src/admin/network/IPSetDetail.tsx +32 -63
  57. package/src/admin/phonehub/PhoneHubPage.tsx +285 -13
  58. package/src/admin/phonehub/SmsComposer.tsx +97 -0
  59. package/src/admin/phonehub/api.ts +11 -5
  60. package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
  61. package/src/admin/rules/condition-removal.ts +7 -0
  62. package/src/admin/security/geoip/models.ts +5 -0
  63. package/src/admin/security/tickets.tsx +40 -17
  64. package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
  65. package/src/admin/storage/BucketDetail.tsx +28 -9
  66. package/src/admin/storage/FileManagerDetail.tsx +23 -5
  67. package/src/admin/storage/FileView.tsx +28 -16
  68. package/src/admin/storage/FilesPage.tsx +4 -5
  69. package/src/client/auth.ts +10 -1
  70. package/src/client/client.ts +14 -6
  71. package/src/client/mock.ts +90 -6
  72. package/src/client/upload.ts +1 -0
  73. package/src/ui/DetailView.tsx +22 -5
  74. package/src/ui/FormView.tsx +24 -1
  75. package/src/ui/ModelTable.tsx +12 -4
  76. package/src/ui/Popover.tsx +13 -12
  77. package/src/ui/detail/MetadataSection.tsx +46 -10
  78. package/src/ui/form-autosave.ts +134 -23
  79. package/src/ui/guardrail.tsx +125 -0
  80. package/src/ui/index.ts +1 -0
  81. package/src/ui/modal.tsx +8 -4
  82. package/src/ui/toast.tsx +48 -5
@@ -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/modal.tsx CHANGED
@@ -2,7 +2,7 @@
2
2
  // native <dialog> + a portal-free host. `await modal.confirm(...)` from any
3
3
  // event handler; no JSX modal state to hoist. The z-index/backdrop stack
4
4
  // manager web-mojo needed does not exist here: <dialog> stacks natively.
5
- import { useEffect, useRef, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react';
5
+ import { useLayoutEffect, useRef, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react';
6
6
 
7
7
  export type ModalSize = 'sm' | 'md' | 'lg';
8
8
 
@@ -200,8 +200,11 @@ export const modal = {
200
200
  function ModalDialog({ item }: { item: ModalItem }) {
201
201
  const ref = useRef<HTMLDialogElement>(null);
202
202
 
203
- useEffect(() => {
204
- ref.current?.showModal();
203
+ useLayoutEffect(() => {
204
+ const dialog = ref.current;
205
+ const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
206
+ dialog?.showModal();
207
+ return () => { if (dialog?.open) dialog.close(); if (opener?.isConnected) opener.focus(); };
205
208
  }, []);
206
209
 
207
210
  const dismiss = () => {
@@ -216,6 +219,7 @@ function ModalDialog({ item }: { item: ModalItem }) {
216
219
  onCancel={(e) => { e.preventDefault(); dismiss(); }}
217
220
  onMouseDown={(e) => { if (e.target === ref.current) dismiss(); }}
218
221
  >
222
+ {item.flush && <button type="button" className="btn-icon modal-fallback-close" aria-label="Close" onClick={dismiss}><i className="bi bi-x-lg" /></button>}
219
223
  {item.render(item.resolve)}
220
224
  </dialog>
221
225
  );
@@ -224,7 +228,7 @@ function ModalDialog({ item }: { item: ModalItem }) {
224
228
  function DrawerDialog({ item }: { item: DrawerItem }) {
225
229
  const ref = useRef<HTMLDialogElement>(null);
226
230
 
227
- useEffect(() => {
231
+ useLayoutEffect(() => {
228
232
  const el = ref.current;
229
233
  el?.showModal();
230
234
  // Unmount is the END of the exit animation (the manager holds the item
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
  }