portal-mojo 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +3 -0
  2. package/docs/ModelTable.md +26 -0
  3. package/docs/README.md +3 -1
  4. package/docs/admin-fleet-configuration.md +50 -0
  5. package/docs/admin-phonehub.md +11 -1
  6. package/docs/admin-push.md +85 -6
  7. package/docs/admin-settings.md +10 -0
  8. package/docs/admin-signin.md +73 -0
  9. package/docs/detail-primitives.md +12 -1
  10. package/docs/feedback.md +60 -1
  11. package/docs/forms.md +41 -0
  12. package/docs/loading.md +3 -2
  13. package/docs/popover.md +3 -3
  14. package/docs/realtime.md +5 -0
  15. package/package.json +1 -1
  16. package/src/admin/credentials/group-api-keys.tsx +20 -0
  17. package/src/admin/credentials/webhook-subscriptions.tsx +21 -0
  18. package/src/admin/domains/identity.ts +7 -1
  19. package/src/admin/domains/operations.ts +8 -1
  20. package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +171 -0
  21. package/src/admin/fleet-configuration/data.ts +89 -0
  22. package/src/admin/identity/members/MemberDetail.tsx +48 -3
  23. package/src/admin/index.ts +3 -0
  24. package/src/admin/messaging/push/PushPage.tsx +61 -13
  25. package/src/admin/messaging/push/PushTestDialog.tsx +59 -0
  26. package/src/admin/messaging/push/api.ts +25 -1
  27. package/src/admin/messaging/push/models.ts +7 -6
  28. package/src/admin/phonehub/PhoneHubPage.tsx +19 -8
  29. package/src/admin/phonehub/api.ts +5 -5
  30. package/src/admin/public/identity.ts +1 -0
  31. package/src/admin/public/operations.ts +1 -0
  32. package/src/admin/registry.ts +4 -2
  33. package/src/admin/settings/SettingDetail.tsx +2 -1
  34. package/src/admin/settings/SettingValue.tsx +37 -0
  35. package/src/admin/settings/SettingsPage.tsx +2 -1
  36. package/src/admin/signin/SigninPage.tsx +397 -0
  37. package/src/admin/signin/api.ts +83 -0
  38. package/src/admin/signin/index.ts +4 -0
  39. package/src/client/fleet-mock.ts +61 -0
  40. package/src/client/mock.ts +240 -11
  41. package/src/client/realtime.ts +12 -0
  42. package/src/ui/FormView.tsx +24 -1
  43. package/src/ui/ModelTable.tsx +12 -4
  44. package/src/ui/detail/MetadataSection.tsx +46 -10
  45. package/src/ui/form-autosave.ts +134 -23
  46. package/src/ui/guardrail.tsx +125 -0
  47. package/src/ui/index.ts +1 -0
  48. package/src/ui/toast.tsx +48 -5
@@ -20,6 +20,12 @@
20
20
  // · zod failure: the save of that field is blocked client-side; the message
21
21
  // occupies the same error slot. Hidden (showWhen) fields never queue and
22
22
  // their transient state clears.
23
+ // · beforeSave (optional): runs once per batch BEFORE the POST, with the
24
+ // wire body. While it awaits the batch counts as in flight (the timer
25
+ // cannot re-fire; a commit meanwhile joins the NEXT batch). `false`
26
+ // DROPS the batch: its names revert to the server snapshot and go idle
27
+ // — no error, no toast (a guardrail the operator declined is not a
28
+ // failure). An object REPLACES the body. true/undefined proceeds.
23
29
  //
24
30
  // Shape: ALL machine state lives in ONE reducer (draft values, server
25
31
  // snapshot, per-field status, the pending batch, the in-flight flag) so every
@@ -168,6 +174,8 @@ export type AutosaveAction =
168
174
  | { type: 'SAVE_OK'; names: string[]; sent: Record<string, FieldValue>; server: FieldValues }
169
175
  /** Batch rejected: REVERT names to the snapshot, pin the server message. */
