portal-mojo 0.2.7 → 0.2.8

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/src/ui/index.ts CHANGED
@@ -30,6 +30,7 @@ export * from './MultiSelectDropdown';
30
30
  export * as dateFns from './date/fns';
31
31
  export * from './detail';
32
32
  export * from './modal';
33
+ export * from './secret-dialog';
33
34
  export * from './safe-node';
34
35
  export * from './RouteError';
35
36
  export * from './route-error';
package/src/ui/modal.tsx CHANGED
@@ -3,6 +3,7 @@
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
5
  import { useLayoutEffect, useRef, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react';
6
+ import { getAuthSnapshot, onAuth, subscribeAuth } from '../client/auth';
6
7
 
7
8
  export type ModalSize = 'sm' | 'md' | 'lg';
8
9
 
@@ -63,6 +64,50 @@ function emit() {
63
64
  listeners.forEach((fn) => fn());
64
65
  }
65
66
 
67
+ /**
68
+ * Close EVERY open modal/drawer, resolving each await with null. Bypasses
69
+ * canDismiss — this is the session ending, not the user dismissing.
70
+ */
71
+ function closeAll(): void {
72
+ if (stack.length === 0) return;
73
+ const open = stack;
74
+ stack = [];
75
+ emit();
76
+ // resolve() is each item's settled-guarded close; the item is already
77
+ // gone, so a drawer's exit animation has nothing left to play.
78
+ for (const item of open) item.resolve(null);
79
+ }
80
+
81
+ // The stack is module state, so it outlives the host that renders it: an
82
+ // app that mounts <ModalHost> under its auth guard unmounts it on sign-out
83
+ // and REMOUNTS it on the next sign-in — every dialog body would come back,
84
+ // re-run its mount effects, for whoever signed in. A session ending (this
85
+ // tab's logout / unauthorized) or a change of identity seen through storage
86
+ // (another tab signing out or in as someone else) therefore empties it.
87
+ // Wired on the first open (never at import: no storage reads in non-DOM
88
+ // consumers of the package).
89
+ let authWired = false;
90
+ function wireAuth(): void {
91
+ if (authWired || typeof window === 'undefined') return;
92
+ authWired = true;
93
+ // Best-effort: a harness or host that stubs the auth module (or has no
94
+ // storage) must never stop a modal from opening.
95
+ try {
96
+ onAuth('logout', closeAll);
97
+ onAuth('unauthorized', closeAll);
98
+ let seenUid: string | null = null;
99
+ const observe = () => {
100
+ try {
101
+ const snap = getAuthSnapshot();
102
+ if (seenUid != null && (!snap.authenticated || snap.uid !== seenUid)) closeAll();
103
+ seenUid = snap.authenticated ? snap.uid : null;
104
+ } catch { /* auth unavailable: nothing to observe */ }
105
+ };
106
+ observe();
107
+ subscribeAuth(observe);
108
+ } catch { /* auth module stubbed or unavailable: modals still open */ }
109
+ }
110
+
66
111
  export interface ModalOptions {
67
112
  size?: ModalSize;
68
113
  flush?: boolean;
@@ -71,6 +116,7 @@ export interface ModalOptions {
71
116
  }
72
117
 
73
118
  function open<T>(render: (close: (value: T) => void) => ReactNode, opts: ModalOptions = {}): Promise<T | null> {
119
+ wireAuth();
74
120
  return new Promise<T | null>((resolve) => {
75
121
  const id = nextId++;
76
122
  let settled = false;
@@ -120,6 +166,7 @@ function drawerWidthPx(width: DrawerWidth | undefined): number {
120
166
  }
121
167
 
122
168
  function drawer<T = unknown>(opts: DrawerOptions<T>): Promise<T | null> {
169
+ wireAuth();
123
170
  const body = opts.render ?? (() => opts.content ?? null);
124
171
  let dismissable = opts.dismissable ?? true;
125
172
  if (!dismissable && !opts.render) {
@@ -169,6 +216,9 @@ function drawer<T = unknown>(opts: DrawerOptions<T>): Promise<T | null> {
169
216
  export const modal = {
170
217
  open,
171
218
 
219
+ /** Close every open modal and drawer; each await resolves null. */
220
+ closeAll,
221
+
172
222
  /** Detail modal: large, flush body — the Modal.detail() envelope. */
173
223
  detail(render: (close: (value: unknown) => void) => ReactNode): Promise<unknown> {
174
224
  return open(render, { size: 'lg', flush: true });
@@ -212,11 +262,29 @@ function ModalDialog({ item }: { item: ModalItem }) {
212
262
  item.resolve(null);
213
263
  };
214
264
 
265
+ // The native dialog can close WITHOUT a cancelable cancel: Chromium's
266
+ // second Escape with no click in between (close-watcher anti-abuse) and
267
+ // Android back fire a non-cancelable cancel, then close. A locked item
268
+ // reopens; an unlocked one settles like any other dismiss.
269
+ const onNativeClose = () => {
270
+ const dialog = ref.current;
271
+ if (!dialog || dialog.open || !stack.some((m) => m.id === item.id)) return; // unmount / already settled
272
+ if (item.canDismiss?.() === false) {
273
+ dialog.showModal();
274
+ if (!dialog.contains(document.activeElement)) {
275
+ (dialog.querySelector<HTMLElement>('[autofocus], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])') ?? dialog).focus();
276
+ }
277
+ return;
278
+ }
279
+ item.resolve(null);
280
+ };
281
+
215
282
  return (
216
283
  <dialog
217
284
  ref={ref}
218
285
  className={`mojo-modal mojo-modal-${item.size}${item.flush ? ' mojo-modal-flush' : ''}`}
219
286
  onCancel={(e) => { e.preventDefault(); dismiss(); }}
287
+ onClose={onNativeClose}
220
288
  onMouseDown={(e) => { if (e.target === ref.current) dismiss(); }}
221
289
  >
222
290
  {item.flush && <button type="button" className="btn-icon modal-fallback-close" aria-label="Close" onClick={dismiss}><i className="bi bi-x-lg" /></button>}
@@ -238,6 +306,13 @@ function DrawerDialog({ item }: { item: DrawerItem }) {
238
306
  }, []);
239
307
 
240
308
  const dismiss = () => { if (item.dismissable) item.resolve(null); };
309
+ // Same non-cancelable close path as ModalDialog: a non-dismissable drawer reopens.
310
+ const onNativeClose = () => {
311
+ const el = ref.current;
312
+ if (!el || el.open || !stack.some((m) => m.id === item.id && m.variant === 'drawer' && !m.closing)) return;
313
+ if (!item.dismissable) { el.showModal(); return; }
314
+ item.resolve(null);
315
+ };
241
316
  const hasHead = item.dismissable || item.eyebrow != null || item.title != null || (item.meta?.length ?? 0) > 0;
242
317
 
243
318
  return (
@@ -247,6 +322,7 @@ function DrawerDialog({ item }: { item: DrawerItem }) {
247
322
  // React's CSSProperties has no index signature for custom props.
248
323
  style={{ '--drawer-w': `${item.width}px` } as CSSProperties}
249
324
  onCancel={(e) => { e.preventDefault(); dismiss(); }}
325
+ onClose={onNativeClose}
250
326
  onMouseDown={(e) => { if (e.target === ref.current) dismiss(); }}
251
327
  >
252
328
  {/* The panel fills the dialog, so the dialog element is only ever
@@ -0,0 +1,83 @@
1
+ // showSecretDialog / SecretBox — the show-once secret reveal (API tokens,
2
+ // webhook secrets). Moved down from admin/credentials so non-admin surfaces
3
+ // (portal-mojo/account) can reveal a freshly minted token without importing
4
+ // an admin domain; admin/credentials re-exports it unchanged. Styled by the
5
+ // app's group-admin.css (.ga-secret-*).
6
+ import { useState, type ReactNode } from 'react';
7
+ import { modal } from './modal';
8
+
9
+ /** Clipboard write with the execCommand fallback for insecure contexts. */
10
+ export async function copyText(value: string): Promise<boolean> {
11
+ try {
12
+ if (navigator.clipboard?.writeText) {
13
+ await navigator.clipboard.writeText(value);
14
+ return true;
15
+ }
16
+ } catch {
17
+ // Fall through for insecure contexts.
18
+ }
19
+ try {
20
+ const input = document.createElement('textarea');
21
+ input.value = value;
22
+ input.style.position = 'fixed';
23
+ input.style.opacity = '0';
24
+ document.body.appendChild(input);
25
+ input.select();
26
+ const copied = document.execCommand('copy');
27
+ input.remove();
28
+ return copied;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ export function SecretBox({ secret, ariaLabel }: { secret: string; ariaLabel: string }) {
35
+ const [copied, setCopied] = useState(false);
36
+ return (
37
+ <div className="ga-secret-box">
38
+ <code className="ga-secret-value" aria-label={ariaLabel}>{secret}</code>
39
+ <button
40
+ type="button"
41
+ className="btn btn-compact"
42
+ title="Copy to clipboard"
43
+ aria-label="Copy to clipboard"
44
+ onClick={() => {
45
+ void copyText(secret).then((ok) => {
46
+ if (!ok) return;
47
+ setCopied(true);
48
+ setTimeout(() => setCopied(false), 1600);
49
+ });
50
+ }}
51
+ >
52
+ <i className={`bi ${copied ? 'bi-check-lg' : 'bi-clipboard'}`} /> {copied ? 'Copied' : 'Copy'}
53
+ </button>
54
+ </div>
55
+ );
56
+ }
57
+
58
+ export interface SecretDialogOptions {
59
+ title: string;
60
+ warning: ReactNode;
61
+ intro?: ReactNode;
62
+ secret: string;
63
+ ariaLabel: string;
64
+ footer?: ReactNode;
65
+ }
66
+
67
+ export function showSecretDialog(options: SecretDialogOptions): Promise<unknown> {
68
+ return modal.open((close) => (
69
+ <div className="modal-pad">
70
+ <h2 className="modal-title">{options.title}</h2>
71
+ {options.intro && <div className="ga-secret-intro">{options.intro}</div>}
72
+ <div className="ga-secret-warning" role="alert">
73
+ <i className="bi bi-exclamation-triangle-fill" />
74
+ <span>{options.warning}</span>
75
+ </div>
76
+ <SecretBox secret={options.secret} ariaLabel={options.ariaLabel} />
77
+ {options.footer}
78
+ <div className="modal-actions">
79
+ <button className="btn" onClick={() => close(null)}>Close</button>
80
+ </div>
81
+ </div>
82
+ ), { size: 'lg' });
83
+ }
package/src/ui/toast.tsx CHANGED
@@ -24,7 +24,8 @@ import { createPortal } from 'react-dom';
24
24
  type Level = 'success' | 'error' | 'info' | 'warning';
25
25
 
26
26
  interface BasicToast { id: number; kind: 'basic'; level: Level; message: string }
27
- interface UndoToastItem { id: number; kind: 'undo'; message: string; onUndo?: () => void }
27
+ /** The undo card; `label` renames its button (toast.action — a follow-up offer). */
28
+ interface UndoToastItem { id: number; kind: 'undo'; message: string; onUndo?: () => void; label?: string }
28
29
  interface ProgressToastItem {
29
30
  id: number;
30
31
  kind: 'progress';
@@ -129,6 +130,21 @@ export function progressToast(label: string, opts: { onCancel?: () => void } = {
129
130
  };
130
131
  }
131
132
 
133
+ /**
134
+ * A follow-up OFFER on the undo card's grammar: the message, one action
135
+ * button named `label` (click dismisses at once and runs `onAction`), and an
136
+ * auto-dismiss after `timeout` ms (default 10000) that leaves things as they are.
137
+ */
138
+ export function actionToast(message: string, label: string, onAction: () => void, opts: { timeout?: number } = {}): UndoToastHandle {
139
+ const id = nextId++;
140
+ toasts = [...toasts, { id, kind: 'undo', message, onUndo: onAction, label }];
141
+ emit();
142
+ const timer = setTimeout(() => remove(id), opts.timeout ?? 10_000);
143
+ return {
144
+ dismiss: () => { clearTimeout(timer); remove(id); },
145
+ };
146
+ }
147
+
132
148
  export const toast = {
133
149
  success: (m: string) => push('success', m),
134
150
  error: (m: string) => push('error', m),
@@ -139,6 +155,8 @@ export const toast = {
139
155
  undo: undoToast,
140
156
  /** Persistent progress card with a bar. See progressToast(). */
141
157
  progress: progressToast,
158
+ /** A message with one follow-up action button. See actionToast(). */
159
+ action: actionToast,
142
160
  };
143
161
 
144
162
  const ICONS: Record<Level, string> = {
@@ -250,7 +268,7 @@ export function ToastHost() {
250
268
  className="undo-btn"
251
269
  onClick={() => { remove(t.id); t.onUndo?.(); }}
252
270
  >
253
- Undo
271
+ {t.label ?? 'Undo'}
254
272
  </button>
255
273
  </div>
256
274
  );