portal-mojo 0.2.5 → 0.2.7
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 +1 -0
- package/docs/admin-identity-directories-sources.json +130 -0
- package/docs/admin-identity-directories.md +60 -0
- package/docs/admin-identity-users.md +6 -1
- package/docs/admin-members.md +16 -0
- package/docs/admin-network-security.md +1 -1
- package/docs/menus-and-access.md +6 -2
- package/docs/user-menu.md +131 -0
- package/package.json +1 -1
- package/src/admin/core/index.ts +4 -4
- package/src/admin/domains/identity.ts +19 -1
- package/src/admin/identity/groups/GroupDetail.tsx +287 -0
- package/src/admin/identity/groups/GroupsPage.tsx +125 -0
- package/src/admin/identity/groups/group-permissions.ts +5 -0
- package/src/admin/identity/groups/group-sections/AuditSection.tsx +57 -0
- package/src/admin/identity/groups/group-sections/AuthConfigDialog.tsx +677 -0
- package/src/admin/identity/groups/group-sections/EventsSection.tsx +53 -0
- package/src/admin/identity/groups/group-sections/GeofenceSection.tsx +198 -0
- package/src/admin/identity/groups/group-sections/IdentitySection.tsx +211 -0
- package/src/admin/identity/groups/group-sections/MembersSection.tsx +25 -0
- package/src/admin/identity/groups/group-sections/OverviewSection.tsx +112 -0
- package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +102 -0
- package/src/admin/identity/groups/group-sections/auth-config.ts +285 -0
- package/src/admin/identity/groups/group-sections/geofence-data.ts +40 -0
- package/src/admin/identity/groups/group-sections/models.ts +110 -0
- package/src/admin/identity/groups/group-sections/shared.ts +21 -0
- package/src/admin/identity/groups/index.ts +6 -0
- package/src/admin/identity/groups/models.ts +70 -0
- package/src/admin/identity/index.ts +1 -0
- package/src/admin/identity/members/MemberDetail.tsx +32 -3
- package/src/admin/identity/members/models.ts +3 -0
- package/src/admin/identity/users/PersonalApiKeysPage.tsx +220 -0
- package/src/admin/identity/users/UserDetail.tsx +2 -2
- package/src/admin/identity/users/index.ts +2 -0
- package/src/admin/identity/users/sections/PermissionsSection.tsx +2 -2
- package/src/admin/registry.ts +3 -1
- package/src/ui/FileField.tsx +1 -10
- package/src/ui/SidebarNav.tsx +2 -2
- package/src/ui/UserMenu.tsx +401 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/safe-url.ts +14 -0
- package/src/ui/shell.ts +1 -0
|
@@ -0,0 +1,401 @@
|
|
|
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
|
+
/** Stored avatar capability for `avatarId`, fetched while mounted only. */
|
|
100
|
+
function useAvatarUrl(avatarId: number | null): [string | null, () => void] {
|
|
101
|
+
const [fetched, setFetched] = useState<{ id: number; src: string | null } | null>(null);
|
|
102
|
+
const [failedSrc, setFailedSrc] = useState<string | null>(null);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (avatarId == null) return;
|
|
106
|
+
const controller = new AbortController();
|
|
107
|
+
let live = true;
|
|
108
|
+
void mojoCall(`/api/fileman/file/${avatarId}`, { signal: controller.signal }).then((envelope) => {
|
|
109
|
+
if (!live || controller.signal.aborted) return;
|
|
110
|
+
const row = (envelope.data ?? {}) as { url?: unknown; thumbnail?: unknown };
|
|
111
|
+
setFetched({ id: avatarId, src: safePreviewUrl(row.thumbnail) ?? safePreviewUrl(row.url) });
|
|
112
|
+
}, () => { /* capability and failure remain local and silent */ });
|
|
113
|
+
return () => { live = false; controller.abort(); };
|
|
114
|
+
}, [avatarId]);
|
|
115
|
+
|
|
116
|
+
// Derived, so a previous identity's image never shows for the next one.
|
|
117
|
+
const src = avatarId != null && fetched?.id === avatarId ? fetched.src : null;
|
|
118
|
+
const onError = useCallback(() => setFailedSrc(src), [src]);
|
|
119
|
+
return [src && src !== failedSrc ? src : null, onError];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function UserMenu({
|
|
123
|
+
items,
|
|
124
|
+
children,
|
|
125
|
+
authMode,
|
|
126
|
+
onSignOut,
|
|
127
|
+
size = 'md',
|
|
128
|
+
className,
|
|
129
|
+
}: UserMenuProps) {
|
|
130
|
+
const auth = useAuthSnapshot();
|
|
131
|
+
const { data: me } = useMe();
|
|
132
|
+
const { pref, setPref } = useTheme();
|
|
133
|
+
|
|
134
|
+
const [open, setOpen] = useState(false);
|
|
135
|
+
const [signingOut, setSigningOut] = useState(false);
|
|
136
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
137
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
138
|
+
const focusOnOpen = useRef<'first' | 'last'>('first');
|
|
139
|
+
const mounted = useRef(true);
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
mounted.current = true;
|
|
142
|
+
return () => { mounted.current = false; };
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
const uid = useId();
|
|
146
|
+
const menuId = `${uid}-menu`;
|
|
147
|
+
const triggerId = `${uid}-trigger`;
|
|
148
|
+
|
|
149
|
+
let density = size;
|
|
150
|
+
if (!SIZES.includes(density)) {
|
|
151
|
+
warnOnce(`UserMenu: unknown size "${String(size)}" — falling back to "md"`);
|
|
152
|
+
density = 'md';
|
|
153
|
+
}
|
|
154
|
+
if (authMode !== undefined && !AUTH_MODES.includes(authMode)) {
|
|
155
|
+
warnOnce(`UserMenu: unknown authMode "${String(authMode)}" — rendering the signed-out chip`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const [avatarSrc, onAvatarError] = useAvatarUrl(auth.authenticated ? me?.avatar?.id ?? null : null);
|
|
159
|
+
const menuOpen = open && auth.authenticated;
|
|
160
|
+
|
|
161
|
+
// Move focus into the menu once it is shown (Popover never moves focus).
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!menuOpen) return;
|
|
164
|
+
const nodes = menuItems(menuRef.current);
|
|
165
|
+
(focusOnOpen.current === 'last' ? nodes[nodes.length - 1] : nodes[0])?.focus();
|
|
166
|
+
}, [menuOpen]);
|
|
167
|
+
|
|
168
|
+
const close = useCallback((refocus: boolean) => {
|
|
169
|
+
setOpen(false);
|
|
170
|
+
if (refocus) triggerRef.current?.focus();
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
// ── Signed out ───────────────────────────────────────────────────
|
|
174
|
+
if (!auth.authenticated) {
|
|
175
|
+
if (authMode === 'inapp') {
|
|
176
|
+
return (
|
|
177
|
+
<Link className={cx('btn btn-primary btn-compact', className)} to="/auth/login">
|
|
178
|
+
<i className="bi bi-box-arrow-in-right" /> Sign in
|
|
179
|
+
</Link>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (authMode === 'hosted' && hostedAuthUrl()) {
|
|
183
|
+
return (
|
|
184
|
+
<button type="button" className={cx('btn btn-primary btn-compact', className)} onClick={() => redirectToHostedAuth()}>
|
|
185
|
+
<i className="bi bi-box-arrow-in-right" /> Sign in
|
|
186
|
+
</button>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return (
|
|
190
|
+
<span className={cx('chip chip-muted', className)}>
|
|
191
|
+
<i className="bi bi-person-slash" /> Signed out
|
|
192
|
+
</span>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const name = me?.display_name || me?.email || auth.email || '…';
|
|
197
|
+
const email = me?.email || auth.email || null;
|
|
198
|
+
|
|
199
|
+
const toggle = () => {
|
|
200
|
+
focusOnOpen.current = 'first';
|
|
201
|
+
setOpen((v) => !v);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const onTriggerKey = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
205
|
+
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
|
206
|
+
event.preventDefault();
|
|
207
|
+
focusOnOpen.current = event.key === 'ArrowUp' ? 'last' : 'first';
|
|
208
|
+
if (menuOpen) {
|
|
209
|
+
const nodes = menuItems(menuRef.current);
|
|
210
|
+
(event.key === 'ArrowUp' ? nodes[nodes.length - 1] : nodes[0])?.focus();
|
|
211
|
+
} else {
|
|
212
|
+
setOpen(true);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const onMenuKey = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
217
|
+
const nodes = menuItems(menuRef.current);
|
|
218
|
+
if (nodes.length === 0) return;
|
|
219
|
+
const target = event.target as HTMLElement;
|
|
220
|
+
const index = nodes.indexOf(target);
|
|
221
|
+
const focusAt = (i: number) => nodes[(i + nodes.length) % nodes.length]?.focus();
|
|
222
|
+
switch (event.key) {
|
|
223
|
+
case 'ArrowDown':
|
|
224
|
+
event.preventDefault();
|
|
225
|
+
focusAt(index < 0 ? 0 : index + 1);
|
|
226
|
+
break;
|
|
227
|
+
case 'ArrowUp':
|
|
228
|
+
event.preventDefault();
|
|
229
|
+
focusAt(index < 0 ? nodes.length - 1 : index - 1);
|
|
230
|
+
break;
|
|
231
|
+
case 'Home':
|
|
232
|
+
event.preventDefault();
|
|
233
|
+
focusAt(0);
|
|
234
|
+
break;
|
|
235
|
+
case 'End':
|
|
236
|
+
event.preventDefault();
|
|
237
|
+
focusAt(nodes.length - 1);
|
|
238
|
+
break;
|
|
239
|
+
case 'ArrowLeft':
|
|
240
|
+
case 'ArrowRight': {
|
|
241
|
+
// Within the theme segmented control only.
|
|
242
|
+
if (target.getAttribute('role') !== 'menuitemradio') break;
|
|
243
|
+
const radios = Array.from(target.parentElement?.querySelectorAll<HTMLElement>('[role="menuitemradio"]') ?? []);
|
|
244
|
+
const at = radios.indexOf(target);
|
|
245
|
+
if (at < 0) break;
|
|
246
|
+
event.preventDefault();
|
|
247
|
+
const step = event.key === 'ArrowRight' ? 1 : -1;
|
|
248
|
+
radios[(at + step + radios.length) % radios.length]?.focus();
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
case ' ':
|
|
252
|
+
// Buttons activate natively; a Link only activates on Enter.
|
|
253
|
+
if (target.tagName === 'A') {
|
|
254
|
+
event.preventDefault();
|
|
255
|
+
target.click();
|
|
256
|
+
}
|
|
257
|
+
break;
|
|
258
|
+
case 'Tab':
|
|
259
|
+
// Close and hand focus back to the trigger; the Tab default
|
|
260
|
+
// action then moves on from there (Shift+Tab goes back).
|
|
261
|
+
close(true);
|
|
262
|
+
break;
|
|
263
|
+
default:
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const select = (item: UserMenuItem) => {
|
|
269
|
+
close(true);
|
|
270
|
+
item.onSelect?.();
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// Children: any role=menuitem click closes the menu (close-on-select).
|
|
274
|
+
const onChildrenClick = (event: MouseEvent<HTMLDivElement>) => {
|
|
275
|
+
if ((event.target as HTMLElement).closest(ITEM_SELECTOR)) close(true);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const signOut = async () => {
|
|
279
|
+
if (signingOut) return;
|
|
280
|
+
setSigningOut(true);
|
|
281
|
+
try {
|
|
282
|
+
if (onSignOut) await onSignOut();
|
|
283
|
+
else logout();
|
|
284
|
+
if (mounted.current) setOpen(false);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
const message = error instanceof Error && error.message ? error.message : 'unknown error';
|
|
287
|
+
toast.error(`Sign out failed: ${message}`);
|
|
288
|
+
} finally {
|
|
289
|
+
if (mounted.current) setSigningOut(false);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<>
|
|
295
|
+
<button
|
|
296
|
+
ref={triggerRef}
|
|
297
|
+
id={triggerId}
|
|
298
|
+
type="button"
|
|
299
|
+
className={cx('user-menu-trigger', `user-menu-${density}`, menuOpen && 'is-open', className)}
|
|
300
|
+
aria-haspopup="menu"
|
|
301
|
+
aria-expanded={menuOpen}
|
|
302
|
+
aria-controls={menuOpen ? menuId : undefined}
|
|
303
|
+
aria-label={`Account menu: ${name}`}
|
|
304
|
+
onClick={toggle}
|
|
305
|
+
onKeyDown={onTriggerKey}
|
|
306
|
+
>
|
|
307
|
+
<Avatar src={avatarSrc} name={name} className="user-menu-avatar-trigger" onError={onAvatarError} />
|
|
308
|
+
<span className="user-menu-name">{name}</span>
|
|
309
|
+
<i className="bi bi-chevron-down user-menu-caret" aria-hidden="true" />
|
|
310
|
+
</button>
|
|
311
|
+
<Popover
|
|
312
|
+
anchorRef={triggerRef}
|
|
313
|
+
open={menuOpen}
|
|
314
|
+
onClose={() => setOpen(false)}
|
|
315
|
+
placement="bottom-end"
|
|
316
|
+
role="none"
|
|
317
|
+
className="user-menu-pop"
|
|
318
|
+
>
|
|
319
|
+
<div className="user-menu-identity">
|
|
320
|
+
<Avatar src={avatarSrc} name={name} className="user-menu-avatar-lg" onError={onAvatarError} />
|
|
321
|
+
<div className="user-menu-who">
|
|
322
|
+
<div className="user-menu-who-name">{name}</div>
|
|
323
|
+
{email && <div className="user-menu-who-email">{email}</div>}
|
|
324
|
+
{me?.is_superuser && (
|
|
325
|
+
<span className="chip chip-primary user-menu-tag">
|
|
326
|
+
<i className="bi bi-shield-lock" /> System admin
|
|
327
|
+
</span>
|
|
328
|
+
)}
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
<div
|
|
332
|
+
ref={menuRef}
|
|
333
|
+
id={menuId}
|
|
334
|
+
role="menu"
|
|
335
|
+
aria-labelledby={triggerId}
|
|
336
|
+
className="user-menu"
|
|
337
|
+
onKeyDown={onMenuKey}
|
|
338
|
+
>
|
|
339
|
+
<div className="user-menu-theme" role="group" aria-label="Theme">
|
|
340
|
+
<span className="user-menu-theme-label" aria-hidden="true">Theme</span>
|
|
341
|
+
<div className="seg user-menu-seg">
|
|
342
|
+
{THEMES.map((theme) => (
|
|
343
|
+
<button
|
|
344
|
+
key={theme.value}
|
|
345
|
+
type="button"
|
|
346
|
+
role="menuitemradio"
|
|
347
|
+
aria-checked={pref === theme.value}
|
|
348
|
+
tabIndex={-1}
|
|
349
|
+
className={cx('seg-btn', pref === theme.value && 'seg-active')}
|
|
350
|
+
onClick={() => setPref(theme.value)}
|
|
351
|
+
>
|
|
352
|
+
<i className={`bi ${theme.icon}`} aria-hidden="true" /> {theme.label}
|
|
353
|
+
</button>
|
|
354
|
+
))}
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
{items && items.length > 0 && <div className="user-menu-sep" role="separator" />}
|
|
358
|
+
{items?.map((item, i) => {
|
|
359
|
+
const cls = cx('user-menu-item', item.danger && 'is-danger');
|
|
360
|
+
const body = (
|
|
361
|
+
<>
|
|
362
|
+
<i className={`bi ${item.icon ?? 'bi-dot'}`} aria-hidden="true" />
|
|
363
|
+
<span>{item.label}</span>
|
|
364
|
+
</>
|
|
365
|
+
);
|
|
366
|
+
return (
|
|
367
|
+
<div key={`${i}:${item.label}`} role="none" className="user-menu-row">
|
|
368
|
+
{item.divider && i > 0 && <div className="user-menu-sep" role="separator" />}
|
|
369
|
+
{item.to != null
|
|
370
|
+
? <Link role="menuitem" tabIndex={-1} className={cls} to={item.to} onClick={() => select(item)}>{body}</Link>
|
|
371
|
+
: <button type="button" role="menuitem" tabIndex={-1} className={cls} onClick={() => select(item)}>{body}</button>}
|
|
372
|
+
</div>
|
|
373
|
+
);
|
|
374
|
+
})}
|
|
375
|
+
{children != null && children !== false && (
|
|
376
|
+
<div role="none" className="user-menu-slot" onClick={onChildrenClick}>{children}</div>
|
|
377
|
+
)}
|
|
378
|
+
<div className="user-menu-sep" role="separator" />
|
|
379
|
+
<button
|
|
380
|
+
type="button"
|
|
381
|
+
role="menuitem"
|
|
382
|
+
tabIndex={-1}
|
|
383
|
+
className="user-menu-item is-danger"
|
|
384
|
+
aria-disabled={signingOut || undefined}
|
|
385
|
+
aria-busy={signingOut || undefined}
|
|
386
|
+
onClick={() => { void signOut(); }}
|
|
387
|
+
>
|
|
388
|
+
<i className={signingOut ? 'bi bi-arrow-repeat spin' : 'bi bi-box-arrow-right'} aria-hidden="true" />
|
|
389
|
+
<span>{signingOut ? 'Signing out…' : 'Sign out'}</span>
|
|
390
|
+
</button>
|
|
391
|
+
</div>
|
|
392
|
+
</Popover>
|
|
393
|
+
</>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function menuItems(root: HTMLElement | null): HTMLElement[] {
|
|
398
|
+
if (!root) return [];
|
|
399
|
+
return Array.from(root.querySelectorAll<HTMLElement>(ITEM_SELECTOR))
|
|
400
|
+
.filter((el) => !(el as HTMLButtonElement).disabled);
|
|
401
|
+
}
|
package/src/ui/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/ui/shell.ts
CHANGED