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/docs/README.md +1 -0
- package/docs/account.md +260 -0
- package/docs/auth-pages.md +8 -3
- package/docs/auth.md +45 -2
- package/docs/feedback.md +9 -0
- package/docs/idioms.md +12 -0
- package/package.json +2 -1
- package/src/account/AccountModal.tsx +275 -0
- package/src/account/AccountSections.tsx +370 -0
- package/src/account/NotificationPreferences.tsx +152 -0
- package/src/account/PasskeyList.tsx +144 -0
- package/src/account/api.ts +236 -0
- package/src/account/credential-models.ts +153 -0
- package/src/account/dialogs.tsx +822 -0
- package/src/account/index.ts +16 -0
- package/src/account/models.ts +76 -0
- package/src/account/sections/ApiKeysSection.tsx +138 -0
- package/src/admin/credentials/secret-dialog.tsx +3 -77
- package/src/admin/identity/users/models.ts +16 -128
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +3 -119
- package/src/admin/identity/users/sections/NotificationsSection.tsx +23 -102
- package/src/admin/identity/users/sections/PersonalSection.tsx +18 -5
- package/src/admin/identity/users/sections/actions.tsx +7 -100
- package/src/client/auth.ts +192 -5
- package/src/client/mock.ts +472 -7
- package/src/ui/FileField.tsx +12 -7
- package/src/ui/UserMenu.tsx +6 -2
- package/src/ui/index.ts +1 -0
- package/src/ui/modal.tsx +76 -0
- package/src/ui/secret-dialog.tsx +83 -0
- package/src/ui/toast.tsx +20 -2
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
|
-
|
|
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
|
);
|