@rapidmx/web-client 0.9.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 (226) 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/settings/DomainDnsSetup.tsx +2 -1
  5. package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
  6. package/apps/shared/components/contacts/ContactsToolbar.tsx +125 -115
  7. package/apps/shared/components/contacts/layout/ContactsShell.tsx +15 -3
  8. package/apps/shared/components/layout/AppShell.tsx +411 -288
  9. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
  10. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -164
  11. package/apps/shared/components/layout/UserMenu.tsx +303 -185
  12. package/apps/shared/components/mail/ConversationList.tsx +269 -262
  13. package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
  14. package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
  15. package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
  16. package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1387
  17. package/apps/shared/components/mail/NewMailToasts.tsx +141 -0
  18. package/apps/shared/components/mail/compose/ComposeContext.tsx +279 -160
  19. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
  20. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1842 -1736
  21. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
  22. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
  23. package/apps/shared/components/mail/compose/composePerf.ts +46 -0
  24. package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
  25. package/apps/shared/components/mail/layout/MailShell.tsx +476 -472
  26. package/apps/shared/components/mail/unreadStyle.tsx +75 -0
  27. package/apps/shared/components/settings/layout/SettingsShell.tsx +252 -240
  28. package/apps/shared/components/tasks/layout/TasksShell.tsx +15 -3
  29. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
  30. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
  31. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
  32. package/apps/shared/keyboard/dispatch.ts +124 -0
  33. package/apps/shared/keyboard/format.ts +89 -0
  34. package/apps/shared/keyboard/keymap.ts +114 -0
  35. package/apps/shared/keyboard/match.ts +44 -0
  36. package/apps/shared/keyboard/parse.ts +136 -0
  37. package/apps/shared/keyboard/platform.ts +34 -0
  38. package/apps/shared/keyboard/registry.ts +65 -0
  39. package/apps/shared/keyboard/targets.ts +79 -0
  40. package/apps/shared/keyboard/useShortcut.ts +50 -0
  41. package/apps/shared/keyboard/useShortcutProps.ts +17 -0
  42. package/apps/shared/mail/folderCounts.ts +302 -0
  43. package/apps/shared/mail/listSnapshots.ts +87 -0
  44. package/apps/shared/mail/messageReadState.ts +85 -0
  45. package/apps/shared/mail/newMailNotifications.ts +183 -0
  46. package/apps/shared/mail/useMailConnection.ts +150 -0
  47. package/apps/shared/mail/useMailLiveUpdates.ts +232 -224
  48. package/apps/shared/mail/useMarkMessageRead.ts +47 -0
  49. package/apps/shared/mail/useNewMailNotifications.ts +163 -0
  50. package/apps/shared/mail/useUnreadTitle.ts +42 -0
  51. package/apps/shared/navigation/AppRouter.tsx +300 -0
  52. package/apps/shared/navigation/appHrefs.ts +23 -0
  53. package/apps/shared/navigation/frameContext.tsx +35 -0
  54. package/apps/shared/navigation/idle.ts +45 -0
  55. package/apps/shared/navigation/routerContext.tsx +83 -0
  56. package/apps/shared/navigation/routes.ts +72 -0
  57. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
  58. package/apps/shared/styles/app.css +28 -10
  59. package/apps/www/_routedPage.tsx +24 -0
  60. package/apps/www/_routes.ts +35 -0
  61. package/apps/www/calendar/index.tsx +55 -19
  62. package/apps/www/contacts/[uid].tsx +112 -107
  63. package/apps/www/contacts/index.tsx +584 -567
  64. package/apps/www/index.tsx +2280 -1917
  65. package/apps/www/messages/[uid].tsx +106 -101
  66. package/apps/www/settings/auto-reply/index.tsx +4 -1
  67. package/apps/www/settings/encryption/index.tsx +1252 -1249
  68. package/apps/www/settings/filters/[uid].tsx +4 -1
  69. package/apps/www/settings/filters/index.tsx +102 -99
  70. package/apps/www/settings/filters/new/index.tsx +138 -133
  71. package/apps/www/settings/labels/index.tsx +204 -201
  72. package/apps/www/settings/privacy/index.tsx +4 -1
  73. package/apps/www/settings/read-receipts/index.tsx +4 -1
  74. package/apps/www/settings/sharing/index.tsx +277 -274
  75. package/apps/www/settings/signatures/[uid].tsx +170 -167
  76. package/apps/www/settings/signatures/index.tsx +88 -85
  77. package/apps/www/settings/signatures/new/index.tsx +134 -129
  78. package/apps/www/tasks/index.tsx +18 -2
  79. package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
  80. package/dist/apps/shared/auth/accountUrl.js +8 -0
  81. package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
  82. package/dist/apps/shared/auth/adminAccess.js +89 -0
  83. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +1 -1
  84. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
  85. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
  86. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  87. package/dist/apps/shared/components/contacts/ContactsToolbar.js +7 -4
  88. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
  89. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +15 -5
  90. package/dist/apps/shared/components/layout/AppShell.d.ts +29 -4
  91. package/dist/apps/shared/components/layout/AppShell.js +73 -14
  92. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
  93. package/dist/apps/shared/components/layout/MailboxProvisioning.js +3 -1
  94. package/dist/apps/shared/components/layout/UserMenu.d.ts +19 -3
  95. package/dist/apps/shared/components/layout/UserMenu.js +52 -5
  96. package/dist/apps/shared/components/mail/ConversationList.js +4 -11
  97. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  98. package/dist/apps/shared/components/mail/ConversationThreadPane.js +52 -27
  99. package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
  100. package/dist/apps/shared/components/mail/LazyReadingPane.js +82 -0
  101. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +4 -1
  102. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -3
  103. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +7 -1
  104. package/dist/apps/shared/components/mail/MessageDetailPane.js +83 -34
  105. package/dist/apps/shared/components/mail/NewMailToasts.d.ts +19 -0
  106. package/dist/apps/shared/components/mail/NewMailToasts.js +55 -0
  107. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +30 -0
  108. package/dist/apps/shared/components/mail/compose/ComposeContext.js +71 -5
  109. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
  110. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +1 -1
  111. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +98 -22
  112. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
  113. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
  114. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
  115. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
  116. package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
  117. package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
  118. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
  119. package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
  120. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +12 -4
  121. package/dist/apps/shared/components/mail/layout/MailShell.js +76 -96
  122. package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
  123. package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
  124. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
  125. package/dist/apps/shared/components/settings/layout/SettingsShell.js +15 -5
  126. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
  127. package/dist/apps/shared/components/tasks/layout/TasksShell.js +15 -5
  128. package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
  129. package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
  130. package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
  131. package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
  132. package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
  133. package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
  134. package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
  135. package/dist/apps/shared/keyboard/dispatch.js +109 -0
  136. package/dist/apps/shared/keyboard/format.d.ts +12 -0
  137. package/dist/apps/shared/keyboard/format.js +74 -0
  138. package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
  139. package/dist/apps/shared/keyboard/keymap.js +84 -0
  140. package/dist/apps/shared/keyboard/match.d.ts +20 -0
  141. package/dist/apps/shared/keyboard/match.js +31 -0
  142. package/dist/apps/shared/keyboard/parse.d.ts +31 -0
  143. package/dist/apps/shared/keyboard/parse.js +107 -0
  144. package/dist/apps/shared/keyboard/platform.d.ts +15 -0
  145. package/dist/apps/shared/keyboard/platform.js +22 -0
  146. package/dist/apps/shared/keyboard/registry.d.ts +39 -0
  147. package/dist/apps/shared/keyboard/registry.js +33 -0
  148. package/dist/apps/shared/keyboard/targets.d.ts +14 -0
  149. package/dist/apps/shared/keyboard/targets.js +67 -0
  150. package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
  151. package/dist/apps/shared/keyboard/useShortcut.js +35 -0
  152. package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
  153. package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
  154. package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
  155. package/dist/apps/shared/mail/folderCounts.js +212 -0
  156. package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
  157. package/dist/apps/shared/mail/listSnapshots.js +43 -0
  158. package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
  159. package/dist/apps/shared/mail/messageReadState.js +63 -0
  160. package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
  161. package/dist/apps/shared/mail/newMailNotifications.js +138 -0
  162. package/dist/apps/shared/mail/useMailConnection.d.ts +55 -0
  163. package/dist/apps/shared/mail/useMailConnection.js +96 -0
  164. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +13 -6
  165. package/dist/apps/shared/mail/useMailLiveUpdates.js +28 -26
  166. package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
  167. package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
  168. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +41 -0
  169. package/dist/apps/shared/mail/useNewMailNotifications.js +110 -0
  170. package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
  171. package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
  172. package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
  173. package/dist/apps/shared/navigation/AppRouter.js +242 -0
  174. package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
  175. package/dist/apps/shared/navigation/appHrefs.js +20 -0
  176. package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
  177. package/dist/apps/shared/navigation/frameContext.js +24 -0
  178. package/dist/apps/shared/navigation/idle.d.ts +14 -0
  179. package/dist/apps/shared/navigation/idle.js +43 -0
  180. package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
  181. package/dist/apps/shared/navigation/routerContext.js +56 -0
  182. package/dist/apps/shared/navigation/routes.d.ts +32 -0
  183. package/dist/apps/shared/navigation/routes.js +37 -0
  184. package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
  185. package/dist/apps/shared/styles/app.css +28 -10
  186. package/dist/apps/www/_routedPage.d.ts +12 -0
  187. package/dist/apps/www/_routedPage.js +19 -0
  188. package/dist/apps/www/_routes.d.ts +11 -0
  189. package/dist/apps/www/_routes.js +29 -0
  190. package/dist/apps/www/calendar/index.d.ts +2 -2
  191. package/dist/apps/www/calendar/index.js +39 -8
  192. package/dist/apps/www/contacts/[uid].d.ts +3 -9
  193. package/dist/apps/www/contacts/[uid].js +6 -2
  194. package/dist/apps/www/contacts/index.d.ts +2 -2
  195. package/dist/apps/www/contacts/index.js +17 -4
  196. package/dist/apps/www/index.d.ts +2 -2
  197. package/dist/apps/www/index.js +301 -35
  198. package/dist/apps/www/messages/[uid].d.ts +3 -7
  199. package/dist/apps/www/messages/[uid].js +7 -4
  200. package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
  201. package/dist/apps/www/settings/auto-reply/index.js +3 -1
  202. package/dist/apps/www/settings/encryption/index.d.ts +2 -2
  203. package/dist/apps/www/settings/encryption/index.js +3 -1
  204. package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
  205. package/dist/apps/www/settings/filters/[uid].js +3 -1
  206. package/dist/apps/www/settings/filters/index.d.ts +2 -2
  207. package/dist/apps/www/settings/filters/index.js +3 -1
  208. package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
  209. package/dist/apps/www/settings/filters/new/index.js +6 -2
  210. package/dist/apps/www/settings/labels/index.d.ts +2 -2
  211. package/dist/apps/www/settings/labels/index.js +3 -1
  212. package/dist/apps/www/settings/privacy/index.d.ts +2 -2
  213. package/dist/apps/www/settings/privacy/index.js +3 -1
  214. package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
  215. package/dist/apps/www/settings/read-receipts/index.js +3 -1
  216. package/dist/apps/www/settings/sharing/index.d.ts +2 -2
  217. package/dist/apps/www/settings/sharing/index.js +3 -1
  218. package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
  219. package/dist/apps/www/settings/signatures/[uid].js +3 -1
  220. package/dist/apps/www/settings/signatures/index.d.ts +2 -2
  221. package/dist/apps/www/settings/signatures/index.js +3 -1
  222. package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
  223. package/dist/apps/www/settings/signatures/new/index.js +6 -2
  224. package/dist/apps/www/tasks/index.d.ts +2 -2
  225. package/dist/apps/www/tasks/index.js +15 -3
  226. 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 - 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. |
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
+ }
@@ -195,7 +195,8 @@ export default function DomainDnsSetup({ uid, onLoaded }: DomainDnsSetupProps) {
195
195
  </span>
196
196
  )}
