portal-mojo 0.2.6 → 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/README.md +2 -2
- package/docs/README.md +2 -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/docs/user-menu.md +131 -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 +13 -17
- package/src/ui/UserMenu.tsx +405 -0
- package/src/ui/index.ts +2 -0
- package/src/ui/modal.tsx +76 -0
- package/src/ui/safe-url.ts +14 -0
- package/src/ui/secret-dialog.tsx +83 -0
- package/src/ui/shell.ts +1 -0
- package/src/ui/toast.tsx +20 -2
package/src/ui/FileField.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { useUploadQueue } from './UploadQueue';
|
|
|
7
7
|
import { fileRelationId } from './field-wire';
|
|
8
8
|
import { toast } from './toast';
|
|
9
9
|
import { imageEditorModal, type ImageEditorModalOptions } from './image-editor';
|
|
10
|
+
import { safePreviewUrl } from './safe-url';
|
|
10
11
|
|
|
11
12
|
export type FileFieldState = 'keep' | 'clear' | 'replacement-in-progress' | 'replacement-failed'
|
|
12
13
|
| 'completed-awaiting-attach' | 'attach-failed' | 'edit-pending' | 'edit-ready' | 'edit-failed';
|
|
@@ -53,16 +54,6 @@ export function reconcileFileOwnerResult(expected: number | null, result: FileFi
|
|
|
53
54
|
|
|
54
55
|
interface PreviewRow { url?: unknown; thumbnail?: unknown; filename?: unknown }
|
|
55
56
|
|
|
56
|
-
function safePreviewUrl(value: unknown): string | null {
|
|
57
|
-
if (typeof value !== 'string' || value.trim() !== value || !value || value.startsWith('//')) return null;
|
|
58
|
-
if (value.startsWith('/')) return /[\r\n\\]/.test(value) ? null : value;
|
|
59
|
-
try {
|
|
60
|
-
const url = new URL(value);
|
|
61
|
-
return (url.protocol === 'http:' || url.protocol === 'https:') && !url.username && !url.password
|
|
62
|
-
? value : null;
|
|
63
|
-
} catch { return null; }
|
|
64
|
-
}
|
|
65
|
-
|
|
66
57
|
/**
|
|
67
58
|
* Controlled django-mojo File relation editor. Browser File values, object
|
|
68
59
|
* URLs, upload tasks, and stored preview capabilities stay component-local.
|
|
@@ -108,13 +99,18 @@ export function FileField(props: FileFieldProps) {
|
|
|
108
99
|
}
|
|
109
100
|
}, [queue]);
|
|
110
101
|
|
|
111
|
-
useEffect(() =>
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
// Re-arm on (re)mount: StrictMode's probe unmount runs the cleanup
|
|
104
|
+
// below, and without this the remount stays "unmounted" forever.
|
|
105
|
+
mounted.current = true;
|
|
106
|
+
return () => {
|
|
107
|
+
mounted.current = false;
|
|
108
|
+
generation.current += 1;
|
|
109
|
+
editController.current?.abort();
|
|
110
|
+
revokeLocal();
|
|
111
|
+
if (desired.current !== undefined && candidate.current != null) reportOrphan(candidate.current);
|
|
112
|
+
queue.cancelAll();
|
|
113
|
+
};
|
|
118
114
|
}, [queue, reportOrphan, revokeLocal]);
|
|
119
115
|
|
|
120
116
|
const active = queue.snapshot.activeCount > 0 || queue.snapshot.queuedCount > 0;
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
// UserMenu — the shared top-nav identity/account menu. Successor to the
|
|
2
|
+
// hand-rolled "user chip" every portal carried, and the React port of
|
|
3
|
+
// web-mojo's TopNav user dropdown (src/core/views/navigation/TopNav.js —
|
|
4
|
+
// avatar-or-icon trigger, `dropdown-menu-end` items, divider rows) plus
|
|
5
|
+
// PortalApp._injectThemeToggleItems (theme choice injected just above the
|
|
6
|
+
// trailing logout). Here the theme choice is an inline Light/Dark/System
|
|
7
|
+
// segmented control instead of a separate settings dialog.
|
|
8
|
+
//
|
|
9
|
+
// Identity comes from `useMe()`; the avatar capability (URL) is fetched from
|
|
10
|
+
// `/api/fileman/file/<id>` ONLY while mounted and kept in component state —
|
|
11
|
+
// never written into a Query cache (the `me` row is sanitized to `{id}` by
|
|
12
|
+
// design). Same contract as FileField's stored preview.
|
|
13
|
+
import {
|
|
14
|
+
useCallback,
|
|
15
|
+
useEffect,
|
|
16
|
+
useId,
|
|
17
|
+
useRef,
|
|
18
|
+
useState,
|
|
19
|
+
type KeyboardEvent,
|
|
20
|
+
type MouseEvent,
|
|
21
|
+
type ReactNode,
|
|
22
|
+
} from 'react';
|
|
23
|
+
import { Link } from 'react-router-dom';
|
|
24
|
+
import { mojoCall } from '../client/client';
|
|
25
|
+
import { hostedAuthUrl, logout, redirectToHostedAuth } from '../client/auth';
|
|
26
|
+
import { useAuthSnapshot, useMe } from '../client/me';
|
|
27
|
+
import { initials } from './format';
|
|
28
|
+
import { Popover } from './Popover';
|
|
29
|
+
import { safePreviewUrl } from './safe-url';
|
|
30
|
+
import { useTheme, type ThemePref } from './ThemeProvider';
|
|
31
|
+
import { toast } from './toast';
|
|
32
|
+
import { warnOnce } from './warn-once';
|
|
33
|
+
|
|
34
|
+
/** One app-supplied entry in the account menu. */
|
|
35
|
+
export interface UserMenuItem {
|
|
36
|
+
label: string;
|
|
37
|
+
/** Bootstrap-icons class, e.g. `'bi-person'`. */
|
|
38
|
+
icon?: string;
|
|
39
|
+
/** Router destination — renders a react-router `<Link>`. */
|
|
40
|
+
to?: string;
|
|
41
|
+
/** Called after the menu closes (also called for `to` items). */
|
|
42
|
+
onSelect?: () => void;
|
|
43
|
+
/** Danger tone (destructive actions). */
|
|
44
|
+
danger?: boolean;
|
|
45
|
+
/** Draw a separator line ABOVE this item. */
|
|
46
|
+
divider?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type UserMenuAuthMode = 'inapp' | 'hosted';
|
|
50
|
+
export type UserMenuSize = 'sm' | 'md';
|
|
51
|
+
|
|
52
|
+
export interface UserMenuProps {
|
|
53
|
+
/** App items, rendered between the theme control and Sign out. */
|
|
54
|
+
items?: UserMenuItem[];
|
|
55
|
+
/** Extra content rendered above Sign out (after `items`). Elements with
|
|
56
|
+
* `role="menuitem"` join arrow-key navigation and close the menu on click. */
|
|
57
|
+
children?: ReactNode;
|
|
58
|
+
/** Signed-out affordance: 'inapp' → Link to /auth/login; 'hosted' →
|
|
59
|
+
* button calling redirectToHostedAuth(); omitted → "Signed out" chip. */
|
|
60
|
+
authMode?: UserMenuAuthMode;
|
|
61
|
+
/** Replaces the default `logout()`. A pending state shows while it runs;
|
|
62
|
+
* a rejection toasts and leaves the menu open for a retry. */
|
|
63
|
+
onSignOut?: () => void | Promise<void>;
|
|
64
|
+
/** Trigger density (default 'md'). */
|
|
65
|
+
size?: UserMenuSize;
|
|
66
|
+
className?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const SIZES: readonly UserMenuSize[] = ['sm', 'md'];
|
|
70
|
+
const AUTH_MODES: readonly UserMenuAuthMode[] = ['inapp', 'hosted'];
|
|
71
|
+
|
|
72
|
+
const THEMES: { value: ThemePref; label: string; icon: string }[] = [
|
|
73
|
+
{ value: 'light', label: 'Light', icon: 'bi-sun' },
|
|
74
|
+
{ value: 'dark', label: 'Dark', icon: 'bi-moon-stars' },
|
|
75
|
+
{ value: 'system', label: 'System', icon: 'bi-circle-half' },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"]';
|
|
79
|
+
|
|
80
|
+
function cx(...parts: (string | false | null | undefined)[]): string {
|
|
81
|
+
return parts.filter(Boolean).join(' ');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function Avatar({ src, name, className, onError }: {
|
|
85
|
+
src: string | null;
|
|
86
|
+
name: string;
|
|
87
|
+
className: string;
|
|
88
|
+
onError: () => void;
|
|
89
|
+
}) {
|
|
90
|
+
return (
|
|
91
|
+
<span className={cx('user-menu-avatar', className)} aria-hidden="true">
|
|
92
|
+
{src
|
|
93
|
+
? <img src={src} alt="" referrerPolicy="no-referrer" onError={onError} />
|
|
94
|
+
: initials(name)}
|
|
95
|
+
</span>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Stored avatar capability for `avatarId`, fetched while mounted only and
|
|
101
|
+
* kept in component state (never a Query cache). Returns `[src, onError]`;
|
|
102
|
+
* wire `onError` to the <img> so a dead capability falls back to initials.
|
|
103
|
+
*/
|
|
104
|
+
export function useAvatarUrl(avatarId: number | null): [string | null, () => void] {
|
|
105
|
+
const [fetched, setFetched] = useState<{ id: number; src: string | null } | null>(null);
|
|
106
|
+
const [failedSrc, setFailedSrc] = useState<string | null>(null);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (avatarId == null) return;
|
|
110
|
+
const controller = new AbortController();
|
|
111
|
+
let live = true;
|
|
112
|
+
void mojoCall(`/api/fileman/file/${avatarId}`, { signal: controller.signal }).then((envelope) => {
|
|
113
|
+
if (!live || controller.signal.aborted) return;
|
|
114
|
+
const row = (envelope.data ?? {}) as { url?: unknown; thumbnail?: unknown };
|
|
115
|
+
setFetched({ id: avatarId, src: safePreviewUrl(row.thumbnail) ?? safePreviewUrl(row.url) });
|
|
116
|
+
}, () => { /* capability and failure remain local and silent */ });
|
|
117
|
+
return () => { live = false; controller.abort(); };
|
|
118
|
+
}, [avatarId]);
|
|
119
|
+
|
|
120
|
+
// Derived, so a previous identity's image never shows for the next one.
|
|
121
|
+
const src = avatarId != null && fetched?.id === avatarId ? fetched.src : null;
|
|
122
|
+
const onError = useCallback(() => setFailedSrc(src), [src]);
|
|
123
|
+
return [src && src !== failedSrc ? src : null, onError];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function UserMenu({
|
|
127
|
+
items,
|
|
128
|
+
children,
|
|
129
|
+
authMode,
|
|
130
|
+
onSignOut,
|
|
131
|
+
size = 'md',
|
|
132
|
+
className,
|
|
133
|
+
}: UserMenuProps) {
|
|
134
|
+
const auth = useAuthSnapshot();
|
|
135
|
+
const { data: me } = useMe();
|
|
136
|
+
const { pref, setPref } = useTheme();
|
|
137
|
+
|
|
138
|
+
const [open, setOpen] = useState(false);
|
|
139
|
+
const [signingOut, setSigningOut] = useState(false);
|
|
140
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
141
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
142
|
+
const focusOnOpen = useRef<'first' | 'last'>('first');
|
|
143
|
+
const mounted = useRef(true);
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
mounted.current = true;
|
|
146
|
+
return () => { mounted.current = false; };
|
|
147
|
+
}, []);
|
|
148
|
+
|
|
149
|
+
const uid = useId();
|
|
150
|
+
const menuId = `${uid}-menu`;
|
|
151
|
+
const triggerId = `${uid}-trigger`;
|
|
152
|
+
|
|
153
|
+
let density = size;
|
|
154
|
+
if (!SIZES.includes(density)) {
|
|
155
|
+
warnOnce(`UserMenu: unknown size "${String(size)}" — falling back to "md"`);
|
|
156
|
+
density = 'md';
|
|
157
|
+
}
|
|
158
|
+
if (authMode !== undefined && !AUTH_MODES.includes(authMode)) {
|
|
159
|
+
warnOnce(`UserMenu: unknown authMode "${String(authMode)}" — rendering the signed-out chip`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const [avatarSrc, onAvatarError] = useAvatarUrl(auth.authenticated ? me?.avatar?.id ?? null : null);
|
|
163
|
+
const menuOpen = open && auth.authenticated;
|
|
164
|
+
|
|
165
|
+
// Move focus into the menu once it is shown (Popover never moves focus).
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (!menuOpen) return;
|
|
168
|
+
const nodes = menuItems(menuRef.current);
|
|
169
|
+
(focusOnOpen.current === 'last' ? nodes[nodes.length - 1] : nodes[0])?.focus();
|
|
170
|
+
}, [menuOpen]);
|
|
171
|
+
|
|
172
|
+
const close = useCallback((refocus: boolean) => {
|
|
173
|
+
setOpen(false);
|
|
174
|
+
if (refocus) triggerRef.current?.focus();
|
|
175
|
+
}, []);
|
|
176
|
+
|
|
177
|
+
// ── Signed out ───────────────────────────────────────────────────
|
|
178
|
+
if (!auth.authenticated) {
|
|
179
|
+
if (authMode === 'inapp') {
|
|
180
|
+
return (
|
|
181
|
+
<Link className={cx('btn btn-primary btn-compact', className)} to="/auth/login">
|
|
182
|
+
<i className="bi bi-box-arrow-in-right" /> Sign in
|
|
183
|
+
</Link>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
if (authMode === 'hosted' && hostedAuthUrl()) {
|
|
187
|
+
return (
|
|
188
|
+
<button type="button" className={cx('btn btn-primary btn-compact', className)} onClick={() => redirectToHostedAuth()}>
|
|
189
|
+
<i className="bi bi-box-arrow-in-right" /> Sign in
|
|
190
|
+
</button>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return (
|
|
194
|
+
<span className={cx('chip chip-muted', className)}>
|
|
195
|
+
<i className="bi bi-person-slash" /> Signed out
|
|
196
|
+
</span>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const name = me?.display_name || me?.email || auth.email || '…';
|
|
201
|
+
const email = me?.email || auth.email || null;
|
|
202
|
+
|
|
203
|
+
const toggle = () => {
|
|
204
|
+
focusOnOpen.current = 'first';
|
|
205
|
+
setOpen((v) => !v);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const onTriggerKey = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
209
|
+
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
|
210
|
+
event.preventDefault();
|
|
211
|
+
focusOnOpen.current = event.key === 'ArrowUp' ? 'last' : 'first';
|
|
212
|
+
if (menuOpen) {
|
|
213
|
+
const nodes = menuItems(menuRef.current);
|
|
214
|
+
(event.key === 'ArrowUp' ? nodes[nodes.length - 1] : nodes[0])?.focus();
|
|
215
|
+
} else {
|
|
216
|
+
setOpen(true);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const onMenuKey = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
221
|
+
const nodes = menuItems(menuRef.current);
|
|
222
|
+
if (nodes.length === 0) return;
|
|
223
|
+
const target = event.target as HTMLElement;
|
|
224
|
+
const index = nodes.indexOf(target);
|
|
225
|
+
const focusAt = (i: number) => nodes[(i + nodes.length) % nodes.length]?.focus();
|
|
226
|
+
switch (event.key) {
|
|
227
|
+
case 'ArrowDown':
|
|
228
|
+
event.preventDefault();
|
|
229
|
+
focusAt(index < 0 ? 0 : index + 1);
|
|
230
|
+
break;
|
|
231
|
+
case 'ArrowUp':
|
|
232
|
+
event.preventDefault();
|
|
233
|
+
focusAt(index < 0 ? nodes.length - 1 : index - 1);
|
|
234
|
+
break;
|
|
235
|
+
case 'Home':
|
|
236
|
+
event.preventDefault();
|
|
237
|
+
focusAt(0);
|
|
238
|
+
break;
|
|
239
|
+
case 'End':
|
|
240
|
+
event.preventDefault();
|
|
241
|
+
focusAt(nodes.length - 1);
|
|
242
|
+
break;
|
|
243
|
+
case 'ArrowLeft':
|
|
244
|
+
case 'ArrowRight': {
|
|
245
|
+
// Within the theme segmented control only.
|
|
246
|
+
if (target.getAttribute('role') !== 'menuitemradio') break;
|
|
247
|
+
const radios = Array.from(target.parentElement?.querySelectorAll<HTMLElement>('[role="menuitemradio"]') ?? []);
|
|
248
|
+
const at = radios.indexOf(target);
|
|
249
|
+
if (at < 0) break;
|
|
250
|
+
event.preventDefault();
|
|
251
|
+
const step = event.key === 'ArrowRight' ? 1 : -1;
|
|
252
|
+
radios[(at + step + radios.length) % radios.length]?.focus();
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
case ' ':
|
|
256
|
+
// Buttons activate natively; a Link only activates on Enter.
|
|
257
|
+
if (target.tagName === 'A') {
|
|
258
|
+
event.preventDefault();
|
|
259
|
+
target.click();
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
262
|
+
case 'Tab':
|
|
263
|
+
// Close and hand focus back to the trigger; the Tab default
|
|
264
|
+
// action then moves on from there (Shift+Tab goes back).
|
|
265
|
+
close(true);
|
|
266
|
+
break;
|
|
267
|
+
default:
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const select = (item: UserMenuItem) => {
|
|
273
|
+
close(true);
|
|
274
|
+
item.onSelect?.();
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// Children: any role=menuitem click closes the menu (close-on-select).
|
|
278
|
+
const onChildrenClick = (event: MouseEvent<HTMLDivElement>) => {
|
|
279
|
+
if ((event.target as HTMLElement).closest(ITEM_SELECTOR)) close(true);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const signOut = async () => {
|
|
283
|
+
if (signingOut) return;
|
|
284
|
+
setSigningOut(true);
|
|
285
|
+
try {
|
|
286
|
+
if (onSignOut) await onSignOut();
|
|
287
|
+
else logout();
|
|
288
|
+
if (mounted.current) setOpen(false);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
const message = error instanceof Error && error.message ? error.message : 'unknown error';
|
|
291
|
+
toast.error(`Sign out failed: ${message}`);
|
|
292
|
+
} finally {
|
|
293
|
+
if (mounted.current) setSigningOut(false);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
return (
|
|
298
|
+
<>
|
|
299
|
+
<button
|
|
300
|
+
ref={triggerRef}
|
|
301
|
+
id={triggerId}
|
|
302
|
+
type="button"
|
|
303
|
+
className={cx('user-menu-trigger', `user-menu-${density}`, menuOpen && 'is-open', className)}
|
|
304
|
+
aria-haspopup="menu"
|
|
305
|
+
aria-expanded={menuOpen}
|
|
306
|
+
aria-controls={menuOpen ? menuId : undefined}
|
|
307
|
+
aria-label={`Account menu: ${name}`}
|
|
308
|
+
onClick={toggle}
|
|
309
|
+
onKeyDown={onTriggerKey}
|
|
310
|
+
>
|
|
311
|
+
<Avatar src={avatarSrc} name={name} className="user-menu-avatar-trigger" onError={onAvatarError} />
|
|
312
|
+
<span className="user-menu-name">{name}</span>
|
|
313
|
+
<i className="bi bi-chevron-down user-menu-caret" aria-hidden="true" />
|
|
314
|
+
</button>
|
|
315
|
+
<Popover
|
|
316
|
+
anchorRef={triggerRef}
|
|
317
|
+
open={menuOpen}
|
|
318
|
+
onClose={() => setOpen(false)}
|
|
319
|
+
placement="bottom-end"
|
|
320
|
+
role="none"
|
|
321
|
+
className="user-menu-pop"
|
|
322
|
+
>
|
|
323
|
+
<div className="user-menu-identity">
|
|
324
|
+
<Avatar src={avatarSrc} name={name} className="user-menu-avatar-lg" onError={onAvatarError} />
|
|
325
|
+
<div className="user-menu-who">
|
|
326
|
+
<div className="user-menu-who-name">{name}</div>
|
|
327
|
+
{email && <div className="user-menu-who-email">{email}</div>}
|
|
328
|
+
{me?.is_superuser && (
|
|
329
|
+
<span className="chip chip-primary user-menu-tag">
|
|
330
|
+
<i className="bi bi-shield-lock" /> System admin
|
|
331
|
+
</span>
|
|
332
|
+
)}
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
<div
|
|
336
|
+
ref={menuRef}
|
|
337
|
+
id={menuId}
|
|
338
|
+
role="menu"
|
|
339
|
+
aria-labelledby={triggerId}
|
|
340
|
+
className="user-menu"
|
|
341
|
+
onKeyDown={onMenuKey}
|
|
342
|
+
>
|
|
343
|
+
<div className="user-menu-theme" role="group" aria-label="Theme">
|
|
344
|
+
<span className="user-menu-theme-label" aria-hidden="true">Theme</span>
|
|
345
|
+
<div className="seg user-menu-seg">
|
|
346
|
+
{THEMES.map((theme) => (
|
|
347
|
+
<button
|
|
348
|
+
key={theme.value}
|
|
349
|
+
type="button"
|
|
350
|
+
role="menuitemradio"
|
|
351
|
+
aria-checked={pref === theme.value}
|
|
352
|
+
tabIndex={-1}
|
|
353
|
+
className={cx('seg-btn', pref === theme.value && 'seg-active')}
|
|
354
|
+
onClick={() => setPref(theme.value)}
|
|
355
|
+
>
|
|
356
|
+
<i className={`bi ${theme.icon}`} aria-hidden="true" /> {theme.label}
|
|
357
|
+
</button>
|
|
358
|
+
))}
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
{items && items.length > 0 && <div className="user-menu-sep" role="separator" />}
|
|
362
|
+
{items?.map((item, i) => {
|
|
363
|
+
const cls = cx('user-menu-item', item.danger && 'is-danger');
|
|
364
|
+
const body = (
|
|
365
|
+
<>
|
|
366
|
+
<i className={`bi ${item.icon ?? 'bi-dot'}`} aria-hidden="true" />
|
|
367
|
+
<span>{item.label}</span>
|
|
368
|
+
</>
|
|
369
|
+
);
|
|
370
|
+
return (
|
|
371
|
+
<div key={`${i}:${item.label}`} role="none" className="user-menu-row">
|
|
372
|
+
{item.divider && i > 0 && <div className="user-menu-sep" role="separator" />}
|
|
373
|
+
{item.to != null
|
|
374
|
+
? <Link role="menuitem" tabIndex={-1} className={cls} to={item.to} onClick={() => select(item)}>{body}</Link>
|
|
375
|
+
: <button type="button" role="menuitem" tabIndex={-1} className={cls} onClick={() => select(item)}>{body}</button>}
|
|
376
|
+
</div>
|
|
377
|
+
);
|
|
378
|
+
})}
|
|
379
|
+
{children != null && children !== false && (
|
|
380
|
+
<div role="none" className="user-menu-slot" onClick={onChildrenClick}>{children}</div>
|
|
381
|
+
)}
|
|
382
|
+
<div className="user-menu-sep" role="separator" />
|
|
383
|
+
<button
|
|
384
|
+
type="button"
|
|
385
|
+
role="menuitem"
|
|
386
|
+
tabIndex={-1}
|
|
387
|
+
className="user-menu-item is-danger"
|
|
388
|
+
aria-disabled={signingOut || undefined}
|
|
389
|
+
aria-busy={signingOut || undefined}
|
|
390
|
+
onClick={() => { void signOut(); }}
|
|
391
|
+
>
|
|
392
|
+
<i className={signingOut ? 'bi bi-arrow-repeat spin' : 'bi bi-box-arrow-right'} aria-hidden="true" />
|
|
393
|
+
<span>{signingOut ? 'Signing out…' : 'Sign out'}</span>
|
|
394
|
+
</button>
|
|
395
|
+
</div>
|
|
396
|
+
</Popover>
|
|
397
|
+
</>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function menuItems(root: HTMLElement | null): HTMLElement[] {
|
|
402
|
+
if (!root) return [];
|
|
403
|
+
return Array.from(root.querySelectorAll<HTMLElement>(ITEM_SELECTOR))
|
|
404
|
+
.filter((el) => !(el as HTMLButtonElement).disabled);
|
|
405
|
+
}
|
package/src/ui/index.ts
CHANGED
|
@@ -30,11 +30,13 @@ 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';
|
|
36
37
|
export * from './password';
|
|
37
38
|
export * from './Popover';
|
|
39
|
+
export * from './UserMenu';
|
|
38
40
|
export * from './TagInput';
|
|
39
41
|
export * from './toast';
|
|
40
42
|
export * from './FileDrop';
|
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,14 @@
|
|
|
1
|
+
// Shared guard for server-issued preview capabilities (fileman File `url` /
|
|
2
|
+
// `thumbnail`). Accepts only same-origin absolute paths or credential-free
|
|
3
|
+
// http(s) URLs — never `javascript:`, `data:`, protocol-relative `//host`, or
|
|
4
|
+
// values with stray whitespace/CR/LF/backslashes. Callers keep the result in
|
|
5
|
+
// component-local state; it must never be written into a Query cache.
|
|
6
|
+
export function safePreviewUrl(value: unknown): string | null {
|
|
7
|
+
if (typeof value !== 'string' || value.trim() !== value || !value || value.startsWith('//')) return null;
|
|
8
|
+
if (value.startsWith('/')) return /[\r\n\\]/.test(value) ? null : value;
|
|
9
|
+
try {
|
|
10
|
+
const url = new URL(value);
|
|
11
|
+
return (url.protocol === 'http:' || url.protocol === 'https:') && !url.username && !url.password
|
|
12
|
+
? value : null;
|
|
13
|
+
} catch { return null; }
|
|
14
|
+
}
|