@rapidmx/web-client 0.8.0 → 0.10.0

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 (247) hide show
  1. package/README.md +192 -88
  2. package/apps/shared/auth/accountUrl.ts +9 -0
  3. package/apps/shared/auth/adminAccess.ts +99 -0
  4. package/apps/shared/components/admin/elevation.ts +61 -0
  5. package/apps/shared/components/admin/layout/AdminShell.tsx +73 -7
  6. package/apps/shared/components/admin/settings/BrandingForm.tsx +2 -1
  7. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +87 -24
  8. package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
  9. package/apps/shared/components/contacts/ContactsToolbar.tsx +125 -115
  10. package/apps/shared/components/contacts/layout/ContactsShell.tsx +15 -3
  11. package/apps/shared/components/layout/AppShell.tsx +411 -288
  12. package/apps/shared/components/layout/BrandingChrome.tsx +3 -2
  13. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
  14. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -126
  15. package/apps/shared/components/layout/UserMenu.tsx +303 -150
  16. package/apps/shared/components/mail/ConversationList.tsx +269 -253
  17. package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
  18. package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
  19. package/apps/shared/components/mail/MailAddress.tsx +88 -0
  20. package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
  21. package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1383
  22. package/apps/shared/components/mail/NewMailToasts.tsx +141 -0
  23. package/apps/shared/components/mail/compose/ComposeContext.tsx +279 -160
  24. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
  25. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1842 -1700
  26. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
  27. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
  28. package/apps/shared/components/mail/compose/SendFailureAlert.tsx +48 -0
  29. package/apps/shared/components/mail/compose/composePerf.ts +46 -0
  30. package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
  31. package/apps/shared/components/mail/layout/MailShell.tsx +476 -445
  32. package/apps/shared/components/mail/unreadStyle.tsx +75 -0
  33. package/apps/shared/components/settings/layout/SettingsShell.tsx +252 -240
  34. package/apps/shared/components/tasks/layout/TasksShell.tsx +15 -3
  35. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
  36. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
  37. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
  38. package/apps/shared/keyboard/dispatch.ts +124 -0
  39. package/apps/shared/keyboard/format.ts +89 -0
  40. package/apps/shared/keyboard/keymap.ts +114 -0
  41. package/apps/shared/keyboard/match.ts +44 -0
  42. package/apps/shared/keyboard/parse.ts +136 -0
  43. package/apps/shared/keyboard/platform.ts +34 -0
  44. package/apps/shared/keyboard/registry.ts +65 -0
  45. package/apps/shared/keyboard/targets.ts +79 -0
  46. package/apps/shared/keyboard/useShortcut.ts +50 -0
  47. package/apps/shared/keyboard/useShortcutProps.ts +17 -0
  48. package/apps/shared/mail/folderCounts.ts +302 -0
  49. package/apps/shared/mail/listSnapshots.ts +87 -0
  50. package/apps/shared/mail/mergeFirstPage.ts +47 -0
  51. package/apps/shared/mail/messageReadState.ts +85 -0
  52. package/apps/shared/mail/newMailNotifications.ts +183 -0
  53. package/apps/shared/mail/useMailConnection.ts +150 -0
  54. package/apps/shared/mail/useMailLiveUpdates.ts +232 -0
  55. package/apps/shared/mail/useMarkMessageRead.ts +47 -0
  56. package/apps/shared/mail/useNewMailNotifications.ts +163 -0
  57. package/apps/shared/mail/useUnreadTitle.ts +42 -0
  58. package/apps/shared/navigation/AppRouter.tsx +300 -0
  59. package/apps/shared/navigation/appHrefs.ts +23 -0
  60. package/apps/shared/navigation/frameContext.tsx +35 -0
  61. package/apps/shared/navigation/idle.ts +45 -0
  62. package/apps/shared/navigation/routerContext.tsx +83 -0
  63. package/apps/shared/navigation/routes.ts +72 -0
  64. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
  65. package/apps/shared/styles/app.css +28 -10
  66. package/apps/www/_routedPage.tsx +24 -0
  67. package/apps/www/_routes.ts +35 -0
  68. package/apps/www/calendar/index.tsx +55 -19
  69. package/apps/www/contacts/[uid].tsx +112 -107
  70. package/apps/www/contacts/index.tsx +584 -567
  71. package/apps/www/index.tsx +2280 -1854
  72. package/apps/www/messages/[uid].tsx +106 -101
  73. package/apps/www/settings/auto-reply/index.tsx +4 -1
  74. package/apps/www/settings/encryption/index.tsx +1252 -1249
  75. package/apps/www/settings/filters/[uid].tsx +4 -1
  76. package/apps/www/settings/filters/index.tsx +102 -99
  77. package/apps/www/settings/filters/new/index.tsx +138 -133
  78. package/apps/www/settings/labels/index.tsx +204 -201
  79. package/apps/www/settings/privacy/index.tsx +4 -1
  80. package/apps/www/settings/read-receipts/index.tsx +4 -1
  81. package/apps/www/settings/sharing/index.tsx +277 -274
  82. package/apps/www/settings/signatures/[uid].tsx +170 -167
  83. package/apps/www/settings/signatures/index.tsx +88 -85
  84. package/apps/www/settings/signatures/new/index.tsx +134 -129
  85. package/apps/www/tasks/index.tsx +18 -2
  86. package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
  87. package/dist/apps/shared/auth/accountUrl.js +8 -0
  88. package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
  89. package/dist/apps/shared/auth/adminAccess.js +89 -0
  90. package/dist/apps/shared/components/admin/elevation.d.ts +24 -0
  91. package/dist/apps/shared/components/admin/elevation.js +56 -0
  92. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +12 -4
  93. package/dist/apps/shared/components/admin/layout/AdminShell.js +49 -6
  94. package/dist/apps/shared/components/admin/settings/BrandingForm.js +1 -1
  95. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +31 -13
  96. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
  97. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
  98. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  99. package/dist/apps/shared/components/contacts/ContactsToolbar.js +7 -4
  100. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
  101. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +15 -5
  102. package/dist/apps/shared/components/layout/AppShell.d.ts +29 -4
  103. package/dist/apps/shared/components/layout/AppShell.js +73 -14
  104. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +3 -2
  105. package/dist/apps/shared/components/layout/BrandingChrome.js +3 -2
  106. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
  107. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +5 -2
  108. package/dist/apps/shared/components/layout/MailboxProvisioning.js +45 -8
  109. package/dist/apps/shared/components/layout/UserMenu.d.ts +28 -9
  110. package/dist/apps/shared/components/layout/UserMenu.js +87 -14
  111. package/dist/apps/shared/components/mail/ConversationList.js +9 -13
  112. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  113. package/dist/apps/shared/components/mail/ConversationThreadPane.js +53 -28
  114. package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
  115. package/dist/apps/shared/components/mail/LazyReadingPane.js +82 -0
  116. package/dist/apps/shared/components/mail/MailAddress.d.ts +27 -0
  117. package/dist/apps/shared/components/mail/MailAddress.js +39 -0
  118. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +4 -1
  119. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -3
  120. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +7 -1
  121. package/dist/apps/shared/components/mail/MessageDetailPane.js +87 -36
  122. package/dist/apps/shared/components/mail/NewMailToasts.d.ts +19 -0
  123. package/dist/apps/shared/components/mail/NewMailToasts.js +55 -0
  124. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +30 -0
  125. package/dist/apps/shared/components/mail/compose/ComposeContext.js +71 -5
  126. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
  127. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +1 -1
  128. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +122 -27
  129. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
  130. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
  131. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
  132. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
  133. package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +14 -0
  134. package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +11 -0
  135. package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
  136. package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
  137. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
  138. package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
  139. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +19 -4
  140. package/dist/apps/shared/components/mail/layout/MailShell.js +85 -85
  141. package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
  142. package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
  143. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
  144. package/dist/apps/shared/components/settings/layout/SettingsShell.js +15 -5
  145. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
  146. package/dist/apps/shared/components/tasks/layout/TasksShell.js +15 -5
  147. package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
  148. package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
  149. package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
  150. package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
  151. package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
  152. package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
  153. package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
  154. package/dist/apps/shared/keyboard/dispatch.js +109 -0
  155. package/dist/apps/shared/keyboard/format.d.ts +12 -0
  156. package/dist/apps/shared/keyboard/format.js +74 -0
  157. package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
  158. package/dist/apps/shared/keyboard/keymap.js +84 -0
  159. package/dist/apps/shared/keyboard/match.d.ts +20 -0
  160. package/dist/apps/shared/keyboard/match.js +31 -0
  161. package/dist/apps/shared/keyboard/parse.d.ts +31 -0
  162. package/dist/apps/shared/keyboard/parse.js +107 -0
  163. package/dist/apps/shared/keyboard/platform.d.ts +15 -0
  164. package/dist/apps/shared/keyboard/platform.js +22 -0
  165. package/dist/apps/shared/keyboard/registry.d.ts +39 -0
  166. package/dist/apps/shared/keyboard/registry.js +33 -0
  167. package/dist/apps/shared/keyboard/targets.d.ts +14 -0
  168. package/dist/apps/shared/keyboard/targets.js +67 -0
  169. package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
  170. package/dist/apps/shared/keyboard/useShortcut.js +35 -0
  171. package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
  172. package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
  173. package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
  174. package/dist/apps/shared/mail/folderCounts.js +212 -0
  175. package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
  176. package/dist/apps/shared/mail/listSnapshots.js +43 -0
  177. package/dist/apps/shared/mail/mergeFirstPage.d.ts +23 -0
  178. package/dist/apps/shared/mail/mergeFirstPage.js +30 -0
  179. package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
  180. package/dist/apps/shared/mail/messageReadState.js +63 -0
  181. package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
  182. package/dist/apps/shared/mail/newMailNotifications.js +138 -0
  183. package/dist/apps/shared/mail/useMailConnection.d.ts +55 -0
  184. package/dist/apps/shared/mail/useMailConnection.js +96 -0
  185. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +55 -0
  186. package/dist/apps/shared/mail/useMailLiveUpdates.js +182 -0
  187. package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
  188. package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
  189. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +41 -0
  190. package/dist/apps/shared/mail/useNewMailNotifications.js +110 -0
  191. package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
  192. package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
  193. package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
  194. package/dist/apps/shared/navigation/AppRouter.js +242 -0
  195. package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
  196. package/dist/apps/shared/navigation/appHrefs.js +20 -0
  197. package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
  198. package/dist/apps/shared/navigation/frameContext.js +24 -0
  199. package/dist/apps/shared/navigation/idle.d.ts +14 -0
  200. package/dist/apps/shared/navigation/idle.js +43 -0
  201. package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
  202. package/dist/apps/shared/navigation/routerContext.js +56 -0
  203. package/dist/apps/shared/navigation/routes.d.ts +32 -0
  204. package/dist/apps/shared/navigation/routes.js +37 -0
  205. package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
  206. package/dist/apps/shared/styles/app.css +28 -10
  207. package/dist/apps/www/_routedPage.d.ts +12 -0
  208. package/dist/apps/www/_routedPage.js +19 -0
  209. package/dist/apps/www/_routes.d.ts +11 -0
  210. package/dist/apps/www/_routes.js +29 -0
  211. package/dist/apps/www/calendar/index.d.ts +2 -2
  212. package/dist/apps/www/calendar/index.js +39 -8
  213. package/dist/apps/www/contacts/[uid].d.ts +3 -9
  214. package/dist/apps/www/contacts/[uid].js +6 -2
  215. package/dist/apps/www/contacts/index.d.ts +2 -2
  216. package/dist/apps/www/contacts/index.js +17 -4
  217. package/dist/apps/www/index.d.ts +2 -2
  218. package/dist/apps/www/index.js +361 -34
  219. package/dist/apps/www/messages/[uid].d.ts +3 -7
  220. package/dist/apps/www/messages/[uid].js +7 -4
  221. package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
  222. package/dist/apps/www/settings/auto-reply/index.js +3 -1
  223. package/dist/apps/www/settings/encryption/index.d.ts +2 -2
  224. package/dist/apps/www/settings/encryption/index.js +3 -1
  225. package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
  226. package/dist/apps/www/settings/filters/[uid].js +3 -1
  227. package/dist/apps/www/settings/filters/index.d.ts +2 -2
  228. package/dist/apps/www/settings/filters/index.js +3 -1
  229. package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
  230. package/dist/apps/www/settings/filters/new/index.js +6 -2
  231. package/dist/apps/www/settings/labels/index.d.ts +2 -2
  232. package/dist/apps/www/settings/labels/index.js +3 -1
  233. package/dist/apps/www/settings/privacy/index.d.ts +2 -2
  234. package/dist/apps/www/settings/privacy/index.js +3 -1
  235. package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
  236. package/dist/apps/www/settings/read-receipts/index.js +3 -1
  237. package/dist/apps/www/settings/sharing/index.d.ts +2 -2
  238. package/dist/apps/www/settings/sharing/index.js +3 -1
  239. package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
  240. package/dist/apps/www/settings/signatures/[uid].js +3 -1
  241. package/dist/apps/www/settings/signatures/index.d.ts +2 -2
  242. package/dist/apps/www/settings/signatures/index.js +3 -1
  243. package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
  244. package/dist/apps/www/settings/signatures/new/index.js +6 -2
  245. package/dist/apps/www/tasks/index.d.ts +2 -2
  246. package/dist/apps/www/tasks/index.js +15 -3
  247. package/package.json +2 -2
