portal-mojo 0.2.6 → 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/user-menu.md +131 -0
- package/package.json +1 -1
- package/src/ui/FileField.tsx +1 -10
- 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
package/README.md
CHANGED
|
@@ -23,8 +23,8 @@ additional exports are required.
|
|
|
23
23
|
|---|---|
|
|
24
24
|
| `portal-mojo/client` | Typed django-mojo protocol layer: envelope unwrap at exactly one boundary (a failed save **rejects**), `start`/`size` paging, `'-field'` sort, Django lookups, the URL-synced `useTableParams` store (single source of truth for table state), TanStack Query hooks. Auth client: password / magic-link / passkey login, forgot/reset, cross-origin handoff (`?auth_code=` scrub-before-network), single-flight refresh + pre-request gate (synthetic-401 reject, `/api/token/refresh` recursion guard), `X-Mojo-UID` device header, `getAuthSnapshot`/`subscribeAuth` for React. Auth-challenged realtime transport: `RealtimeProvider`/`RealtimeClient`, refcounted topics, typed event projection, and a deterministic mock. Boot auth with `initAuth()`. The in-memory HTTP mock transport lives here too — it is the wire contract's executable spec and evolves in lockstep with the client (any seeded active user logs in with email + `"mojo"`). |
|
|
25
25
|
| `portal-mojo/client/runtime` | Stable, narrow client boundary for first-party application runtime code. It omits eager mock-test controls; the mock transport itself loads on the first mock request. |
|
|
26
|
-
| `portal-mojo/ui` | Mission-control UI: `ModelTable`, `FilterBar`/`FilterPills`, `SchemaForm`/`formModal`, `DetailView`, `RecordFeed`, `AttachmentQueue`/`UploadQueue`, `ImageEditor`/`imageEditorModal`, awaitable native-`<dialog>` `modal`, `toast`, `ThemeProvider`, `Guarded` (permission slot), `GroupSwitcher` (searchable tree selector), `RequiresGroup`, the sidebar engine (`registerMenus`/`setDefaultMenu` registry + `SidebarNav` — static or searchable accordion with a compact icon rail; global and group scopes are explicit), `Badge`/`MetricCard`/`Spark`, `fmt` formatter namespace. |
|
|
27
|
-
| `portal-mojo/ui/shell` | Stable, narrow boundary for application-shell primitives: theme, guards, menus/sidebar, right panel, modal/toast hosts, password helpers, and formatters. |
|
|
26
|
+
| `portal-mojo/ui` | Mission-control UI: `ModelTable`, `FilterBar`/`FilterPills`, `SchemaForm`/`formModal`, `DetailView`, `RecordFeed`, `AttachmentQueue`/`UploadQueue`, `ImageEditor`/`imageEditorModal`, awaitable native-`<dialog>` `modal`, `toast`, `ThemeProvider`, `UserMenu` (top-nav account menu), `Guarded` (permission slot), `GroupSwitcher` (searchable tree selector), `RequiresGroup`, the sidebar engine (`registerMenus`/`setDefaultMenu` registry + `SidebarNav` — static or searchable accordion with a compact icon rail; global and group scopes are explicit), `Badge`/`MetricCard`/`Spark`, `fmt` formatter namespace. |
|
|
27
|
+
| `portal-mojo/ui/shell` | Stable, narrow boundary for application-shell primitives: theme, guards, menus/sidebar, right panel, account menu (`UserMenu`), modal/toast hosts, password helpers, and formatters. |
|
|
28
28
|
| `portal-mojo/charts` | Dependency-free SVG charts: `SeriesChart` (line/bar/area, stacked bars, legend toggle, crosshair tooltip) and `MetricsChart` (granularity/range/type control bar for `/api/metrics/fetch`). |
|
|
29
29
|
| `portal-mojo/admin/core` | Side-effect-light Admin contracts and host: `AdminSection`, the discriminated `AdminRoute` XOR, `adminSectionRoutes`, `adminSectionsMenu`, and `AdminLazyPage`. |
|
|
30
30
|
| `portal-mojo/admin/registry` | Stable, narrow built-in `ADMIN_SECTIONS` roster for first-party hosts, without broad page barrels. It installs the optional Email→DNS adapter synchronously so communications routes work before DNS navigation. |
|
package/docs/README.md
CHANGED
|
@@ -51,6 +51,7 @@ everything: run the showcase (`npm run dev:showcase`) → **Develop → Componen
|
|
|
51
51
|
| [image-editor.md](image-editor.md) | Full-resolution crop/transform/filter composition, bounded history, PNG Blob results, modal helper, and ImageField pre-upload editing |
|
|
52
52
|
| [loading.md](loading.md) | `Spinner`, `busy()`/`busyWhile()` blocking overlay, `ViewLoader`, `InlineLoader`, `Busy` — the anti-flash delay rule and skeleton-vs-loader guidance |
|
|
53
53
|
| [popover.md](popover.md) | Anchored top-layer popover shell — placement, reposition, outside/Escape close, the dialog stacking story |
|
|
54
|
+
| [user-menu.md](user-menu.md) | Top-nav account menu — avatar trigger, identity header, theme control, app items, pending Sign out, signed-out matrix, keyboard map |
|
|
54
55
|
| [taginput.md](taginput.md) | Chip/tag entry — CSV wire shape, keyboard matrix, validation + inline errors |
|
|
55
56
|
| [combobox.md](combobox.md) | The house autocomplete — options with descriptions/meta, commit-only change pipeline, allowCustom, ARIA |
|
|
56
57
|
| [location-address.md](location-address.md) | Six-endpoint LocationClient, private provider sessions, stale guards, controlled AddressField, atomic form patching |
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# UserMenu — the top-nav account menu
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import { UserMenu, type UserMenuItem } from 'portal-mojo/ui';
|
|
5
|
+
// also on the narrow shell boundary:
|
|
6
|
+
import { UserMenu, type UserMenuItem } from 'portal-mojo/ui/shell';
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The shared identity menu for every django-mojo portal's top bar — replaces
|
|
10
|
+
hand-rolled "user chips" and the separate theme-cycle button. Ported from
|
|
11
|
+
web-mojo's TopNav user dropdown (`src/core/views/navigation/TopNav.js`:
|
|
12
|
+
avatar-or-icon trigger, end-aligned dropdown, divider rows) and
|
|
13
|
+
`PortalApp._injectThemeToggleItems` (theme choice sits just above the
|
|
14
|
+
trailing logout); the theme dialog becomes an inline Light/Dark/System
|
|
15
|
+
segmented control.
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
<header className="topnav">
|
|
19
|
+
<h2 className="topnav-title">Dashboard</h2>
|
|
20
|
+
<div className="topnav-right">
|
|
21
|
+
<UserMenu
|
|
22
|
+
authMode="inapp"
|
|
23
|
+
items={[
|
|
24
|
+
{ label: 'My profile', icon: 'bi-person', to: '/profile' },
|
|
25
|
+
{ label: 'Leave organization', icon: 'bi-box-arrow-left', danger: true, divider: true, onSelect: leave },
|
|
26
|
+
]}
|
|
27
|
+
onSignOut={async () => { await revokeServerSession(); logout(); }}
|
|
28
|
+
/>
|
|
29
|
+
</div>
|
|
30
|
+
</header>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Live demo: showcase → Components → Overlays → **UserMenu**.
|
|
34
|
+
|
|
35
|
+
## Props
|
|
36
|
+
|
|
37
|
+
| Prop | Type | Notes |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `items?` | `UserMenuItem[]` | App entries, rendered between the theme control and Sign out. |
|
|
40
|
+
| `children?` | `ReactNode` | Rendered after `items`, above Sign out. Elements with `role="menuitem"` (and `tabIndex={-1}`, class `user-menu-item` for the look) join arrow-key navigation, and clicking one closes the menu. |
|
|
41
|
+
| `authMode?` | `'inapp' \| 'hosted'` | Signed-out affordance (see matrix). The app knows its mode — the toolkit never imports app config. Unknown values warn once and render the signed-out chip. |
|
|
42
|
+
| `onSignOut?` | `() => void \| Promise<void>` | Replaces the default `logout()`. Sign out shows a pending state ("Signing out…", spinner, `aria-busy`) until it settles; repeat clicks are ignored. A rejection toasts `Sign out failed: <message>` and leaves the menu open for a retry. |
|
|
43
|
+
| `size?` | `'sm' \| 'md'` | Trigger density: 24px vs 28px avatar. Default `md`; unknown values warn once and fall back. |
|
|
44
|
+
| `className?` | `string` | Appended to the trigger (or the signed-out element). |
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
interface UserMenuItem {
|
|
48
|
+
label: string;
|
|
49
|
+
icon?: string; // bootstrap-icons class, e.g. 'bi-person'
|
|
50
|
+
to?: string; // react-router <Link> destination
|
|
51
|
+
onSelect?: () => void; // runs after the menu closes (also for `to` items)
|
|
52
|
+
danger?: boolean; // danger tone
|
|
53
|
+
divider?: boolean; // separator line ABOVE this item
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`divider` is a flag on the following item, not a standalone row (web-mojo's
|
|
58
|
+
`{divider: true}` rows map to `divider: true` on the next entry).
|
|
59
|
+
|
|
60
|
+
## Anatomy
|
|
61
|
+
|
|
62
|
+
- **Trigger** — `<button>` with avatar (image, or `fmt.initials(name)` while
|
|
63
|
+
loading / without one / after an image error), display name, caret.
|
|
64
|
+
`aria-haspopup="menu"`, `aria-expanded`, `aria-controls` (while open), and
|
|
65
|
+
`aria-label="Account menu: <name>"` so the avatar-only phone trigger keeps
|
|
66
|
+
an accessible name. Under **720px** the name and caret hide.
|
|
67
|
+
- **Popover** (`placement="bottom-end"`, 280px, clamped to the viewport):
|
|
68
|
+
identity header (42px avatar, name, email, `is_superuser` → "System admin"
|
|
69
|
+
chip), then the `role="menu"` list: Theme (three `menuitemradio`s wired to
|
|
70
|
+
`useTheme().setPref`), items, children, Sign out (danger tone).
|
|
71
|
+
- Name = `me.display_name || me.email || token email || '…'`.
|
|
72
|
+
|
|
73
|
+
## Wire contract
|
|
74
|
+
|
|
75
|
+
- Identity: `useMe()` → `GET /api/user/me` (cache key `['me', uid]`, the row
|
|
76
|
+
is sanitized so `avatar` is `{id} | null`).
|
|
77
|
+
- Avatar: when `me.avatar?.id` is set, `mojoCall('/api/fileman/file/<id>')`
|
|
78
|
+
runs **only while mounted** (AbortController on unmount/id change; failures
|
|
79
|
+
silent). The image is `thumbnail ?? url`, each through `safePreviewUrl`
|
|
80
|
+
(`ui/safe-url.ts`, shared with FileField): same-origin `/paths` or
|
|
81
|
+
credential-free `http(s)` only.
|
|
82
|
+
- Sign out: `onSignOut()` or `logout()` (client-side; django-mojo has no
|
|
83
|
+
logout endpoint).
|
|
84
|
+
|
|
85
|
+
## Invariants
|
|
86
|
+
|
|
87
|
+
- The avatar URL is a **capability**: it lives in component state only and
|
|
88
|
+
is never written into any Query cache (the `me` cache holds `{id}` by
|
|
89
|
+
design). A previous identity's image can never render for the next
|
|
90
|
+
(state is keyed by avatar id).
|
|
91
|
+
- No avatar request while signed out or when `avatar` is null.
|
|
92
|
+
- Popover is controlled; outside-mousedown closes without moving focus.
|
|
93
|
+
- Tokens only — `apps/portal/src/theme/user-menu.css`; both themes from the
|
|
94
|
+
token swap. Reuses `.seg`/`.seg-btn`, `.chip-primary`, `.spin`,
|
|
95
|
+
`.btn-primary`/`.btn-compact`, `.chip-muted`.
|
|
96
|
+
|
|
97
|
+
## Signed-out matrix
|
|
98
|
+
|
|
99
|
+
`useAuthSnapshot().authenticated === false` renders no menu:
|
|
100
|
+
|
|
101
|
+
| `authMode` | Renders |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `'inapp'` | `<Link to="/auth/login">` Sign in button |
|
|
104
|
+
| `'hosted'` and `hostedAuthUrl()` resolves | Sign in button → `redirectToHostedAuth()` |
|
|
105
|
+
| `'hosted'` with no API origin, or omitted | muted "Signed out" chip |
|
|
106
|
+
|
|
107
|
+
## Keyboard
|
|
108
|
+
|
|
109
|
+
| Where | Key | Effect |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| Trigger | Enter / Space / click | Toggle; on open focus the first item |
|
|
112
|
+
| Trigger | ↓ / ↑ | Open (or enter the open menu) on the first / last item |
|
|
113
|
+
| Menu | ↓ / ↑ | Next / previous item (wraps; includes the theme radios) |
|
|
114
|
+
| Menu | Home / End | First / last item |
|
|
115
|
+
| Theme control | ← / → | Previous / next theme option (wraps) |
|
|
116
|
+
| Menu | Enter / Space | Activate (Space also activates `to` links) |
|
|
117
|
+
| Menu | Escape | Close, focus back to the trigger (first Escape only closes the menu inside a modal) |
|
|
118
|
+
| Menu | Tab / Shift+Tab | Close; focus continues from the trigger |
|
|
119
|
+
|
|
120
|
+
Selecting an item or a `role="menuitem"` child closes the menu and returns
|
|
121
|
+
focus to the trigger before `onSelect` runs (so dialogs opened from
|
|
122
|
+
`onSelect` restore focus to a live element). A successful Sign out closes the
|
|
123
|
+
menu. Theme picks apply immediately and keep the menu open.
|
|
124
|
+
|
|
125
|
+
## Pitfalls
|
|
126
|
+
|
|
127
|
+
- Needs a router context (items with `to`, and the in-app Sign in link) and
|
|
128
|
+
`ThemeProvider` for the theme control; `ToastHost` for the failure toast.
|
|
129
|
+
- Don't also render a theme toggle button in the same bar — the menu owns it.
|
|
130
|
+
- `onSignOut` that never settles leaves Sign out pending; resolve or reject.
|
|
131
|
+
- Apps must ship `theme/user-menu.css` (the package ships no CSS).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portal-mojo",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
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.
|
|
@@ -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