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.
Files changed (43) hide show
  1. package/README.md +2 -2
  2. package/docs/README.md +1 -0
  3. package/docs/admin-identity-directories-sources.json +130 -0
  4. package/docs/admin-identity-directories.md +60 -0
  5. package/docs/admin-identity-users.md +6 -1
  6. package/docs/admin-members.md +16 -0
  7. package/docs/admin-network-security.md +1 -1
  8. package/docs/menus-and-access.md +6 -2
  9. package/docs/user-menu.md +131 -0
  10. package/package.json +1 -1
  11. package/src/admin/core/index.ts +4 -4
  12. package/src/admin/domains/identity.ts +19 -1
  13. package/src/admin/identity/groups/GroupDetail.tsx +287 -0
  14. package/src/admin/identity/groups/GroupsPage.tsx +125 -0
  15. package/src/admin/identity/groups/group-permissions.ts +5 -0
  16. package/src/admin/identity/groups/group-sections/AuditSection.tsx +57 -0
  17. package/src/admin/identity/groups/group-sections/AuthConfigDialog.tsx +677 -0
  18. package/src/admin/identity/groups/group-sections/EventsSection.tsx +53 -0
  19. package/src/admin/identity/groups/group-sections/GeofenceSection.tsx +198 -0
  20. package/src/admin/identity/groups/group-sections/IdentitySection.tsx +211 -0
  21. package/src/admin/identity/groups/group-sections/MembersSection.tsx +25 -0
  22. package/src/admin/identity/groups/group-sections/OverviewSection.tsx +112 -0
  23. package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +102 -0
  24. package/src/admin/identity/groups/group-sections/auth-config.ts +285 -0
  25. package/src/admin/identity/groups/group-sections/geofence-data.ts +40 -0
  26. package/src/admin/identity/groups/group-sections/models.ts +110 -0
  27. package/src/admin/identity/groups/group-sections/shared.ts +21 -0
  28. package/src/admin/identity/groups/index.ts +6 -0
  29. package/src/admin/identity/groups/models.ts +70 -0
  30. package/src/admin/identity/index.ts +1 -0
  31. package/src/admin/identity/members/MemberDetail.tsx +32 -3
  32. package/src/admin/identity/members/models.ts +3 -0
  33. package/src/admin/identity/users/PersonalApiKeysPage.tsx +220 -0
  34. package/src/admin/identity/users/UserDetail.tsx +2 -2
  35. package/src/admin/identity/users/index.ts +2 -0
  36. package/src/admin/identity/users/sections/PermissionsSection.tsx +2 -2
  37. package/src/admin/registry.ts +3 -1
  38. package/src/ui/FileField.tsx +1 -10
  39. package/src/ui/SidebarNav.tsx +2 -2
  40. package/src/ui/UserMenu.tsx +401 -0
  41. package/src/ui/index.ts +1 -0
  42. package/src/ui/safe-url.ts +14 -0
  43. 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
@@ -35,6 +35,7 @@ export * from './RouteError';
35
35
  export * from './route-error';
36
36
  export * from './password';
37
37
  export * from './Popover';
38
+ export * from './UserMenu';
38
39
  export * from './TagInput';
39
40
  export * from './toast';
40
41
  export * from './FileDrop';
@@ -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
@@ -4,6 +4,7 @@ export * from './Guarded';
4
4
  export * from './menu-registry';
5
5
  export * from './SidebarNav';
6
6
  export * from './RightPanel';
7
+ export * from './UserMenu';
7
8
  export * from './RouteError';
8
9
  export * from './route-error';
9
10
  export * from './modal';