package/README.md CHANGED
@@ -1,88 +1,192 @@
1
- # RapidMX: Web Client
2
-
3
- [![npm version](https://img.shields.io/npm/v/@rapidmx/web-client)](https://www.npmjs.com/package/@rapidmx/web-client)
4
-
5
- RapidMX's webmail (`apps/www`), admin console (`apps/admin`) and escrow console (`apps/escrow`) React UI. The pages are
6
- served and hydrated by [`rapidmx/server`](https://github.com/RapidMX/server) through `@rapidrest/react`'s file-convention
7
- routes, and `@rapidmx/electron-client` reuses the same components. Platform-agnostic API clients, hooks and generic UI
8
- primitives live in [`@rapidmx/react-shared`](https://github.com/RapidMX/react-shared).
9
-
10
- ## Package layout
11
-
12
- The package ships the TSX sources (`apps/`) and a compiled mirror (`dist/apps/`, JavaScript plus `.d.ts` declarations).
13
- There is no root export. Every module is its own subpath, mapped by `package.json`'s `exports` from
14
- `@rapidmx/web-client/<path>.js` to `dist/apps/<path>.js`:
15
-
16
- ```ts
17
- import SettingsShell from "@rapidmx/web-client/shared/components/settings/layout/SettingsShell.js";
18
- ```
19
-
20
- `@rapidmx/web-client/shared/styles/app.css` is the Tailwind entry point and design tokens.
21
-
22
- ## Plugin UI surface
23
-
24
- Server plugins can ship their own pages (see the plugin manifest's `ui` field in `@rapidmx/restapi`). The server builds
25
- them together with this package, so they share one React, one `@rapidmx/react-shared` state and one stylesheet. The
26
- modules below are the **supported surface for plugin pages**. Anything else under `apps/` is internal and may change in
27
- any release.
28
-
29
- ### Shells
30
-
31
- | Import | Use |
32
- | --- | --- |
33
- | `shared/components/layout/AppShell.js` | Chrome for webmail apps: app rail, header, user menu, impersonation banner, compose and unlock providers. `active` is a core app or the plugin's `appRail` item id. |
34
- | `shared/components/settings/layout/SettingsShell.js` | Settings chrome with the section list and mailbox switcher. `active` is the plugin's `settingsSections` item id. `useSettingsShell()` gives the selected `mailboxUid` and the accessible `mailboxes`. |
35
- | `shared/components/admin/layout/AdminShell.js` | Admin console chrome, gated on administrator access. `active` is the plugin's `adminNav` item id. |
36
- | `shared/components/layout/BrandingChrome.js` | `BrandingHeader` and `BrandingFooter`, for pages that don't use a shell, such as public pages. |
37
- | `shared/plugins/pluginNav.js` | The `PluginNav`, `PluginUiNavItem` and `PluginNavProps` types. |
38
-
39
- Every www and admin page receives a `pluginNav` prop from the server. It lists the settings sections, admin sections and
40
- app rail entries of every enabled plugin whose UI built. The shells append those entries after their own, with a generic
41
- icon. An entry whose id matches a core entry is skipped, and so is one whose `href` isn't a same-origin path. Pass the
42
- page props straight to the shell so the navigation shows:
43
-
44
- ```tsx
45
- import React from "react";
46
- import SettingsShell, {
47
- SettingsShellProps,
48
- useSettingsShell,
49
- } from "@rapidmx/web-client/shared/components/settings/layout/SettingsShell.js";
50
-
51
- export default function RemindersSettingsPage(props: Omit<SettingsShellProps, "active">) {
52
- return (
53
- <SettingsShell {...props} active="reminders">
54
- <RemindersSettings />
55
- </SettingsShell>
56
- );
57
- }
58
-
59
- function RemindersSettings() {
60
- const { mailboxUid } = useSettingsShell();
61
- return <p>Settings for {mailboxUid}</p>;
62
- }
63
- ```
64
-
65
- Public and escrow pages get no `pluginNav`.
66
-
67
- ### From `@rapidmx/react-shared`
68
-
69
- Plugin pages import these directly from `@rapidmx/react-shared`, which the server resolves to the same copy the shells
70
- use:
71
-
72
- - `branding/useBranding.js`: `useBranding()`, for the branding and icon of pages outside a shell;
73
- - `auth/session.js`: `useRedirectIfUnauthenticated()`, already called by every shell;
74
- - `util/api.js`: `apiFetch()` and `ApiRequestError`, for calling the plugin's own API routes;
75
- - `mail/mailApi.js`: mailboxes and folders, such as `listMailboxes()` and `listFolders()`;
76
- - `components/buttons/Button.js`, `components/feedback/Alert.js` and `components/feedback/Skeleton.js`;
77
- - `components/forms/FormField.js`;
78
- - `components/overlays/Modal.js` and `components/overlays/Drawer.js`;
79
- - `components/pickers/MiniDatePicker.js`.
80
-
81
- ## Development
82
-
83
- ```sh
84
- yarn install
85
- yarn test # vitest with coverage gates
86
- yarn lint
87
- yarn build # tsc into dist/apps
88
- ```
1
+ # RapidMX: Web Client
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@rapidmx/web-client)](https://www.npmjs.com/package/@rapidmx/web-client)
4
+
5
+ RapidMX's webmail (`apps/www`), admin console (`apps/admin`) and escrow console (`apps/escrow`) React UI. The pages are
6
+ served and hydrated by [`rapidmx/server`](https://github.com/RapidMX/server) through `@rapidrest/react`'s file-convention
7
+ routes, and `@rapidmx/electron-client` reuses the same components. Platform-agnostic API clients, hooks and generic UI
8
+ primitives live in [`@rapidmx/react-shared`](https://github.com/RapidMX/react-shared).
9
+
10
+ ## Package layout
11
+
12
+ The package ships the TSX sources (`apps/`) and a compiled mirror (`dist/apps/`, JavaScript plus `.d.ts` declarations).
13
+ There is no root export. Every module is its own subpath, mapped by `package.json`'s `exports` from
14
+ `@rapidmx/web-client/<path>.js` to `dist/apps/<path>.js`:
15
+
16
+ ```ts
17
+ import SettingsShell from "@rapidmx/web-client/shared/components/settings/layout/SettingsShell.js";
18
+ ```
19
+
20
+ `@rapidmx/web-client/shared/styles/app.css` is the Tailwind entry point and design tokens.
21
+
22
+ ## Navigation without page loads
23
+
24
+ `@rapidrest/react` has no router: every page is a server-rendered document that hydrates only its own page component. The
25
+ `www` pages (Mail, Calendar, Contacts, Tasks, Settings and their subpages) therefore render a small client-side router
26
+ themselves, and go between one another - and between the folders of Mail - without a page load:
27
+
28
+ - each `apps/www` page's default export is `routedPage("/its/route", Page)` (`apps/www/_routedPage.tsx`). The server
29
+ renders and the browser hydrates exactly what it did before; the first load is unchanged. `apps/www/_routes.ts` lists the
30
+ pages, each with a dynamic `import()` so it is a chunk of its own (a test keeps it in step with the files);
31
+ - one `AppShell` chrome (the app rail, header, user menu, impersonation banner, compose windows in progress, the unlock
32
+ prompt and the idle-key timer) stays mounted, and only the page inside it is replaced. A page's own shell
33
+ (`MailShell`, `CalendarShell`, ...) still renders `AppShell`; inside the router that is only its children;
34
+ - **links stay ordinary links.** Every same-origin `<a href>` to a route in the table is taken over - plain left clicks
35
+ only, so ctrl/cmd/shift/middle click, `target`, `download`, `#hash` links and links marked `data-full-reload` keep
36
+ the browser's behaviour, and pages that are not in the table (the admin and escrow consoles, plugin pages, other sites) are
37
+ reached with a page load. The address bar always holds the real, shareable URL (`/?mailboxUid=&folderUid=`), and back and
38
+ forward work;
39
+ - a page's code is fetched when the pointer, focus or a press reaches a link to it, and for the app rail's pages when the
40
+ browser is idle after load (not with data saving on); if it can't be loaded the router falls back to a page load;
41
+ - after a page change focus moves to the content region (`#app-content`), the window scrolls to the top, `document.title`
42
+ follows the page and a polite live region announces it.
43
+
44
+ Code that decides where to go uses the two hooks in `shared/navigation/AppRouter.js`:
45
+
46
+ ```tsx
47
+ import { useLocation, useNavigate } from "@rapidmx/web-client/shared/navigation/AppRouter.js";
48
+
49
+ const navigate = useNavigate(); // navigate("/contacts"), navigate("/?mailboxUid=a&folderUid=b", { replace: true })
50
+ const { pathname, search, hash } = useLocation(); // empty until read after the first render, so server and browser agree
51
+ ```
52
+
53
+ `navigate()` changes the page without a load when the URL is a route of the app and is an ordinary navigation otherwise
54
+ (outside the router too, where it is `window.location.href = ...`). Page props are the same for every `www` page except
55
+ `params`, which the router recomputes from the URL. Plugin pages are not part of the router (they render their own
56
+ `AppShell` chrome) and are opened with a page load.
57
+
58
+ The compose window, the reading pane, S/MIME and the emoji list are also chunks of their own, loaded on demand or fetched
59
+ when the browser is idle, so a page's first JavaScript is React and what the first screen draws.
60
+
61
+ ## Keyboard shortcuts
62
+
63
+ One keyboard layer, `shared/keyboard/`, lives in the persistent app frame (`AppChrome`), so the shortcuts work in every view -
64
+ Mail, Calendar, Contacts, Tasks and Settings - and a page change only changes which of them are registered. `?` (or `Ctrl+/`)
65
+ opens a "Keyboard shortcuts" dialog, also reached from the account menu, that lists what is available *here* - the global
66
+ shortcuts plus those of the view on screen (and of an open compose window) - with the platform's own key names. `mod` below is
67
+ **Ctrl** on Windows and Linux and **Cmd** on a Mac; the navigation set is Ctrl+Shift on every platform (Cmd+Shift collides with the
68
+ browsers' own, so it is not offered).
69
+
70
+ | Where | Key | Does |
71
+ | --- | --- | --- |
72
+ | Everywhere | `Ctrl+Shift+A` | Account (auth-server's account page; only offered when one is configured) |
73
+ | | `Ctrl+Shift+S` / `B` / `M` / `C` / `L` | Settings / Contacts / Mail / Calendar / To-Do |
74
+ | | `?` or `Ctrl+/` | Keyboard shortcuts |
75
+ | Mail | `Alt+N` | New message |
76
+ | | `mod+R` / `mod+Shift+R` / `mod+Shift+F` | Reply / Reply all / Forward the selected message |
77
+ | | `mod+D` or `Delete` | Delete the selected message or conversation (moves it to Deleted Items) |
78
+ | | `E` or `Backspace` | Archive |
79
+ | | `mod+Shift+V` | Move to folder |
80
+ | | `Ctrl+Q` / `mod+U` | Mark as read / unread (`Ctrl+Q` on a Mac too: `Cmd+Q` quits) |
81
+ | | `Insert` | Flag or unflag |
82
+ | | `Down` or `J` / `Up` or `K` | Next / previous message (conversation) |
83
+ | | `Ctrl+.` / `Ctrl+,` | Next / previous unread |
84
+ | | `Enter` / `Escape` | Open the selected message on its own page / clear the selection (leave select mode, clear the search) |
85
+ | | `/` or `mod+E` | Search |
86
+ | Compose window | `mod+Enter` | Send |
87
+ | | `mod+S` | Save draft |
88
+ | | `Escape` | Close, keeping the draft (the existing "keep draft / discard" question still applies) |
89
+ | | `Alt+N` | Another new message |
90
+ | Calendar | `Alt+N` | New event |
91
+ | | `T` | Today |
92
+ | | `Left` / `Right` (or `mod+Left` / `mod+Right`) | Previous / next period |
93
+ | | `Ctrl+Alt+1` / `2` / `3` / `4` | Day / work week / week / month |
94
+ | Contacts | `Alt+N` | New contact |
95
+ | | `/` or `mod+E` | Search |
96
+ | Tasks | `Alt+N` | New task (moves to the "Add a task" field) |
97
+
98
+ In the desktop client (`@rapidmx/electron-client`, which exposes `window.rapidmx`) `Ctrl+N` (`Cmd+N` on a Mac) also creates - a new
99
+ message, event, contact or task in the current view - and `Ctrl+Shift+T` also goes to Tasks; browsers keep both for themselves, which is
100
+ why the web client uses `Alt+N` and `Ctrl+Shift+L`.
101
+
102
+ How it behaves:
103
+
104
+ - **Views register only what they can do.** A view claims a shortcut with `useShortcut(SHORTCUTS.mail.reply, handler, { enabled })`
105
+ for as long as it is mounted and able to do it (Reply exists only while a message is selected, Archive not for Drafts or Outbox),
106
+ so there are no dead keys and the help dialog is always accurate. `SHORTCUTS` in `shared/keyboard/keymap.js` is the one key map;
107
+ handlers are looked up when the key is pressed. A handler that returns `false` declines the key.
108
+ - **Scopes.** `global`, the view's own (`mail`, `calendar`, `contacts`, `tasks`), `compose` (while focus is inside a compose window; it
109
+ beats the view behind it) and `dialog`: while a modal dialog (`aria-modal`) is open only its own shortcuts and its own Escape work.
110
+ - **Typing is never taken.** Bare keys (`J`, `E`, `?`, `Delete`) do nothing while focus is in a text field, select or the rich-text
111
+ editor, and caret keys with a modifier (word jumps) stay the field's. Chords with Ctrl/Alt/Cmd work from a field, except Option on
112
+ a Mac and Ctrl+Alt (AltGr) elsewhere, which type characters. Copy, Cut, Paste, Select all, Undo, Redo and Find are never
113
+ bound. Enter and Space are left to a focused button or link, and an open menu keeps its own keys. Events already
114
+ `defaultPrevented` (the editor's own bindings, a menu), IME composition and held-key repeats of one-shot actions are ignored, and
115
+ the browser's default is prevented only when a handler actually ran.
116
+ - **Layouts.** A key is matched on `event.key` (so it follows AZERTY or Dvorak) with `event.code` as the fallback when `event.key` is
117
+ not a Latin character - macOS Option+N, or a Cyrillic layout.
118
+ - **Hints.** Buttons that a shortcut also does carry `aria-keyshortcuts` and a tooltip such as "Reply (Ctrl+R)"; their accessible
119
+ names are unchanged (`useShortcutProps()` gives a control both).
120
+
121
+ Known limits: a browser keeps a few keys for itself (`Ctrl+N`, `Ctrl+T`, `Ctrl+W` are never delivered to a page, and some browsers claim
122
+ `Ctrl+Shift+A/B/C/M/S`); and a message body is shown in a sandboxed iframe, which forwards no key events, so after clicking into a
123
+ message body press Tab or click the list before using a shortcut. In the compose body the editor's own bindings win over `Ctrl+Shift+S`, `B`
124
+ and `L` (strike-through, quote, align left).
125
+
126
+ ## Plugin UI surface
127
+
128
+ Server plugins can ship their own pages (see the plugin manifest's `ui` field in `@rapidmx/restapi`). The server builds
129
+ them together with this package, so they share one React, one `@rapidmx/react-shared` state and one stylesheet. The
130
+ modules below are the **supported surface for plugin pages**. Anything else under `apps/` is internal and may change in
131
+ any release.
132
+
133
+ ### Shells
134
+
135
+ | Import | Use |
136
+ | --- | --- |
137
+ | `shared/components/layout/AppShell.js` | Chrome for webmail apps: app rail, header, user menu, impersonation banner, compose and unlock providers. `active` is a core app or the plugin's `appRail` item id. The user menu shows an "Admin Console" item to an administrator even when their session isn't elevated (it asks auth-server for the user's own roles, using the `trustedRoles` page prop) and, in Mail, the new-mail pop-up switch. |
138
+ | `shared/components/settings/layout/SettingsShell.js` | Settings chrome with the section list and mailbox switcher. `active` is the plugin's `settingsSections` item id. `useSettingsShell()` gives the selected `mailboxUid` and the accessible `mailboxes`. |
139
+ | `shared/components/admin/layout/AdminShell.js` | Admin console chrome, gated on administrator access - an administrator whose session isn't elevated is sent to auth-server's `/auth/elevate` page and returned. `active` is the plugin's `adminNav` item id. |
140
+ | `shared/components/layout/BrandingChrome.js` | `BrandingHeader` and `BrandingFooter`, for pages that don't use a shell, such as public pages. |
141
+ | `shared/plugins/pluginNav.js` | The `PluginNav`, `PluginUiNavItem` and `PluginNavProps` types. |
142
+
143
+ Every www and admin page receives a `pluginNav` prop from the server. It lists the settings sections, admin sections and
144
+ app rail entries of every enabled plugin whose UI built. The shells append those entries after their own, with a generic
145
+ icon. An entry whose id matches a core entry is skipped, and so is one whose `href` isn't a same-origin path. Pass the
146
+ page props straight to the shell so the navigation shows:
147
+
148
+ ```tsx
149
+ import React from "react";
150
+ import SettingsShell, {
151
+ SettingsShellProps,
152
+ useSettingsShell,
153
+ } from "@rapidmx/web-client/shared/components/settings/layout/SettingsShell.js";
154
+
155
+ export default function RemindersSettingsPage(props: Omit<SettingsShellProps, "active">) {
156
+ return (
157
+ <SettingsShell {...props} active="reminders">
158
+ <RemindersSettings />
159
+ </SettingsShell>
160
+ );
161
+ }
162
+
163
+ function RemindersSettings() {
164
+ const { mailboxUid } = useSettingsShell();
165
+ return <p>Settings for {mailboxUid}</p>;
166
+ }
167
+ ```
168
+
169
+ Public and escrow pages get no `pluginNav`.
170
+
171
+ ### From `@rapidmx/react-shared`
172
+
173
+ Plugin pages import these directly from `@rapidmx/react-shared`, which the server resolves to the same copy the shells
174
+ use:
175
+
176
+ - `branding/useBranding.js`: `useBranding()`, for the branding and icon of pages outside a shell;
177
+ - `auth/session.js`: `useRedirectIfUnauthenticated()`, already called by every shell;
178
+ - `util/api.js`: `apiFetch()` and `ApiRequestError`, for calling the plugin's own API routes;
179
+ - `mail/mailApi.js`: mailboxes and folders, such as `listMailboxes()` and `listFolders()`;
180
+ - `components/buttons/Button.js`, `components/feedback/Alert.js` and `components/feedback/Skeleton.js`;
181
+ - `components/forms/FormField.js`;
182
+ - `components/overlays/Modal.js` and `components/overlays/Drawer.js`;
183
+ - `components/pickers/MiniDatePicker.js`.
184
+
185
+ ## Development
186
+
187
+ ```sh
188
+ yarn install
189
+ yarn test # vitest with coverage gates
190
+ yarn lint
191
+ yarn build # tsc into dist/apps
192
+ ```
@@ -0,0 +1,9 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+
6
+ /** auth-server's account page, or `undefined` when no auth-server is configured. Tolerates a configured URL with a trailing slash. */
7
+ export function accountUrlOf(authServerUrl: string | undefined): string | undefined {
8
+ return authServerUrl ? `${authServerUrl.replace(/\/+$/, "")}/account` : undefined;
9
+ }
@@ -0,0 +1,99 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { authApiFetch } from "@rapidmx/react-shared/util/api.js";
6
+
7
+ /**
8
+ * Whether the signed-in user holds an administrator role, learned without elevating.
9
+ *
10
+ * The JWT the browser carries can't say: auth-server strips every trusted role from a token that isn't elevated
11
+ * (`TokenUtils.resolveTokenUser()`), so `trusted` (from the JWT) is false for a real administrator until they confirm
12
+ * their identity on auth-server's elevation page. The role itself lives on the caller's own `User` record, which they may
13
+ * always read - `GET {authServerUrl}/api/users/me` (the same call auth-server's own UI makes "to check for admin access") -
14
+ * so the answer is read from there, with the same cross-origin credentials the profile lookups use.
15
+ *
16
+ * This only decides whether a menu item is shown. The `/admin` pages and API stay authoritative: they check the role and
17
+ * the elevation themselves, and the link only navigates (`/admin` then runs the elevation redirect), so a wrong answer
18
+ * - a stale cache, a tampered `sessionStorage` - can show a link that leads to "no administrator access", never more.
19
+ */
20
+
21
+ /** The roles the server treats as trusted when it isn't configured otherwise (`trusted_roles`, default `["admin"]`). */
22
+ export const DEFAULT_TRUSTED_ROLES: readonly string[] = ["admin"];
23
+
24
+ /** `sessionStorage` key prefix of a remembered answer; the user's uid follows, so another account signing in in the same
25
+ * tab never inherits it. */
26
+ export const ADMIN_ACCESS_KEY_PREFIX = "rapidmx-admin-access:";
27
+
28
+ /** How long a remembered answer is trusted - long enough that navigating between the apps costs no request, short enough
29
+ * that a role granted or removed shows up within the sitting. */
30
+ export const ADMIN_ACCESS_TTL_MS = 30 * 60 * 1000;
31
+
32
+ /** Lookups in flight, by key, so two components asking at once make one request. */
33
+ const lookups = new Map<string, Promise<boolean | undefined>>();
34
+
35
+ /** Forgets the lookups in flight (tests). */
36
+ export function resetAdminAccessLookups(): void {
37
+ lookups.clear();
38
+ }
39
+
40
+ function readRemembered(key: string, now: number): boolean | undefined {
41
+ try {
42
+ const stored = JSON.parse(sessionStorage.getItem(key) ?? "null") as { admin?: unknown; at?: unknown } | null;
43
+ if (typeof stored?.admin === "boolean" && typeof stored.at === "number" && now - stored.at >= 0 && now - stored.at < ADMIN_ACCESS_TTL_MS) {
44
+ return stored.admin;
45
+ }
46
+ } catch {
47
+ // Unreadable, or storage unavailable - ask again.
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ function remember(key: string, admin: boolean, now: number): void {
53
+ try {
54
+ sessionStorage.setItem(key, JSON.stringify({ admin, at: now }));
55
+ } catch {
56
+ // Storage unavailable - the next page load asks again.
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Resolves `true` when `userUid`'s own `User` record on auth-server carries one of `trustedRoles`, `false` when it
62
+ * doesn't, and `undefined` when that couldn't be established (unreachable, blocked by CORS, not signed in, an unexpected
63
+ * body, or a record that isn't this user's). Only a real answer is remembered; a failure is asked again on the next page.
64
+ * Never rejects.
65
+ */
66
+ export function lookUpAdminAccess(
67
+ authServerUrl: string,
68
+ userUid: string,
69
+ trustedRoles: readonly string[] = DEFAULT_TRUSTED_ROLES,
70
+ now: number = Date.now(),
71
+ ): Promise<boolean | undefined> {
72
+ const key = `${ADMIN_ACCESS_KEY_PREFIX}${userUid}`;
73
+ const remembered = readRemembered(key, now);
74
+ if (remembered !== undefined) {
75
+ return Promise.resolve(remembered);
76
+ }
77
+ const inFlight = lookups.get(key);
78
+ if (inFlight) {
79
+ return inFlight;
80
+ }
81
+ const lookup = (async (): Promise<boolean | undefined> => {
82
+ try {
83
+ const user = await authApiFetch<{ uid?: unknown; roles?: unknown } | null>(authServerUrl, "/users/me");
84
+ if (!user || user.uid !== userUid || !Array.isArray(user.roles)) {
85
+ return undefined;
86
+ }
87
+ const admin = user.roles.some((role) => typeof role === "string" && trustedRoles.includes(role));
88
+ remember(key, admin, now);
89
+ return admin;
90
+ } catch {
91
+ return undefined;
92
+ } finally {
93
+ // Answered: what was learned is in storage; a failure may be asked again.
94
+ lookups.delete(key);
95
+ }
96
+ })();
97
+ lookups.set(key, lookup);
98
+ return lookup;
99
+ }
@@ -0,0 +1,61 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
6
+
7
+ /**
8
+ * The `ApiRequestError.code` a `@RequiresElevation()` endpoint answers a caller whose token isn't elevated with
9
+ * (a 403 - `ApiErrors.AUTH_REQUIRES_ELEVATION` in `@rapidrest/service-core`). Distinct from `api-103` (the caller is
10
+ * elevated but lacks the trusted role), which no amount of elevating fixes.
11
+ */
12
+ export const ELEVATION_REQUIRED_CODE = "api-104";
13
+
14
+ /** `sessionStorage` key holding the time (`Date.now()`) the browser was last sent to auth-server to elevate. */
15
+ export const ELEVATION_ATTEMPT_KEY = "rapidmx-admin-elevation-attempt";
16
+
17
+ /** How long after sending the browser to elevate a second `api-104` is taken to mean the elevation didn't take
18
+ * effect (e.g. auth-server's elevated cookie never reaching this origin), rather than a fresh need to elevate. */
19
+ export const ELEVATION_RETRY_WINDOW_MS = 2 * 60 * 1000;
20
+
21
+ /** Whether `err` is a 403 from an elevation-gated endpoint for a caller whose token isn't elevated. */
22
+ export function isElevationRequired(err: unknown): boolean {
23
+ return err instanceof ApiRequestError && err.status === 403 && err.code === ELEVATION_REQUIRED_CODE;
24
+ }
25
+
26
+ /** auth-server's elevation page: it sends the browser back to `returnTo` once the user has confirmed their
27
+ * identity, or to its own account page if they cancel. */
28
+ export function elevationUrl(authServerUrl: string, returnTo: string): string {
29
+ return `${authServerUrl}/auth/elevate?return_to=${encodeURIComponent(returnTo)}`;
30
+ }
31
+
32
+ /** Whether the browser was sent to elevate within `ELEVATION_RETRY_WINDOW_MS` of `now`. `false` when nothing was
33
+ * recorded, the record is unreadable or from the future, or storage is unavailable. */
34
+ export function elevationAttemptedRecently(now: number = Date.now()): boolean {
35
+ try {
36
+ const recorded = Number(sessionStorage.getItem(ELEVATION_ATTEMPT_KEY));
37
+ const elapsed = now - recorded;
38
+ return recorded > 0 && elapsed >= 0 && elapsed < ELEVATION_RETRY_WINDOW_MS;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ /** Remembers, for this tab, that the browser is about to be sent to elevate. A no-op where storage is unavailable
45
+ * (then only the first-round protection is lost - elevating needs the user to confirm each time round). */
46
+ export function recordElevationAttempt(now: number = Date.now()): void {
47
+ try {
48
+ sessionStorage.setItem(ELEVATION_ATTEMPT_KEY, String(now));
49
+ } catch {
50
+ // See the doc comment.
51
+ }
52
+ }
53
+
54
+ /** Forgets any recorded attempt - once elevation has worked, or when the user asks to try again. */
55
+ export function clearElevationAttempt(): void {
56
+ try {
57
+ sessionStorage.removeItem(ELEVATION_ATTEMPT_KEY);
58
+ } catch {
59
+ // Nothing was recorded.
60
+ }
61
+ }
@@ -26,9 +26,17 @@ import { getSetupStatus } from "@rapidmx/react-shared/admin/setupApi.js";
26
26
  import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
27
27
  import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
28
28
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
29
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
29
30
  import BottomTabBar, { NavItem } from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
30
- import { BrandingFooter, BrandingHeader } from "../../layout/BrandingChrome.js";
31
+ import { BrandingFooter } from "../../layout/BrandingChrome.js";
31
32
  import UserMenu from "../../layout/UserMenu.js";
33
+ import {
34
+ clearElevationAttempt,
35
+ elevationAttemptedRecently,
36
+ elevationUrl,
37
+ isElevationRequired,
38
+ recordElevationAttempt,
39
+ } from "../elevation.js";
32
40
  import { signOutOfConsole } from "../signOut.js";
33
41
  import { mergePluginNavItems, PluginNav, PluginNavProps } from "../../../plugins/pluginNav.js";
34
42
 
@@ -68,7 +76,9 @@ export interface AdminShellProps extends PluginNavProps {
68
76
  impersonationBaseUrl?: string;
69
77
  }
70
78
 
71
- type Status = "checking" | "denied" | "error" | "authorized";
79
+ /** `elevating`: the browser is being sent to auth-server to confirm the user's identity. `elevationFailed`: it was
80
+ * sent a moment ago and the console still isn't elevated - see `elevation.ts`. */
81
+ type Status = "checking" | "denied" | "elevating" | "elevationFailed" | "error" | "authorized";
72
82
 
73
83
  /** Sections shown in the persistent icon rail / mobile tab bar — every admin area reachable from
74
84
  * anywhere in the console. Deliberately excludes `quarantine`/`ingestQueue`: those are scoped to a
@@ -148,14 +158,24 @@ export function adminNavItems(pluginNav?: PluginNav): NavItem[] {
148
158
  }
149
159
 
150
160
  /**
151
- * Gates every `apps/admin` page behind the `admin` trusted role. Uses `GET /api/admin/release-notes` (any
152
- * `BaseAdminRoute` endpoint works — this one is side-effect-free) purely as a canary: a 200 means the
153
- * caller's JWT carries a trusted role, a 403 means it doesn't. There is no local step-up/elevation flow
154
- * (that would need a cross-origin call to auth-server's own elevation endpoint — not wired up yet).
161
+ * Gates every `apps/admin` page behind the `admin` trusted role, and behind an elevated session. Uses
162
+ * `GET /api/admin/release-notes` (any `BaseAdminRoute` endpoint works — this one is side-effect-free) purely as a
163
+ * canary. The endpoint is class-level `@RequiresElevation()`, checked *before* the trusted-role check, so a 200 means the
164
+ * caller's JWT is elevated and carries a trusted role: show the console. Otherwise:
165
+ *
166
+ * 403 `api-104` means the JWT isn't elevated (an administrator's normal sign-in). There is no local step-up form; the
167
+ * browser is sent to auth-server's `/auth/elevate?return_to=<this page>`, which returns it here once the user has
168
+ * confirmed their identity (or to its own account page if they cancel). Sent at most once per
169
+ * `ELEVATION_RETRY_WINDOW_MS`, so an elevated cookie that never reaches this origin can't bounce the browser back
170
+ * and forth - see `elevation.ts`, and the "didn't take effect" alert with its own "Try again" below.
171
+ *
172
+ * 403 `api-103` (elevated, but not an administrator), any other 403, and 401 mean "no administrator access".
155
173
  */
156
174
  export default function AdminShell({ active, userUid, authServerUrl, pluginNav, children }: PropsWithChildren<AdminShellProps>) {
157
175
  const [status, setStatus] = useState<Status>("checking");
158
176
  const [error, setError] = useState<string | null>(null);
177
+ // `branding` only feeds the footer below: the admin-configured header is for the webmail and public pages, not the
178
+ // console. `useBranding()` is still what injects the custom stylesheet and supplies the rail's icon.
159
179
  const { branding, iconSrc } = useBranding();
160
180
 
161
181
  useRedirectIfUnauthenticated(userUid, authServerUrl);
@@ -166,6 +186,8 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
166
186
  }
167
187
  apiFetch("/admin/release-notes")
168
188
  .then(async () => {
189
+ // Elevated (or elevation isn't needed): a later expiry of the elevation may send the user round again.
190
+ clearElevationAttempt();
169
191
  // Until first-run setup is finished, every other admin page sends the admin to the wizard. A failed
170
192
  // check never blocks the console - the admin can still reach setup from the Mailboxes page.
171
193
  if (active !== "setup") {
@@ -181,6 +203,17 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
181
203
  setStatus("authorized");
182
204
  })
183
205
  .catch((err) => {
206
+ if (isElevationRequired(err)) {
207
+ // Without auth-server's URL there is nowhere to send the browser to elevate.
208
+ if (!authServerUrl) {
209
+ setStatus("denied");
210
+ } else if (elevationAttemptedRecently()) {
211
+ setStatus("elevationFailed");
212
+ } else {
213
+ startElevation(authServerUrl);
214
+ }
215
+ return;
216
+ }
184
217
  if (err instanceof ApiRequestError && (err.status === 403 || err.status === 401)) {
185
218
  setStatus("denied");
186
219
  return;
@@ -190,6 +223,18 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
190
223
  });
191
224
  }, [userUid]);
192
225
 
226
+ /** Remembers the attempt, then sends the browser to auth-server to confirm the user's identity. */
227
+ function startElevation(authServer: string) {
228
+ recordElevationAttempt();
229
+ setStatus("elevating");
230
+ window.location.href = elevationUrl(authServer, window.location.href);
231
+ }
232
+
233
+ function handleRetryElevation() {
234
+ clearElevationAttempt();
235
+ startElevation(authServerUrl!);
236
+ }
237
+
193
238
  function handleSignOut() {
194
239
  // Ends the auth-server session and tells other tabs, not just navigates - see `signOutOfConsole()`.
195
240
  void signOutOfConsole(authServerUrl);
@@ -198,6 +243,28 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
198
243
  let content: ReactNode;
199
244
  if (!userUid || status === "checking") {
200
245
  content = <div className="min-h-screen" />;
246
+ } else if (status === "elevating") {
247
+ content = (
248
+ <div className="min-h-screen flex items-center justify-center p-8">
249
+ <p role="status" className="text-sm text-text-muted">
250
+ Redirecting to confirm your identity&hellip;
251
+ </p>
252
+ </div>
253
+ );
254
+ } else if (status === "elevationFailed") {
255
+ content = (
256
+ <div className="min-h-screen flex items-center justify-center p-8">
257
+ <div className="w-full max-w-md flex flex-col items-start">
258
+ <Alert>
259
+ Confirming your identity didn&rsquo;t take effect, so the administrator console is still locked. Try again,
260
+ and if this keeps happening, sign out and sign back in.
261
+ </Alert>
262
+ <Button type="button" className="!w-auto" onClick={handleRetryElevation}>
263
+ Try again
264
+ </Button>
265
+ </div>
266
+ </div>
267
+ );
201
268
  } else if (status === "denied") {
202
269
  content = (
203
270
  <div className="min-h-screen flex items-center justify-center p-8">
@@ -260,7 +327,6 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
260
327
 
261
328
  return (
262
329
  <>
263
- <BrandingHeader branding={branding} />
264
330
  {content}
265
331
  <BrandingFooter branding={branding} />
266
332
  </>
@@ -131,7 +131,8 @@ export default function BrandingForm({ branding, onChange, embedded = false }: B
131
131
  <h1 className="text-xl font-bold uppercase tracking-wide mb-1">Branding</h1>
132
132
  <p className="text-sm text-text-muted mb-5">
133
133
  Customize the logo, nav-header icon, product name, and chrome shown to every visitor of the
134
- webmail and admin console — including anonymous booking-page visitors.
134
+ webmail and admin console — including anonymous booking-page visitors. The header appears on the
135
+ webmail and public pages, not in the admin console; the footer appears in both.
135
136
  </p>
136
137
  </>
137
138
  )}