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.
Files changed (168) hide show
  1. package/README.md +59 -11
  2. package/docs/DetailView.md +3 -2
  3. package/docs/README.md +7 -2
  4. package/docs/account.md +261 -0
  5. package/docs/admin-bouncer.md +3 -3
  6. package/docs/admin-credentials.md +4 -3
  7. package/docs/admin-devices-geoip.md +1 -1
  8. package/docs/admin-dns-certificates.md +2 -2
  9. package/docs/admin-dns-records.md +1 -1
  10. package/docs/admin-edge.md +210 -0
  11. package/docs/admin-identity-directories-sources.json +2 -2
  12. package/docs/admin-identity-directories.md +9 -0
  13. package/docs/admin-identity-users.md +2 -2
  14. package/docs/admin-jobs.md +4 -4
  15. package/docs/admin-media-rendering.md +142 -0
  16. package/docs/admin-modals.md +7 -0
  17. package/docs/admin-monitoring.md +4 -4
  18. package/docs/admin-network-security.md +2 -2
  19. package/docs/admin-phonehub.md +4 -1
  20. package/docs/admin-storage.md +5 -2
  21. package/docs/auth-pages.md +8 -3
  22. package/docs/auth.md +45 -2
  23. package/docs/calendar.md +1 -1
  24. package/docs/client.md +159 -26
  25. package/docs/collection-multiselect.md +2 -2
  26. package/docs/combobox.md +2 -2
  27. package/docs/dataview.md +1 -1
  28. package/docs/datepicker.md +3 -3
  29. package/docs/daterange.md +1 -1
  30. package/docs/datetimepicker.md +1 -1
  31. package/docs/defineModel.md +40 -7
  32. package/docs/detail-primitives.md +1 -1
  33. package/docs/feedback.md +18 -8
  34. package/docs/file-upload-ux.md +2 -2
  35. package/docs/idioms.md +13 -1
  36. package/docs/loading.md +2 -2
  37. package/docs/location-address.md +2 -1
  38. package/docs/markdown.md +3 -3
  39. package/docs/menus-and-access.md +21 -1
  40. package/docs/multiselect-dropdown.md +1 -1
  41. package/docs/popover.md +1 -1
  42. package/docs/record-feed.md +2 -2
  43. package/docs/taginput.md +2 -1
  44. package/docs/timepicker.md +1 -1
  45. package/docs/timezone-select.md +4 -3
  46. package/docs/user-menu.md +4 -3
  47. package/docs/worldmap.md +2 -2
  48. package/package.json +4 -2
  49. package/src/account/AccountModal.tsx +276 -0
  50. package/src/account/AccountSections.tsx +370 -0
  51. package/src/account/NotificationPreferences.tsx +152 -0
  52. package/src/account/PasskeyList.tsx +144 -0
  53. package/src/account/api.ts +236 -0
  54. package/src/account/credential-models.ts +153 -0
  55. package/src/account/dialogs.tsx +822 -0
  56. package/src/account/index.ts +16 -0
  57. package/src/account/models.ts +76 -0
  58. package/src/account/sections/ApiKeysSection.tsx +138 -0
  59. package/src/admin/core/index.ts +1 -1
  60. package/src/admin/credentials/secret-dialog.tsx +3 -77
  61. package/src/admin/dns/models.ts +1 -1
  62. package/src/admin/domains/infrastructure.ts +12 -2
  63. package/src/admin/domains/security.ts +3 -1
  64. package/src/admin/edge/BlocklistPage.tsx +110 -0
  65. package/src/admin/edge/UpstreamsPage.tsx +113 -0
  66. package/src/admin/edge/VhostDetail.tsx +166 -0
  67. package/src/admin/edge/VhostWizard.tsx +273 -0
  68. package/src/admin/edge/VhostsPage.tsx +47 -0
  69. package/src/admin/edge/api.ts +56 -0
  70. package/src/admin/edge/index.ts +15 -0
  71. package/src/admin/edge/models.ts +395 -0
  72. package/src/admin/edge/permissions.ts +13 -0
  73. package/src/admin/identity/users/PersonalApiKeysPage.tsx +10 -0
  74. package/src/admin/identity/users/models.ts +16 -128
  75. package/src/admin/identity/users/sections/ApiKeysSection.tsx +3 -119
  76. package/src/admin/identity/users/sections/NotificationsSection.tsx +23 -102
  77. package/src/admin/identity/users/sections/PersonalSection.tsx +18 -5
  78. package/src/admin/identity/users/sections/actions.tsx +10 -103
  79. package/src/admin/index.ts +1 -0
  80. package/src/admin/phonehub/api.ts +5 -3
  81. package/src/admin/public/infrastructure.ts +1 -0
  82. package/src/admin/registry.ts +2 -1
  83. package/src/admin/storage/MediaRenderingPage.tsx +258 -0
  84. package/src/admin/storage/file-renditions.ts +12 -0
  85. package/src/admin/storage/index.ts +2 -0
  86. package/src/admin/storage/rendition-settings.ts +264 -0
  87. package/src/charts/exportChart.ts +1 -1
  88. package/src/client/action-result.ts +23 -61
  89. package/src/client/active-group.ts +9 -3
  90. package/src/client/auth.ts +207 -6
  91. package/src/client/client.ts +49 -10
  92. package/src/client/errors.ts +57 -2
  93. package/src/client/group-context.ts +2 -0
  94. package/src/client/group.tsx +112 -20
  95. package/src/client/hooks.ts +7 -3
  96. package/src/client/mock.ts +1429 -20
  97. package/src/client/model.ts +15 -3
  98. package/src/client/renditions-mock.ts +212 -0
  99. package/src/client/scoped.ts +19 -5
  100. package/src/styles/components/account.css +304 -0
  101. package/src/styles/components/admin-assistant.css +28 -0
  102. package/src/styles/components/admin-bouncer.css +53 -0
  103. package/src/styles/components/admin-cloudwatch.css +23 -0
  104. package/src/styles/components/admin-dashboard.css +16 -0
  105. package/src/styles/components/admin-devices.css +260 -0
  106. package/src/styles/components/admin-dns-certificates.css +28 -0
  107. package/src/styles/components/admin-dns-registrar.css +1 -0
  108. package/src/styles/components/admin-dns.css +43 -0
  109. package/src/styles/components/admin-edge.css +1 -0
  110. package/src/styles/components/admin-incidents.css +14 -0
  111. package/src/styles/components/admin-jobs.css +237 -0
  112. package/src/styles/components/admin-media-rendering.css +38 -0
  113. package/src/styles/components/admin-messaging.css +3 -0
  114. package/src/styles/components/admin-monitoring.css +152 -0
  115. package/src/styles/components/admin-network.css +406 -0
  116. package/src/styles/components/admin-phonehub.css +61 -0
  117. package/src/styles/components/admin-push.css +30 -0
  118. package/src/styles/components/admin-rules.css +23 -0
  119. package/src/styles/components/admin-security.css +37 -0
  120. package/src/styles/components/admin-shortlinks.css +8 -0
  121. package/src/styles/components/admin-signin.css +64 -0
  122. package/src/styles/components/admin-storage.css +4 -0
  123. package/src/styles/components/auth.css +162 -0
  124. package/src/styles/components/calendar.css +228 -0
  125. package/src/styles/components/charts-c2.css +249 -0
  126. package/src/styles/components/collection-multiselect.css +114 -0
  127. package/src/styles/components/collection-select.css +63 -0
  128. package/src/styles/components/combobox.css +42 -0
  129. package/src/styles/components/dataview.css +139 -0
  130. package/src/styles/components/datepicker.css +106 -0
  131. package/src/styles/components/daterange.css +119 -0
  132. package/src/styles/components/datetimepicker.css +230 -0
  133. package/src/styles/components/detail-primitives.css +246 -0
  134. package/src/styles/components/detailview.css +69 -0
  135. package/src/styles/components/drawer.css +111 -0
  136. package/src/styles/components/file-upload.css +112 -0
  137. package/src/styles/components/form-wizard.css +11 -0
  138. package/src/styles/components/formview.css +74 -0
  139. package/src/styles/components/group-admin.css +302 -0
  140. package/src/styles/components/guardrail.css +29 -0
  141. package/src/styles/components/idioms.css +84 -0
  142. package/src/styles/components/image-editor.css +80 -0
  143. package/src/styles/components/loading.css +200 -0
  144. package/src/styles/components/location-address.css +32 -0
  145. package/src/styles/components/markdown.css +192 -0
  146. package/src/styles/components/multiselect-dropdown.css +92 -0
  147. package/src/styles/components/popover.css +35 -0
  148. package/src/styles/components/record-feed.css +161 -0
  149. package/src/styles/components/right-panel.css +37 -0
  150. package/src/styles/components/tabs.css +26 -0
  151. package/src/styles/components/taginput.css +132 -0
  152. package/src/styles/components/timepicker.css +282 -0
  153. package/src/styles/components/user-admin.css +189 -0
  154. package/src/styles/components/user-menu.css +98 -0
  155. package/src/styles/components/worldmap.css +97 -0
  156. package/src/styles/core.css +807 -0
  157. package/src/styles/index.css +69 -0
  158. package/src/styles/required-tokens.json +32 -0
  159. package/src/ui/FileField.tsx +12 -7
  160. package/src/ui/FormWizard.tsx +9 -3
  161. package/src/ui/ThemeProvider.tsx +1 -1
  162. package/src/ui/UserMenu.tsx +6 -2
  163. package/src/ui/detail/index.ts +2 -2
  164. package/src/ui/index.ts +4 -2
  165. package/src/ui/loading.tsx +1 -1
  166. package/src/ui/modal.tsx +76 -0
  167. package/src/ui/secret-dialog.tsx +83 -0
  168. 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 consuming app (`apps/portal/src/theme/taginput.css`,
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` /
@@ -150,7 +150,7 @@ zone (`Intl.DateTimeFormat().resolvedOptions().timeZone`, `'UTC'` on failure)
150
150
 
151
151
  ## Styling
152
152
 
153
- `apps/portal/src/theme/timepicker.css` — `mojo-time-*` anatomy only
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
@@ -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 ships: the wrapper `.tz-select` is an unstyled hook and every
144
- pixel comes from `apps/portal/src/theme/combobox.css`, which consuming apps
145
- must include.
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 — `apps/portal/src/theme/user-menu.css`; both themes from the
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 ship `theme/user-menu.css` (the package ships no CSS).
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 two theme copies
302
- staying byte-identical).
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.7",
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
+ }