170
176
  | { type: 'SAVE_FAIL'; names: string[]; error: string }
177
+ /** beforeSave vetoed: REVERT names to the snapshot, status idle, no error. */
178
+ | { type: 'BATCH_DROP'; names: string[] }
171
179
  /** The ~1.5s saved flash ended. */
172
180
  | { type: 'FLASH_DONE'; name: string }
173
181
  /** The row prop changed (refetch/invalidate) or fields arrived late. */
@@ -366,6 +374,22 @@ export function autosaveReducer(state: AutosaveState, action: AutosaveAction): A
366
374
  return { ...state, draft, pending, fieldState, fieldError, inflight: false };
367
375
  }
368
376
 
377
+ case 'BATCH_DROP': {
378
+ const draft = { ...state.draft };
379
+ const fieldState = { ...state.fieldState };
380
+ const fieldError = { ...state.fieldError };
381
+ for (const n of action.names) {
382
+ // A field re-committed while beforeSave awaited belongs to the
383
+ // NEXT batch (SAVE_OK precedent) — the veto covers only what
384
+ // this batch carried.
385
+ if (n in state.pending) continue;
386
+ if (n in state.server) draft[n] = state.server[n]!;
387
+ fieldState[n] = 'idle';
388
+ fieldError[n] = null;
389
+ }
390
+ return { ...state, draft, fieldState, fieldError, inflight: false };
391
+ }
392
+
369
393
  case 'FLASH_DONE': {
370
394
  if (state.fieldState[action.name] !== 'saved') return state;
371
395
  return { ...state, fieldState: { ...state.fieldState, [action.name]: 'idle' } };
@@ -387,6 +411,83 @@ export function autosaveReducer(state: AutosaveState, action: AutosaveAction): A
387
411
  }
388
412
  }
389
413
 
414
+ // ── The batch runner ──────────────────────────────────────────────────
415
+ // Pure orchestration over the reducer — exported so the contract (veto /
416
+ // amend / concurrent commit / no-hook parity) is unit-testable without a
417
+ // DOM (scripts/verify-form-autosave.mjs). The hook binds it to its refs.
418
+
419
+ export interface AutosaveBatchRunnerDeps<T> {
420
+ /** The state snapshot at fire time. */
421
+ state: AutosaveState;
422
+ /** Current options, captured once for this batch and its settlement. */
423
+ opts: () => Pick<UseFormAutosaveOptions<T>, 'fields' | 'row' | 'save' | 'beforeSave' | 'onSaved' | 'onSaveError'>;
424
+ dispatch: (action: AutosaveAction) => void;
425
+ /** Re-arm the batch window (drain anything committed mid-flight). */
426
+ armBatchTimer: () => void;
427
+ /** Start a field's saved flash. */
428
+ armFlashTimer: (name: string) => void;
429
+ }
430
+
431
+ /**
432
+ * Fire one batch from `state.pending`. Resolves when the batch has settled
433
+ * (saved, failed, or dropped); resolves immediately when nothing fires
434
+ * (in flight, or nothing pending).
435
+ */
436
+ export function runAutosaveBatch<T>(deps: AutosaveBatchRunnerDeps<T>): Promise<void> {
437
+ const { state: s, opts, dispatch, armBatchTimer, armFlashTimer } = deps;
438
+ if (s.inflight) return Promise.resolve(); // completion re-arms the timer (serialize)
439
+ const names = Object.keys(s.pending);
440
+ if (names.length === 0) return Promise.resolve();
441
+ const sent: Record<string, FieldValue> = { ...s.pending };
442
+ const changes = expandDotted(sent);
443
+ // The approval, write target and callbacks belong to the same batch.
444
+ // Reading opts again after a gate can redirect an approved write to a
445
+ // different row/model rendered while the confirmation was open.
446
+ const batch = opts();
447
+ dispatch({ type: 'BATCH_START', names });
448
+
449
+ const post = (body: Record<string, unknown>): Promise<void> => batch.save(body).then(
450
+ (savedRow) => {
451
+ const server = serverValuesFor(batch.fields, savedRow);
452
+ dispatch({ type: 'SAVE_OK', names, sent, server });
453
+ for (const n of names) armFlashTimer(n);
454
+ batch.onSaved?.({ changes: body, fields: names, row: savedRow });
455
+ armBatchTimer(); // drain anything committed mid-flight
456
+ },
457
+ (err: unknown) => fail(body, err),
458
+ );
459
+ const fail = (body: Record<string, unknown>, err: unknown) => {
460
+ const error = err instanceof Error ? err : new Error('Save failed');
461
+ dispatch({ type: 'SAVE_FAIL', names, error: error.message });
462
+ batch.onSaveError?.({ changes: body, fields: names, error });
463
+ armBatchTimer();
464
+ };
465
+
466
+ const beforeSave = batch.beforeSave;
467
+ if (!beforeSave) return post(changes); // no hook: the pre-existing path, unchanged
468
+
469
+ // The gate. BATCH_START already flagged inflight, so the window cannot
470
+ // re-fire while this awaits and commits meanwhile queue the NEXT batch.
471
+ let verdict: Promise<boolean | void | Record<string, unknown>>;
472
+ try {
473
+ verdict = Promise.resolve(beforeSave({ changes, names, row: batch.row }));
474
+ } catch (err) {
475
+ verdict = Promise.reject(err);
476
+ }
477
+ return verdict.then(
478
+ (v) => {
479
+ if (v === false) {
480
+ dispatch({ type: 'BATCH_DROP', names });
481
+ armBatchTimer(); // a commit made during the await fires next
482
+ return;
483
+ }
484
+ const body = v !== null && typeof v === 'object' ? v : changes;
485
+ return post(body);
486
+ },
487
+ (err: unknown) => fail(changes, err),
488
+ );
489
+ }
490
+
390
491
  // ── The hook ──────────────────────────────────────────────────────────
