portal-mojo 0.2.7 → 0.2.9
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 +59 -11
- package/docs/DetailView.md +3 -2
- package/docs/README.md +7 -2
- package/docs/account.md +261 -0
- package/docs/admin-bouncer.md +3 -3
- package/docs/admin-credentials.md +4 -3
- package/docs/admin-devices-geoip.md +1 -1
- package/docs/admin-dns-certificates.md +2 -2
- package/docs/admin-dns-records.md +1 -1
- package/docs/admin-edge.md +210 -0
- package/docs/admin-identity-directories-sources.json +2 -2
- package/docs/admin-identity-directories.md +9 -0
- package/docs/admin-identity-users.md +2 -2
- package/docs/admin-jobs.md +4 -4
- package/docs/admin-media-rendering.md +142 -0
- package/docs/admin-modals.md +7 -0
- package/docs/admin-monitoring.md +4 -4
- package/docs/admin-network-security.md +2 -2
- package/docs/admin-phonehub.md +4 -1
- package/docs/admin-storage.md +5 -2
- package/docs/auth-pages.md +8 -3
- package/docs/auth.md +45 -2
- package/docs/calendar.md +1 -1
- package/docs/client.md +159 -26
- package/docs/collection-multiselect.md +2 -2
- package/docs/combobox.md +2 -2
- package/docs/dataview.md +1 -1
- package/docs/datepicker.md +3 -3
- package/docs/daterange.md +1 -1
- package/docs/datetimepicker.md +1 -1
- package/docs/defineModel.md +40 -7
- package/docs/detail-primitives.md +1 -1
- package/docs/feedback.md +18 -8
- package/docs/file-upload-ux.md +2 -2
- package/docs/idioms.md +13 -1
- package/docs/loading.md +2 -2
- package/docs/location-address.md +2 -1
- package/docs/markdown.md +3 -3
- package/docs/menus-and-access.md +21 -1
- package/docs/multiselect-dropdown.md +1 -1
- package/docs/popover.md +1 -1
- package/docs/record-feed.md +2 -2
- package/docs/taginput.md +2 -1
- package/docs/timepicker.md +1 -1
- package/docs/timezone-select.md +4 -3
- package/docs/user-menu.md +4 -3
- package/docs/worldmap.md +2 -2
- package/package.json +4 -2
- package/src/account/AccountModal.tsx +276 -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/core/index.ts +1 -1
- package/src/admin/credentials/secret-dialog.tsx +3 -77
- package/src/admin/dns/models.ts +1 -1
- package/src/admin/domains/infrastructure.ts +12 -2
- package/src/admin/domains/security.ts +3 -1
- package/src/admin/edge/BlocklistPage.tsx +110 -0
- package/src/admin/edge/UpstreamsPage.tsx +113 -0
- package/src/admin/edge/VhostDetail.tsx +166 -0
- package/src/admin/edge/VhostWizard.tsx +273 -0
- package/src/admin/edge/VhostsPage.tsx +47 -0
- package/src/admin/edge/api.ts +56 -0
- package/src/admin/edge/index.ts +15 -0
- package/src/admin/edge/models.ts +395 -0
- package/src/admin/edge/permissions.ts +13 -0
- package/src/admin/identity/users/PersonalApiKeysPage.tsx +10 -0
- 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 +10 -103
- package/src/admin/index.ts +1 -0
- package/src/admin/phonehub/api.ts +5 -3
- package/src/admin/public/infrastructure.ts +1 -0
- package/src/admin/registry.ts +2 -1
- package/src/admin/storage/MediaRenderingPage.tsx +258 -0
- package/src/admin/storage/file-renditions.ts +12 -0
- package/src/admin/storage/index.ts +2 -0
- package/src/admin/storage/rendition-settings.ts +264 -0
- package/src/charts/exportChart.ts +1 -1
- package/src/client/action-result.ts +23 -61
- package/src/client/active-group.ts +9 -3
- package/src/client/auth.ts +207 -6
- package/src/client/client.ts +49 -10
- package/src/client/errors.ts +57 -2
- package/src/client/group-context.ts +2 -0
- package/src/client/group.tsx +112 -20
- package/src/client/hooks.ts +7 -3
- package/src/client/mock.ts +1429 -20
- package/src/client/model.ts +15 -3
- package/src/client/renditions-mock.ts +212 -0
- package/src/client/scoped.ts +19 -5
- package/src/styles/components/account.css +304 -0
- package/src/styles/components/admin-assistant.css +28 -0
- package/src/styles/components/admin-bouncer.css +53 -0
- package/src/styles/components/admin-cloudwatch.css +23 -0
- package/src/styles/components/admin-dashboard.css +16 -0
- package/src/styles/components/admin-devices.css +260 -0
- package/src/styles/components/admin-dns-certificates.css +28 -0
- package/src/styles/components/admin-dns-registrar.css +1 -0
- package/src/styles/components/admin-dns.css +43 -0
- package/src/styles/components/admin-edge.css +1 -0
- package/src/styles/components/admin-incidents.css +14 -0
- package/src/styles/components/admin-jobs.css +237 -0
- package/src/styles/components/admin-media-rendering.css +38 -0
- package/src/styles/components/admin-messaging.css +3 -0
- package/src/styles/components/admin-monitoring.css +152 -0
- package/src/styles/components/admin-network.css +406 -0
- package/src/styles/components/admin-phonehub.css +61 -0
- package/src/styles/components/admin-push.css +30 -0
- package/src/styles/components/admin-rules.css +23 -0
- package/src/styles/components/admin-security.css +37 -0
- package/src/styles/components/admin-shortlinks.css +8 -0
- package/src/styles/components/admin-signin.css +64 -0
- package/src/styles/components/admin-storage.css +4 -0
- package/src/styles/components/auth.css +162 -0
- package/src/styles/components/calendar.css +228 -0
- package/src/styles/components/charts-c2.css +249 -0
- package/src/styles/components/collection-multiselect.css +114 -0
- package/src/styles/components/collection-select.css +63 -0
- package/src/styles/components/combobox.css +42 -0
- package/src/styles/components/dataview.css +139 -0
- package/src/styles/components/datepicker.css +106 -0
- package/src/styles/components/daterange.css +119 -0
- package/src/styles/components/datetimepicker.css +230 -0
- package/src/styles/components/detail-primitives.css +246 -0
- package/src/styles/components/detailview.css +69 -0
- package/src/styles/components/drawer.css +111 -0
- package/src/styles/components/file-upload.css +112 -0
- package/src/styles/components/form-wizard.css +11 -0
- package/src/styles/components/formview.css +74 -0
- package/src/styles/components/group-admin.css +302 -0
- package/src/styles/components/guardrail.css +29 -0
- package/src/styles/components/idioms.css +84 -0
- package/src/styles/components/image-editor.css +80 -0
- package/src/styles/components/loading.css +200 -0
- package/src/styles/components/location-address.css +32 -0
- package/src/styles/components/markdown.css +192 -0
- package/src/styles/components/multiselect-dropdown.css +92 -0
- package/src/styles/components/popover.css +35 -0
- package/src/styles/components/record-feed.css +161 -0
- package/src/styles/components/right-panel.css +37 -0
- package/src/styles/components/tabs.css +26 -0
- package/src/styles/components/taginput.css +132 -0
- package/src/styles/components/timepicker.css +282 -0
- package/src/styles/components/user-admin.css +189 -0
- package/src/styles/components/user-menu.css +98 -0
- package/src/styles/components/worldmap.css +97 -0
- package/src/styles/core.css +807 -0
- package/src/styles/index.css +69 -0
- package/src/styles/required-tokens.json +32 -0
- package/src/ui/FileField.tsx +12 -7
- package/src/ui/FormWizard.tsx +9 -3
- package/src/ui/ThemeProvider.tsx +1 -1
- package/src/ui/UserMenu.tsx +6 -2
- package/src/ui/detail/index.ts +2 -2
- package/src/ui/index.ts +4 -2
- package/src/ui/loading.tsx +1 -1
- package/src/ui/modal.tsx +76 -0
- package/src/ui/secret-dialog.tsx +83 -0
- package/src/ui/toast.tsx +21 -3
package/docs/taginput.md
CHANGED
|
@@ -99,7 +99,8 @@ containing commas that would re-split on the next load.
|
|
|
99
99
|
|
|
100
100
|
## Styling
|
|
101
101
|
|
|
102
|
-
Classes are styled by the
|
|
102
|
+
Classes are styled by the package stylesheet
|
|
103
|
+
(`packages/portal-mojo/src/styles/components/taginput.css`,
|
|
103
104
|
tokens only, light + dark): `.tag-input`, `.tag-input-wrap` (wears the `.input`
|
|
104
105
|
field look, accent ring on `:focus-within`), `.tag-chip` / `.tag-chip-text` /
|
|
105
106
|
`.tag-chip-x`, `.tag-input-field`, `.tag-input-foot` / `.tag-input-count` /
|
package/docs/timepicker.md
CHANGED
|
@@ -150,7 +150,7 @@ zone (`Intl.DateTimeFormat().resolvedOptions().timeZone`, `'UTC'` on failure)
|
|
|
150
150
|
|
|
151
151
|
## Styling
|
|
152
152
|
|
|
153
|
-
`
|
|
153
|
+
`packages/portal-mojo/src/styles/components/timepicker.css` — `mojo-time-*` anatomy only
|
|
154
154
|
(`trigger` / `trigger-text` / `trigger-clear` / `popover-inner` /
|
|
155
155
|
`picker-inline` / `stepper*` / `ampm*` / `tz-host` / `foot`), tokens only,
|
|
156
156
|
both themes. The trigger wears the house `.input` look so it lines up in a
|
package/docs/timezone-select.md
CHANGED
|
@@ -140,8 +140,9 @@ be rendering a plain `<select>` fallback in that slot; swapping it for
|
|
|
140
140
|
reachable on engines without `Intl.supportedValuesOf`.
|
|
141
141
|
- An empty `timezones={[]}` means "no override" (source parity), not "no
|
|
142
142
|
zones".
|
|
143
|
-
- No stylesheet
|
|
144
|
-
pixel comes from
|
|
145
|
-
|
|
143
|
+
- No stylesheet of its own: the wrapper `.tz-select` is an unstyled hook and
|
|
144
|
+
every pixel comes from
|
|
145
|
+
`packages/portal-mojo/src/styles/components/combobox.css`, which ships in
|
|
146
|
+
`portal-mojo/styles.css`.
|
|
146
147
|
- The dropdown is ComboBox's locally-positioned list — inside
|
|
147
148
|
`overflow:hidden` containers it clips (MERGE-WIRE: Popover primitive #1271).
|
package/docs/user-menu.md
CHANGED
|
@@ -90,8 +90,8 @@ interface UserMenuItem {
|
|
|
90
90
|
(state is keyed by avatar id).
|
|
91
91
|
- No avatar request while signed out or when `avatar` is null.
|
|
92
92
|
- Popover is controlled; outside-mousedown closes without moving focus.
|
|
93
|
-
- Tokens only — `
|
|
94
|
-
token swap. Reuses `.seg`/`.seg-btn`, `.chip-primary`, `.spin`,
|
|
93
|
+
- Tokens only — `src/styles/components/user-menu.css`, shipped in
|
|
94
|
+
`portal-mojo/styles.css`; both themes from the token swap. Reuses `.seg`/`.seg-btn`, `.chip-primary`, `.spin`,
|
|
95
95
|
`.btn-primary`/`.btn-compact`, `.chip-muted`.
|
|
96
96
|
|
|
97
97
|
## Signed-out matrix
|
|
@@ -128,4 +128,5 @@ menu. Theme picks apply immediately and keep the menu open.
|
|
|
128
128
|
`ThemeProvider` for the theme control; `ToastHost` for the failure toast.
|
|
129
129
|
- Don't also render a theme toggle button in the same bar — the menu owns it.
|
|
130
130
|
- `onSignOut` that never settles leaves Sign out pending; resolve or reject.
|
|
131
|
-
- Apps must
|
|
131
|
+
- Apps must import `portal-mojo/styles.css` and declare the tokens; the
|
|
132
|
+
UserMenu styles ship in it.
|
package/docs/worldmap.md
CHANGED
|
@@ -298,5 +298,5 @@ round-trip, clamping, fitting, antimeridian splitting), the centroid table
|
|
|
298
298
|
(244 unique keys, coordinate ranges, `ES` = Spain), the binding helpers
|
|
299
299
|
(slug parsing, sizing, sorting/top-N/drops), tone fallbacks and their warns,
|
|
300
300
|
the land normalizer, and the source invariants above (no network, no map
|
|
301
|
-
library, no HTML-string tooltips, tokens-only CSS, and the
|
|
302
|
-
|
|
301
|
+
library, no HTML-string tooltips, tokens-only CSS, and the package
|
|
302
|
+
stylesheet importing `worldmap.css`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portal-mojo",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
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",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"exports": {
|
|
28
28
|
"./client": "./src/client/index.ts",
|
|
29
29
|
"./client/runtime": "./src/client/runtime.ts",
|
|
30
|
+
"./account": "./src/account/index.ts",
|
|
30
31
|
"./ui": "./src/ui/index.ts",
|
|
31
32
|
"./ui/shell": "./src/ui/shell.ts",
|
|
32
33
|
"./charts": "./src/charts/index.ts",
|
|
@@ -41,7 +42,8 @@
|
|
|
41
42
|
"./admin/infrastructure": "./src/admin/public/infrastructure.ts",
|
|
42
43
|
"./admin/communications": "./src/admin/public/communications.ts",
|
|
43
44
|
"./admin/assistant": "./src/admin/public/assistant.ts",
|
|
44
|
-
"./admin/assistant/launcher": "./src/admin/public/assistant-launcher.ts"
|
|
45
|
+
"./admin/assistant/launcher": "./src/admin/public/assistant-launcher.ts",
|
|
46
|
+
"./styles.css": "./src/styles/index.css"
|
|
45
47
|
},
|
|
46
48
|
"scripts": {
|
|
47
49
|
"typecheck": "tsc --noEmit"
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// AccountModal — the self-service "My account" modal (portal-mojo #7184,
|
|
2
|
+
// approved shape 2026-10-07). ONE modal: a short rail is the whole
|
|
3
|
+
// navigation, each entry one flat section (no drill-ins, no cross-links),
|
|
4
|
+
// Sign out at the rail's foot, the section title + Close in the header.
|
|
5
|
+
// ≈760×520 on desktop; under 720px a full-screen sheet whose rail becomes a
|
|
6
|
+
// row of tabs across the top.
|
|
7
|
+
//
|
|
8
|
+
// Opened imperatively on the toolkit modal stack:
|
|
9
|
+
//
|
|
10
|
+
// <UserMenu items={[{ label: 'My account', icon: 'bi-person-circle', onSelect: () => openAccountModal() }]} />
|
|
11
|
+
//
|
|
12
|
+
// Every focused flow (add passkey, authenticator, recovery codes, password,
|
|
13
|
+
// email/phone change, verify, photo) stacks its own dialog over this one;
|
|
14
|
+
// fresh-auth step-ups come from the app's FreshAuthHost. Styles:
|
|
15
|
+
// styles/components/account.css, shipped in portal-mojo/styles.css (tokens
|
|
16
|
+
// only, both themes).
|
|
17
|
+
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
|
18
|
+
import { hostedAuthUrl, logout, onAuth, redirectToHostedAuth, useAuthSnapshot, useMe, type Me } from '../client/runtime';
|
|
19
|
+
import { fmt, modal, toast, useAvatarUrl, type UserMenuAuthMode } from '../ui';
|
|
20
|
+
import type { NotificationKind } from './api';
|
|
21
|
+
import {
|
|
22
|
+
AccountApiKeysSection, AccountNotificationsSection, AccountPasskeysSection, AccountProfileSection,
|
|
23
|
+
AccountSecuritySection, AccountSessionsSection,
|
|
24
|
+
} from './AccountSections';
|
|
25
|
+
|
|
26
|
+
export type AccountSectionKey = 'profile' | 'passkeys' | 'security' | 'sessions' | 'notifications' | 'api-keys';
|
|
27
|
+
|
|
28
|
+
export const ACCOUNT_SECTIONS: readonly AccountSectionKey[] = ['profile', 'passkeys', 'security', 'sessions', 'notifications', 'api-keys'];
|
|
29
|
+
|
|
30
|
+
export interface AccountSectionContext {
|
|
31
|
+
me: Me;
|
|
32
|
+
/** Close the whole modal. */
|
|
33
|
+
close: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** An app-supplied rail entry, appended after the built-ins. */
|
|
37
|
+
export interface AccountSectionDef {
|
|
38
|
+
key: string;
|
|
39
|
+
label: string;
|
|
40
|
+
/** Bootstrap-icons class, e.g. 'bi-building'. */
|
|
41
|
+
icon: string;
|
|
42
|
+
render: (ctx: AccountSectionContext) => ReactNode;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AccountModalProps {
|
|
46
|
+
/** Built-in sections to show, in order (default all six). Unknown keys warn and drop. */
|
|
47
|
+
sections?: AccountSectionKey[];
|
|
48
|
+
/** App sections appended to the rail. */
|
|
49
|
+
extraSections?: AccountSectionDef[];
|
|
50
|
+
/** Section open on mount (built-in or extra key). Unknown keys warn and fall back to the first. */
|
|
51
|
+
initialSection?: string;
|
|
52
|
+
/** Replaces the default `logout()` on Sign out. A rejection toasts and keeps the modal open. */
|
|
53
|
+
onSignOut?: () => void | Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* With the default sign out: 'hosted' sends the user to the hosted
|
|
56
|
+
* auth page after logout (when the deployment has one); 'inapp' goes to
|
|
57
|
+
* `#/auth/login`; omitted leaves routing to the app's auth gate.
|
|
58
|
+
*/
|
|
59
|
+
authMode?: UserMenuAuthMode;
|
|
60
|
+
/** The notification kinds this app sends (labels/descriptions/channels). */
|
|
61
|
+
notificationKinds?: NotificationKind[];
|
|
62
|
+
/** Called when the modal should close (Close, Escape via the host, sign out). */
|
|
63
|
+
onClose: () => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const BUILT_INS: Record<AccountSectionKey, { label: string; icon: string }> = {
|
|
67
|
+
profile: { label: 'Profile', icon: 'bi-person' },
|
|
68
|
+
passkeys: { label: 'Passkeys', icon: 'bi-fingerprint' },
|
|
69
|
+
security: { label: 'Security', icon: 'bi-shield-lock' },
|
|
70
|
+
sessions: { label: 'Sessions', icon: 'bi-display' },
|
|
71
|
+
notifications: { label: 'Notifications', icon: 'bi-bell' },
|
|
72
|
+
'api-keys': { label: 'API keys', icon: 'bi-key' },
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
interface ResolvedSection {
|
|
76
|
+
key: string;
|
|
77
|
+
label: string;
|
|
78
|
+
icon: string;
|
|
79
|
+
render: (ctx: AccountSectionContext) => ReactNode;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const warned = new Set<string>();
|
|
83
|
+
function warnOnce(message: string) {
|
|
84
|
+
if (warned.has(message)) return;
|
|
85
|
+
warned.add(message);
|
|
86
|
+
console.warn(message);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function AccountModal({
|
|
90
|
+
sections = [...ACCOUNT_SECTIONS],
|
|
91
|
+
extraSections,
|
|
92
|
+
initialSection,
|
|
93
|
+
onSignOut,
|
|
94
|
+
authMode,
|
|
95
|
+
notificationKinds,
|
|
96
|
+
onClose,
|
|
97
|
+
}: AccountModalProps) {
|
|
98
|
+
const auth = useAuthSnapshot();
|
|
99
|
+
const { data: me, isPending, isError, error } = useMe();
|
|
100
|
+
const uid = useId();
|
|
101
|
+
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
|
|
102
|
+
const [signingOut, setSigningOut] = useState(false);
|
|
103
|
+
|
|
104
|
+
const resolved = useMemo<ResolvedSection[]>(() => {
|
|
105
|
+
const out: ResolvedSection[] = [];
|
|
106
|
+
for (const key of sections) {
|
|
107
|
+
if (!(key in BUILT_INS)) { warnOnce(`AccountModal: unknown section "${String(key)}" — dropped`); continue; }
|
|
108
|
+
const meta = BUILT_INS[key];
|
|
109
|
+
out.push({
|
|
110
|
+
key,
|
|
111
|
+
...meta,
|
|
112
|
+
render: ({ me: row }) => {
|
|
113
|
+
switch (key) {
|
|
114
|
+
case 'profile': return <AccountProfileSection me={row} />;
|
|
115
|
+
case 'passkeys': return <AccountPasskeysSection me={row} />;
|
|
116
|
+
case 'security': return <AccountSecuritySection me={row} />;
|
|
117
|
+
case 'sessions': return <AccountSessionsSection me={row} />;
|
|
118
|
+
case 'notifications': return <AccountNotificationsSection me={row} kinds={notificationKinds} />;
|
|
119
|
+
case 'api-keys': return <AccountApiKeysSection me={row} />;
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
for (const extra of extraSections ?? []) {
|
|
125
|
+
if (out.some((s) => s.key === extra.key)) { warnOnce(`AccountModal: duplicate section key "${extra.key}" — dropped`); continue; }
|
|
126
|
+
out.push(extra);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}, [sections, extraSections, notificationKinds]);
|
|
130
|
+
|
|
131
|
+
const [active, setActive] = useState<string>(() => {
|
|
132
|
+
if (initialSection && resolved.some((s) => s.key === initialSection)) return initialSection;
|
|
133
|
+
if (initialSection) warnOnce(`AccountModal: unknown initialSection "${initialSection}" — opening "${resolved[0]?.key ?? ''}"`);
|
|
134
|
+
return resolved[0]?.key ?? '';
|
|
135
|
+
});
|
|
136
|
+
const current = resolved.find((s) => s.key === active) ?? resolved[0];
|
|
137
|
+
|
|
138
|
+
// On phones the rail is a scrolling tab strip: keep the active tab visible.
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
tabRefs.current.get(active)?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
|
141
|
+
}, [active]);
|
|
142
|
+
|
|
143
|
+
// The session ended underneath us (sign out elsewhere, refresh failure):
|
|
144
|
+
// there is no account to show. The auth events cover an app that drops
|
|
145
|
+
// this host on sign-out before the snapshot effect can run; the toolkit
|
|
146
|
+
// modal stack closes the child dialogs on the same events.
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (!auth.authenticated) onClose();
|
|
149
|
+
}, [auth.authenticated, onClose]);
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
const offs = [onAuth('logout', onClose), onAuth('unauthorized', onClose)];
|
|
152
|
+
return () => { for (const off of offs) off(); };
|
|
153
|
+
}, [onClose]);
|
|
154
|
+
|
|
155
|
+
const [avatarSrc, onAvatarError] = useAvatarUrl(me?.avatar?.id ?? null);
|
|
156
|
+
const name = (typeof me?.display_name === 'string' && me.display_name) || (typeof me?.email === 'string' && me.email) || auth.email || 'My account';
|
|
157
|
+
|
|
158
|
+
const signOut = async () => {
|
|
159
|
+
if (signingOut) return;
|
|
160
|
+
setSigningOut(true);
|
|
161
|
+
try {
|
|
162
|
+
if (onSignOut) {
|
|
163
|
+
await onSignOut();
|
|
164
|
+
} else {
|
|
165
|
+
logout();
|
|
166
|
+
if (authMode === 'hosted' && hostedAuthUrl()) redirectToHostedAuth();
|
|
167
|
+
else if (authMode === 'inapp' && typeof window !== 'undefined') window.location.hash = '#/auth/login';
|
|
168
|
+
}
|
|
169
|
+
onClose();
|
|
170
|
+
} catch (err) {
|
|
171
|
+
toast.error(`Sign out failed: ${err instanceof Error && err.message ? err.message : 'unknown error'}`);
|
|
172
|
+
setSigningOut(false);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Roving tabs: arrows move between rail entries (vertical rail on
|
|
177
|
+
// desktop, horizontal strip on phones), Home/End jump to the ends.
|
|
178
|
+
const onTabKey = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
179
|
+
const keys = resolved.map((s) => s.key);
|
|
180
|
+
const at = keys.indexOf(active);
|
|
181
|
+
let next = -1;
|
|
182
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') next = (at + 1) % keys.length;
|
|
183
|
+
else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') next = (at - 1 + keys.length) % keys.length;
|
|
184
|
+
else if (event.key === 'Home') next = 0;
|
|
185
|
+
else if (event.key === 'End') next = keys.length - 1;
|
|
186
|
+
if (next < 0) return;
|
|
187
|
+
event.preventDefault();
|
|
188
|
+
setActive(keys[next]!);
|
|
189
|
+
tabRefs.current.get(keys[next]!)?.focus();
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const tabId = (key: string) => `${uid}-tab-${key}`;
|
|
193
|
+
const panelId = `${uid}-panel`;
|
|
194
|
+
const titleId = `${uid}-title`;
|
|
195
|
+
|
|
196
|
+
const signOutButton = (className: string) => (
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
className={`acct-rail-item is-danger ${className}`}
|
|
200
|
+
aria-busy={signingOut || undefined}
|
|
201
|
+
disabled={signingOut}
|
|
202
|
+
onClick={() => void signOut()}
|
|
203
|
+
>
|
|
204
|
+
<i className={signingOut ? 'bi bi-arrow-repeat spin' : 'bi bi-box-arrow-right'} aria-hidden="true" />
|
|
205
|
+
<span>{signingOut ? 'Signing out…' : 'Sign out'}</span>
|
|
206
|
+
</button>
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div className="acct" aria-labelledby={titleId}>
|
|
211
|
+
<nav className="acct-rail" aria-label="Account sections">
|
|
212
|
+
<div className="acct-who">
|
|
213
|
+
<span className="acct-avatar acct-avatar-sm" aria-hidden="true">
|
|
214
|
+
{avatarSrc ? <img src={avatarSrc} alt="" referrerPolicy="no-referrer" onError={onAvatarError} /> : fmt.initials(name)}
|
|
215
|
+
</span>
|
|
216
|
+
<span className="acct-who-text">
|
|
217
|
+
<span className="acct-who-name">{name}</span>
|
|
218
|
+
<span className="acct-who-sub">My account</span>
|
|
219
|
+
</span>
|
|
220
|
+
</div>
|
|
221
|
+
<div className="acct-tabs" role="tablist" aria-label="Account sections" aria-orientation="vertical">
|
|
222
|
+
{resolved.map((section) => (
|
|
223
|
+
<button
|
|
224
|
+
key={section.key}
|
|
225
|
+
ref={(node) => { if (node) tabRefs.current.set(section.key, node); else tabRefs.current.delete(section.key); }}
|
|
226
|
+
id={tabId(section.key)}
|
|
227
|
+
type="button"
|
|
228
|
+
role="tab"
|
|
229
|
+
aria-selected={section.key === current?.key}
|
|
230
|
+
aria-controls={panelId}
|
|
231
|
+
tabIndex={section.key === current?.key ? 0 : -1}
|
|
232
|
+
className={`acct-rail-item${section.key === current?.key ? ' is-active' : ''}`}
|
|
233
|
+
onClick={() => setActive(section.key)}
|
|
234
|
+
onKeyDown={onTabKey}
|
|
235
|
+
>
|
|
236
|
+
<i className={`bi ${section.icon}`} aria-hidden="true" />
|
|
237
|
+
<span>{section.label}</span>
|
|
238
|
+
</button>
|
|
239
|
+
))}
|
|
240
|
+
</div>
|
|
241
|
+
{signOutButton('acct-signout-rail')}
|
|
242
|
+
</nav>
|
|
243
|
+
<div className="acct-panel">
|
|
244
|
+
<header className="acct-head">
|
|
245
|
+
<h2 id={titleId} className="acct-title">
|
|
246
|
+
<span className="acct-title-section">{current?.label ?? 'My account'}</span>
|
|
247
|
+
<span className="acct-title-sheet">My account</span>
|
|
248
|
+
</h2>
|
|
249
|
+
<button type="button" className="btn-icon" aria-label="Close" title="Close" onClick={onClose}>
|
|
250
|
+
<i className="bi bi-x-lg" aria-hidden="true" />
|
|
251
|
+
</button>
|
|
252
|
+
</header>
|
|
253
|
+
<div
|
|
254
|
+
id={panelId}
|
|
255
|
+
className="acct-body"
|
|
256
|
+
role="tabpanel"
|
|
257
|
+
aria-labelledby={current ? tabId(current.key) : undefined}
|
|
258
|
+
data-section={current?.key}
|
|
259
|
+
>
|
|
260
|
+
{isPending && <div className="acct-skel" aria-busy="true"><span className="skel skel-block" /><span className="skel skel-block" /></div>}
|
|
261
|
+
{isError && <div className="form-alert" role="alert">{error instanceof Error ? error.message : 'Could not load your account.'}</div>}
|
|
262
|
+
{me && current && <div key={current.key} className="acct-section">{current.render({ me, close: onClose })}</div>}
|
|
263
|
+
{signOutButton('acct-signout-sheet')}
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Open the AccountModal on the toolkit modal stack. Resolves when it closes.
|
|
272
|
+
* Pass any AccountModalProps except `onClose`.
|
|
273
|
+
*/
|
|
274
|
+
export function openAccountModal(props: Omit<AccountModalProps, 'onClose'> = {}): Promise<unknown> {
|
|
275
|
+
return modal.open((close) => <AccountModal {...props} onClose={() => close(null)} />, { size: 'lg' });
|
|
276
|
+
}
|