197
197
  </td>
198
- <td className="py-2.5 px-2.5 border-b border-border text-text-muted">
198
+ {/* A floor on the name column: beside a long DKIM key (which breaks anywhere) the table gave it a few characters, and a name wrapped one letter to a line. */}
199
+ <td className="py-2.5 px-2.5 border-b border-border text-text-muted min-w-[10rem]">
199
200
  {check.recordName ? (
200
201
  <CopyableValue
201
202
  value={check.recordName}
@@ -9,6 +9,7 @@ import { Folder, Mailbox, listFolders, listMailboxes } from "@rapidmx/react-shar
9
9
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
10
  import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
11
11
  import AppShell, { AppShellProps } from "../../layout/AppShell.js";
12
+ import { useLocationSearch } from "../../../navigation/AppRouter.js";
12
13
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
13
14
 
14
15
  export type CalendarShellProps = Omit<AppShellProps, "active">;
@@ -69,6 +70,7 @@ export default function CalendarShell({
69
70
  impersonating,
70
71
  impersonationBaseUrl,
71
72
  trusted,
73
+ trustedRoles,
72
74
  pluginNav,
73
75
  children,
74
76
  }: PropsWithChildren<CalendarShellProps>) {
@@ -79,9 +81,12 @@ export default function CalendarShell({
79
81
  const [requestedMailboxUid, setRequestedMailboxUid] = useState<string | null>(null);
80
82
  const [folderRefreshToken, setFolderRefreshToken] = useState(0);
81
83
 
84
+ // Read from the router's location, so a mailbox change (a link, or `navigate()`) takes effect without a page load - and in an
85
+ // effect, not during render, so the server render and the hydrating render agree.
86
+ const search = useLocationSearch();
82
87
  useEffect(() => {
83
- setRequestedMailboxUid(new URLSearchParams(window.location.search).get("mailboxUid"));
84
- }, []);
88
+ setRequestedMailboxUid(new URLSearchParams(search).get("mailboxUid"));
89
+ }, [search]);
85
90
 
86
91
  useEffect(() => {
87
92
  if (!userUid) {
@@ -186,6 +191,7 @@ export default function CalendarShell({
186
191
  impersonating={impersonating}
187
192
  impersonationBaseUrl={impersonationBaseUrl}
188
193
  trusted={trusted}
194
+ trustedRoles={trustedRoles}
189
195
  pluginNav={pluginNav}
190
196
  >
191
197
  {inner}