391
492
 
392
493
  export interface AutosaveBatchInfo {
@@ -396,6 +497,24 @@ export interface AutosaveBatchInfo {
396
497
  fields: string[];
397
498
  }
398
499
 
500
+ export interface AutosaveBeforeSaveInfo<T> {
501
+ /** The wire body about to POST (dotted names expanded). */
502
+ changes: Record<string, unknown>;
503
+ /** Flat field names the batch carries (`permissions.manage_group`, …). */
504
+ names: string[];
505
+ /** The server row the batch edits. */
506
+ row: T;
507
+ }
508
+
509
+ /**
510
+ * Pre-save gate. `false` drops the batch (fields revert to server values,
511
+ * status → idle, no error toast); an object replaces `changes`; `true` /
512
+ * `undefined` proceeds. A thrown/rejected hook is a bug, not a veto — it is
513
+ * surfaced like a failed save (revert + pinned error + onSaveError).
514
+ */
515
+ export type AutosaveBeforeSave<T> = (info: AutosaveBeforeSaveInfo<T>) =>
516
+ boolean | void | Record<string, unknown> | Promise<boolean | void | Record<string, unknown>>;
517
+
399
518
  export interface UseFormAutosaveOptions<T> {
400
519
  /** EVERY saveable field — flat fields plus every tab's fields. */
401
520
  fields: Field[];
@@ -403,6 +522,14 @@ export interface UseFormAutosaveOptions<T> {
403
522
  row: T;
404
523
  /** One batch POST. MUST reject on failure (defineModel useSave contract). */
405
524
  save: (changes: Record<string, unknown>) => Promise<T>;
525
+ /**
526
+ * Runs before a batch POSTs. Return false to drop the batch (fields
527
+ * revert to server values, status → idle, no error toast); return an
528
+ * object to replace `changes`; true/undefined to proceed. While it awaits
529
+ * the batch is in flight: the timer cannot re-fire and a commit meanwhile
530
+ * joins the NEXT batch.
531
+ */
532
+ beforeSave?: AutosaveBeforeSave<T>;
406
533
  /** Batch window after the last commit (web-mojo: 300). */
407
534
  debounceMs?: number;
408
535
  /** How long the 'saved' check shows before returning to idle. */
@@ -485,29 +612,13 @@ export function useFormAutosave<T>(opts: UseFormAutosaveOptions<T>): FormAutosav
485
612
 
486
613
  // ── the batch runner ──────────────────────────────────────────────
487
614
  const fireBatch = useCallback(() => {
488
- const s = stateRef.current;
489
- if (s.inflight) return; // completion re-arms the timer (serialize)
490
- const names = Object.keys(s.pending);
491
- if (names.length === 0) return;
492
- const sent: Record<string, FieldValue> = { ...s.pending };
493
- const changes = expandDotted(sent);
494
- dispatch({ type: 'BATCH_START', names });
495
-
496
- optsRef.current.save(changes).then(
497
- (savedRow) => {
498
- const server = serverValuesFor(optsRef.current.fields, savedRow);
499
- dispatch({ type: 'SAVE_OK', names, sent, server });
500
- for (const n of names) armFlashTimer(n);
501
- optsRef.current.onSaved?.({ changes, fields: names, row: savedRow });
502
- armBatchTimer(); // drain anything committed mid-flight
503
- },
504
- (err: unknown) => {
505
- const error = err instanceof Error ? err : new Error('Save failed');
506
- dispatch({ type: 'SAVE_FAIL', names, error: error.message });
507
- optsRef.current.onSaveError?.({ changes, fields: names, error });
508
- armBatchTimer();
509
- },
510
- );
615
+ void runAutosaveBatch<T>({
616
+ state: stateRef.current,
617
+ opts: () => optsRef.current,
618
+ dispatch,
619
+ armBatchTimer,
620
+ armFlashTimer,
621
+ });
511
622
  }, [armBatchTimer, armFlashTimer]);
512
623
  fireBatchRef.current = fireBatch;
513
624
 
@@ -0,0 +1,125 @@
1
+ // Guardrail — the "are you sure, and here is WHY" stop in front of a change
2
+ // that takes a tenant dark, moves money, or cannot be undone if made by
3
+ // accident. One shape for every breaking knob so the operator learns to read
4
+ // it: what happens the moment it lands, why that is dangerous (concrete
5
+ // consequences, not adjectives), how to get back — and, for the sharpest
6
+ // knobs, a typed literal before the button arms.
7
+ //
8
+ // Promoted from wmx-admin-v2 `src/lib/guardrail.tsx` (bc3e900) verbatim in
9
+ // contract and markup so the app's copy keeps working when it swaps the
10
+ // import. Built on the native-<dialog> `modal.open` stack, so it stacks
11
+ // correctly over a formModal that is still open.
12
+ //
13
+ // Which stop to reach for:
14
+ // · `modal.confirm` — reversible / low blast radius; a yes-no.
15
+ // · `ArmedButton` — irreversible, needs no input, inline (row actions).
16
+ // · `confirmGuardrail` — anything that takes a tenant dark, moves money, or
17
+ // can't be undone — the operator must read WHY.
18
+ //
19
+ // Usage — `if (!(await confirmGuardrail({...}))) return;` before the mutation.
20
+ // The dialog resolves false on Escape / backdrop / Cancel, true only on the
21
+ // armed confirm button.
22
+ import { useEffect, useRef, useState, type ReactNode } from 'react';
23
+ import { modal } from './modal';
24
+
25
+ export interface Guardrail {
26
+ /** Question form, naming the target: "Disable Alea for Club Axo?" */
27
+ title: string;
28
+ /** What happens the moment this lands — present tense, one or two sentences. */
29
+ effect: ReactNode;
30
+ /** Why it is dangerous — each item one concrete consequence. */
31
+ why: ReactNode[];
32
+ /** How to get back, or that you can't. */
33
+ undo?: ReactNode;
34
+ confirmText: string;
35
+ cancelText?: string;
36
+ /**
37
+ * Highest tier: the operator must type this literal (the brand name, a
38
+ * count, "DISABLE") before the confirm button arms. Compared trimmed and
39
+ * case-insensitively.
40
+ */
41
+ typeToConfirm?: string;
42
+ /** false = primary button + warn tint for a stop that is not destructive. Default true. */
43
+ danger?: boolean;
44
+ }
45
+
46
+ function GuardrailBody({ g, close }: { g: Guardrail; close: (v: boolean) => void }) {
47
+ const [typed, setTyped] = useState('');
48
+ const inputRef = useRef<HTMLInputElement>(null);
49
+ const needsTyping = Boolean(g.typeToConfirm);
50
+ const armed = !needsTyping || typed.trim().toLowerCase() === g.typeToConfirm!.trim().toLowerCase();
51
+ const danger = g.danger !== false;
52
+
53
+ useEffect(() => {
54
+ if (needsTyping) inputRef.current?.focus();
55
+ }, [needsTyping]);
56
+
57
+ return (
58
+ <div className={danger ? 'modal-pad guardrail' : 'modal-pad guardrail guardrail-warn'}>
59
+ <h2 className="modal-title">
60
+ <i className={`bi ${danger ? 'bi-exclamation-octagon-fill' : 'bi-exclamation-triangle-fill'} guardrail-icon`} /> {g.title}
61
+ </h2>
62
+ <div className="modal-message">
63
+ <p className="guardrail-effect">{g.effect}</p>
64
+ <div className="guardrail-why">
65
+ <div className="guardrail-why-head">Why this is dangerous</div>
66
+ <ul>
67
+ {g.why.map((w, i) => <li key={i}>{w}</li>)}
68
+ </ul>
69
+ </div>
70
+ {g.undo && (
71
+ <p className="guardrail-undo"><i className="bi bi-arrow-counterclockwise" /> {g.undo}</p>
72
+ )}
73
+ {needsTyping && (
74
+ <label className="guardrail-type">
75
+ <span>Type <b>{g.typeToConfirm}</b> to confirm</span>
76
+ <input
77
+ ref={inputRef}
78
+ className="input"
79
+ value={typed}
80
+ autoComplete="off"
81
+ spellCheck={false}
82
+ onChange={(e) => setTyped(e.target.value)}
83
+ onKeyDown={(e) => { if (e.key === 'Enter' && armed) close(true); }}
84
+ />
85
+ </label>
86
+ )}
87
+ </div>
88
+ <div className="modal-actions">
89
+ <button type="button" className="btn" onClick={() => close(false)}>{g.cancelText ?? 'Cancel'}</button>
90
+ <button
91
+ type="button"
92
+ className={danger ? 'btn btn-danger' : 'btn btn-primary'}
93
+ disabled={!armed}
94
+ onClick={() => close(true)}
95
+ >
96
+ {g.confirmText}
97
+ </button>
98
+ </div>
99
+ </div>
100
+ );
101
+ }
102
+
103
+ /** Open the guardrail and resolve true only when the operator confirms. */
104
+ export function confirmGuardrail(g: Guardrail): Promise<boolean> {
105
+ return modal.open<boolean>((close) => <GuardrailBody g={g} close={close} />, { size: 'sm' })
106
+ .then((v) => v === true);
107
+ }
108
+
109
+ /**
110
+ * Diff helper for config-form saves: which of the watched keys changed
111
+ * between what the form was seeded with and what came back. Lets a save
112
+ * confirm ONLY the fields that break things (environment, currency codes…)
113
+ * and pass silently otherwise.
114
+ */
115
+ export function changedKeys(
116
+ initial: Record<string, unknown>,
117
+ next: Record<string, unknown>,
118
+ watch: readonly string[],
119
+ ): string[] {
120
+ return watch.filter((k) => {
121
+ const a = initial[k]; const b = next[k];
122
+ const norm = (v: unknown) => (v == null || v === '' ? '' : String(v));
123
+ return norm(a) !== norm(b);
124
+ });
125
+ }
package/src/ui/index.ts CHANGED
@@ -9,6 +9,7 @@ export * from './RequiresGroup';
9
9
  export * from './menu-registry';
10
10
  export * from './SidebarNav';
11
11
  export * from './armed-button';
12
+ export * from './guardrail';
12
13
  export * from './loading';
13
14
  export * from './CollectionMultiSelect';
14
15
  export * from './CollectionSelect';
package/src/ui/toast.tsx CHANGED
@@ -18,7 +18,8 @@
18
18
  // Visual spec from web-mojo's toast.css (left accent bar, compact card,
19
19
  // bottom-right); idiom styles live in the app's theme/idioms.css — tokens
20
20
  // only, both themes.
21
- import { useSyncExternalStore } from 'react';
21
+ import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
22
+ import { createPortal } from 'react-dom';
22
23
 
23
24
  type Level = 'success' | 'error' | 'info' | 'warning';
24
25
 
@@ -187,8 +188,50 @@ export function ToastHost() {
187
188
  (cb) => { listeners.add(cb); return () => listeners.delete(cb); },
188
189
  () => toasts,
189
190
  );
190
- return (
191
- <div className="toast-host" role="status" aria-live="polite">
191
+ // Keep one portal target so moving above a new modal preserves live cards.
192
+ const [host] = useState(() => typeof document === 'undefined' ? null : document.createElement('div'));
193
+ const visible = useRef(false);
194
+ visible.current = items.length > 0;
195
+ const syncHost = useRef<() => void>(() => {});
196
+ useLayoutEffect(() => {
197
+ if (!host) return;
198
+ host.className = 'toast-host';
199
+ host.setAttribute('role', 'status');
200
+ host.setAttribute('aria-live', 'polite');
201
+ const supportsPopover = typeof host.showPopover === 'function';
202
+ if (supportsPopover) host.setAttribute('popover', 'manual');
203
+ let dialogs: HTMLDialogElement[] = [];
204
+ const sync = (records: MutationRecord[] = []) => {
205
+ const open = Array.from(document.querySelectorAll('dialog')).filter(dialog => dialog.matches(':modal'));
206
+ dialogs = dialogs.filter(dialog => open.includes(dialog));
207
+ for (const dialog of open) if (!dialogs.includes(dialog)) dialogs.push(dialog);
208
+ // Opening order, not DOM order, determines the active native modal.
209
+ for (const record of records) {
210
+ if (record.type !== 'attributes' || record.oldValue !== null || !(record.target instanceof HTMLDialogElement) || !open.includes(record.target)) continue;
211
+ dialogs = [...dialogs.filter(dialog => dialog !== record.target), record.target];
212
+ }
213
+ const parent = dialogs.at(-1) ?? document.body;
214
+ const moved = host.parentElement !== parent;
215
+ if (supportsPopover && host.matches(':popover-open') && (moved || !visible.current)) host.hidePopover();
216
+ // A body-level popover paints above dialogs but remains inert.
217
+ // Modal-local ancestry is required for Undo/Cancel and keyboard use.
218
+ if (moved) parent.appendChild(host);
219
+ if (supportsPopover && visible.current && !host.matches(':popover-open')) host.showPopover();
220
+ };
221
+ syncHost.current = sync;
222
+ const observer = new MutationObserver(sync);
223
+ observer.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['open'], attributeOldValue: true });
224
+ sync();
225
+ return () => {
226
+ observer.disconnect();
227
+ syncHost.current = () => {};
228
+ if (supportsPopover && host.matches(':popover-open')) host.hidePopover();
229
+ host.remove();
230
+ };
231
+ }, [host]);
232
+ useLayoutEffect(() => { syncHost.current(); }, [items.length > 0]);
233
+ return host ? createPortal(
234
+ <>
192
235
  {items.map((t) => {
193
236
  if (t.kind === 'basic') {
194
237
  return (
@@ -214,6 +257,6 @@ export function ToastHost() {
214
257
  }
215
258
  return <ProgressCard key={t.id} t={t} />;
216
259
  })}
217
- </div>
218
- );
260
+ </>, host,
261
+ ) : null;
219
262
  }