@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
@@ -11,9 +11,11 @@ import { getSetupStatus } from "@rapidmx/react-shared/admin/setupApi.js";
11
11
  import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
12
12
  import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
13
13
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
14
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
14
15
  import BottomTabBar from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
15
- import { BrandingFooter, BrandingHeader } from "../../layout/BrandingChrome.js";
16
+ import { BrandingFooter } from "../../layout/BrandingChrome.js";
16
17
  import UserMenu from "../../layout/UserMenu.js";
18
+ import { clearElevationAttempt, elevationAttemptedRecently, elevationUrl, isElevationRequired, recordElevationAttempt, } from "../elevation.js";
17
19
  import { signOutOfConsole } from "../signOut.js";
18
20
  import { mergePluginNavItems } from "../../../plugins/pluginNav.js";
19
21
  /** Sections shown in the persistent icon rail / mobile tab bar — every admin area reachable from
@@ -84,14 +86,24 @@ export function adminNavItems(pluginNav) {
84
86
  return mergePluginNavItems(NAV_ITEMS, pluginNav?.adminNav, ({ id, href, label }) => ({ id, href, label, icon: HiOutlinePuzzlePiece }), OFF_RAIL_ITEMS.map((item) => item.id));
85
87
  }
86
88
  /**
87
- * Gates every `apps/admin` page behind the `admin` trusted role. Uses `GET /api/admin/release-notes` (any
88
- * `BaseAdminRoute` endpoint works — this one is side-effect-free) purely as a canary: a 200 means the
89
- * caller's JWT carries a trusted role, a 403 means it doesn't. There is no local step-up/elevation flow
90
- * (that would need a cross-origin call to auth-server's own elevation endpoint — not wired up yet).
89
+ * Gates every `apps/admin` page behind the `admin` trusted role, and behind an elevated session. Uses
90
+ * `GET /api/admin/release-notes` (any `BaseAdminRoute` endpoint works — this one is side-effect-free) purely as a
91
+ * canary. The endpoint is class-level `@RequiresElevation()`, checked *before* the trusted-role check, so a 200 means the
92
+ * caller's JWT is elevated and carries a trusted role: show the console. Otherwise:
93
+ *
94
+ * 403 `api-104` means the JWT isn't elevated (an administrator's normal sign-in). There is no local step-up form; the
95
+ * browser is sent to auth-server's `/auth/elevate?return_to=<this page>`, which returns it here once the user has
96
+ * confirmed their identity (or to its own account page if they cancel). Sent at most once per
97
+ * `ELEVATION_RETRY_WINDOW_MS`, so an elevated cookie that never reaches this origin can't bounce the browser back
98
+ * and forth - see `elevation.ts`, and the "didn't take effect" alert with its own "Try again" below.
99
+ *
100
+ * 403 `api-103` (elevated, but not an administrator), any other 403, and 401 mean "no administrator access".
91
101
  */
92
102
  export default function AdminShell({ active, userUid, authServerUrl, pluginNav, children }) {
93
103
  const [status, setStatus] = useState("checking");
94
104
  const [error, setError] = useState(null);
105
+ // `branding` only feeds the footer below: the admin-configured header is for the webmail and public pages, not the
106
+ // console. `useBranding()` is still what injects the custom stylesheet and supplies the rail's icon.
95
107
  const { branding, iconSrc } = useBranding();
96
108
  useRedirectIfUnauthenticated(userUid, authServerUrl);
97
109
  useEffect(() => {
@@ -100,6 +112,8 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
100
112
  }
101
113
  apiFetch("/admin/release-notes")
102
114
  .then(async () => {
115
+ // Elevated (or elevation isn't needed): a later expiry of the elevation may send the user round again.
116
+ clearElevationAttempt();
103
117
  // Until first-run setup is finished, every other admin page sends the admin to the wizard. A failed
104
118
  // check never blocks the console - the admin can still reach setup from the Mailboxes page.
105
119
  if (active !== "setup") {
@@ -116,6 +130,19 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
116
130
  setStatus("authorized");
117
131
  })
118
132
  .catch((err) => {
133
+ if (isElevationRequired(err)) {
134
+ // Without auth-server's URL there is nowhere to send the browser to elevate.
135
+ if (!authServerUrl) {
136
+ setStatus("denied");
137
+ }
138
+ else if (elevationAttemptedRecently()) {
139
+ setStatus("elevationFailed");
140
+ }
141
+ else {
142
+ startElevation(authServerUrl);
143
+ }
144
+ return;
145
+ }
119
146
  if (err instanceof ApiRequestError && (err.status === 403 || err.status === 401)) {
120
147
  setStatus("denied");
121
148
  return;
@@ -124,6 +151,16 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
124
151
  setStatus("error");
125
152
  });
126
153
  }, [userUid]);
154
+ /** Remembers the attempt, then sends the browser to auth-server to confirm the user's identity. */
155
+ function startElevation(authServer) {
156
+ recordElevationAttempt();
157
+ setStatus("elevating");
158
+ window.location.href = elevationUrl(authServer, window.location.href);
159
+ }
160
+ function handleRetryElevation() {
161
+ clearElevationAttempt();
162
+ startElevation(authServerUrl);
163
+ }
127
164
  function handleSignOut() {
128
165
  // Ends the auth-server session and tells other tabs, not just navigates - see `signOutOfConsole()`.
129
166
  void signOutOfConsole(authServerUrl);
@@ -132,6 +169,12 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
132
169
  if (!userUid || status === "checking") {
133
170
  content = _jsx("div", { className: "min-h-screen" });
134
171
  }
172
+ else if (status === "elevating") {
173
+ content = (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8", children: _jsx("p", { role: "status", className: "text-sm text-text-muted", children: "Redirecting to confirm your identity\u2026" }) }));
174
+ }
175
+ else if (status === "elevationFailed") {
176
+ content = (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8", children: _jsxs("div", { className: "w-full max-w-md flex flex-col items-start", children: [_jsx(Alert, { children: "Confirming your identity didn\u2019t take effect, so the administrator console is still locked. Try again, and if this keeps happening, sign out and sign back in." }), _jsx(Button, { type: "button", className: "!w-auto", onClick: handleRetryElevation, children: "Try again" })] }) }));
177
+ }
135
178
  else if (status === "denied") {
136
179
  content = (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8", children: _jsx("div", { className: "w-full max-w-md", children: _jsx(Alert, { children: "You do not have administrator access." }) }) }));
137
180
  }
@@ -148,5 +191,5 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
148
191
  : "text-text-muted hover:bg-surface-alt hover:text-text",
149
192
  ].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] }), _jsx(BottomTabBar, { apps: navItems, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [_jsxs("header", { className: "h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: activeItem?.label }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut })] }), _jsx("div", { className: "flex-1 pb-14 md:pb-0", children: _jsx("main", { className: "max-w-6xl mx-auto px-6 py-8", children: children }) })] })] }) }));
150
193
  }
151
- return (_jsxs(_Fragment, { children: [_jsx(BrandingHeader, { branding: branding }), content, _jsx(BrandingFooter, { branding: branding })] }));
194
+ return (_jsxs(_Fragment, { children: [content, _jsx(BrandingFooter, { branding: branding })] }));
152
195
  }
@@ -104,7 +104,7 @@ export default function BrandingForm({ branding, onChange, embedded = false }) {
104
104
  setSaving(false);
105
105
  }
106
106
  }
107
- return (_jsxs("div", { className: "max-w-2xl", children: [!embedded && (_jsxs(_Fragment, { children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "Branding" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Customize the logo, nav-header icon, product name, and chrome shown to every visitor of the webmail and admin console \u2014 including anonymous booking-page visitors." })] })), error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), _jsxs("div", { className: "flex flex-col gap-6", children: [_jsxs("section", { className: "flex flex-col gap-3", children: [_jsx(SectionHeading, { className: "text-sm font-bold uppercase tracking-wide text-text-muted", children: "Logo" }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsx("img", { src: branding.logoUrl || "/images/logo.svg", alt: "Current logo", width: "64", height: "64", className: "border border-border rounded-sm bg-surface-alt p-1" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: assetBusy === "logo-upload", disabled: assetBusy !== null, onClick: () => logoFileRef.current?.click(), children: "Upload logo" }), _jsx("input", { ref: logoFileRef, type: "file", accept: RASTER_IMAGE_ACCEPT, "aria-label": "Upload logo", className: "hidden", onChange: handleLogoFileChange }), branding.logoUrl && (_jsx(Button, { type: "button", variant: "text", disabled: assetBusy !== null, loading: assetBusy === "logo-delete", onClick: () => void runAsset("logo-delete", async () => {
107
+ return (_jsxs("div", { className: "max-w-2xl", children: [!embedded && (_jsxs(_Fragment, { children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "Branding" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Customize the logo, nav-header icon, product name, and chrome shown to every visitor of the webmail and admin console \u2014 including anonymous booking-page visitors. The header appears on the webmail and public pages, not in the admin console; the footer appears in both." })] })), error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), _jsxs("div", { className: "flex flex-col gap-6", children: [_jsxs("section", { className: "flex flex-col gap-3", children: [_jsx(SectionHeading, { className: "text-sm font-bold uppercase tracking-wide text-text-muted", children: "Logo" }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsx("img", { src: branding.logoUrl || "/images/logo.svg", alt: "Current logo", width: "64", height: "64", className: "border border-border rounded-sm bg-surface-alt p-1" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: assetBusy === "logo-upload", disabled: assetBusy !== null, onClick: () => logoFileRef.current?.click(), children: "Upload logo" }), _jsx("input", { ref: logoFileRef, type: "file", accept: RASTER_IMAGE_ACCEPT, "aria-label": "Upload logo", className: "hidden", onChange: handleLogoFileChange }), branding.logoUrl && (_jsx(Button, { type: "button", variant: "text", disabled: assetBusy !== null, loading: assetBusy === "logo-delete", onClick: () => void runAsset("logo-delete", async () => {
108
108
  await deleteBrandingLogo();
109
109
  return { ...branding, logoUrl: undefined };
110
110
  }), children: "Remove logo" }))] })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Or use an external image URL" }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { "aria-label": "Logo URL", className: INPUT_CLASS, value: logoUrlInput, onChange: (e) => setLogoUrlInput(e.target.value) }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: assetBusy !== null || logoUrlInput === (branding.logoUrl ?? ""), loading: assetBusy === "logo-url", onClick: () => void runAsset("logo-url", () => updateBranding({ logoUrl: logoUrlInput })), children: "Set" })] })] })] }), _jsxs("section", { className: "flex flex-col gap-3", children: [_jsx(SectionHeading, { className: "text-sm font-bold uppercase tracking-wide text-text-muted", children: "Icon" }), _jsx("p", { className: "text-xs text-text-muted -mt-1", children: "A compact mark for navigation headers, independent of the full logo above. Falls back to the logo, then a default asset, when not set." }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsx("img", { src: branding.iconUrl || branding.logoUrl || "/images/logo.svg", alt: "Current icon", width: "64", height: "64", className: "border border-border rounded-sm bg-surface-alt p-1" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: assetBusy === "icon-upload", disabled: assetBusy !== null, onClick: () => iconFileRef.current?.click(), children: "Upload icon" }), _jsx("input", { ref: iconFileRef, type: "file", accept: RASTER_IMAGE_ACCEPT, "aria-label": "Upload icon", className: "hidden", onChange: handleIconFileChange }), branding.iconUrl && (_jsx(Button, { type: "button", variant: "text", disabled: assetBusy !== null, loading: assetBusy === "icon-delete", onClick: () => void runAsset("icon-delete", async () => {
@@ -8,6 +8,7 @@ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
8
  import { getDnsSetup, getDomain, verifyDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
9
9
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
10
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
+ import CopyButton from "@rapidmx/react-shared/components/buttons/CopyButton.js";
11
12
  const RECORD_TYPE_LABELS = {
12
13
  ownership: "Ownership (TXT)",
13
14
  mx: "MX",
@@ -15,6 +16,23 @@ const RECORD_TYPE_LABELS = {
15
16
  dkim: "DKIM",
16
17
  dmarc: "DMARC",
17
18
  };
19
+ /** How each record is named in a Copy button's accessible name ("Copy value for the SPF record"). */
20
+ const RECORD_TYPE_NAMES = {
21
+ ownership: "ownership TXT",
22
+ mx: "MX",
23
+ spf: "SPF",
24
+ dkim: "DKIM",
25
+ dmarc: "DMARC",
26
+ };
27
+ /** An MX record's recommended value is `"<priority> <mail server>"` - two separate fields in a DNS provider's form. */
28
+ function splitMxValue(value) {
29
+ const match = /^(\d+)\s+(\S+)$/.exec(value.trim());
30
+ return match ? { priority: match[1], server: match[2] } : null;
31
+ }
32
+ /** A value to type into a DNS provider's form, shown in full (long DKIM keys wrap) with a Copy button beside it. */
33
+ function CopyableValue({ value, copyLabel }) {
34
+ return (_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("code", { className: "flex-1 min-w-0 text-xs break-all", children: value }), _jsx(CopyButton, { value: value, label: copyLabel })] }));
35
+ }
18
36
  /**
19
37
  * A domain's status, its ownership TXT record with "Verify now", and the DNS setup checklist - shared by the domain
20
38
  * detail page and the setup wizard.
@@ -26,7 +44,6 @@ export default function DomainDnsSetup({ uid, onLoaded }) {
26
44
  const [error, setError] = useState(null);
27
45
  const [verifying, setVerifying] = useState(false);
28
46
  const [verifyError, setVerifyError] = useState(null);
29
- const [copied, setCopied] = useState(false);
30
47
  function reload(id) {
31
48
  setLoading(true);
32
49
  setError(null);
@@ -62,22 +79,23 @@ export default function DomainDnsSetup({ uid, onLoaded }) {
62
79
  setVerifying(false);
63
80
  }
64
81
  }
65
- async function handleCopy(value) {
66
- try {
67
- await navigator.clipboard.writeText(value);
68
- setCopied(true);
69
- setTimeout(() => setCopied(false), 2000);
70
- }
71
- catch {
72
- // Clipboard access can be denied by the browser — the value is still selectable/copyable by hand.
73
- }
74
- }
75
82
  if (loading) {
76
83
  return _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" });
77
84
  }
78
85
  if (error || !domain) {
79
86
  return _jsx(Alert, { children: error ?? "Domain not found." });
80
87
  }
81
- const ownershipValue = dnsSetup.find((c) => c.type === "ownership")?.recommendedValue ?? `rapidmx-domain-verification=${domain.verificationToken}`;
82
- return (_jsxs("div", { className: "flex flex-col gap-5", children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Enabled" }), _jsx("dd", { children: domain.enabled ? "Yes" : "No" }), _jsx("dt", { className: "text-text-muted", children: "Verification" }), _jsx("dd", { children: domain.verified ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Verified" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Unverified" })) }), _jsx("dt", { className: "text-text-muted", children: "Last checked" }), _jsx("dd", { children: domain.lastCheckedAt ? new Date(domain.lastCheckedAt).toLocaleString() : "Never" })] }), !domain.verified && (_jsxs("div", { className: "mt-5 pt-5 border-t border-border", children: [_jsxs("p", { className: "text-sm mb-2", children: ["Add the following TXT record to ", _jsx("strong", { children: domain.name }), " to prove ownership, then verify:"] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("code", { className: "flex-1 text-xs bg-surface-alt border border-border rounded-sm py-2 px-3 overflow-x-auto whitespace-nowrap", children: ownershipValue }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", onClick: () => handleCopy(ownershipValue), children: copied ? "Copied" : "Copy" })] }), verifyError && (_jsx("div", { className: "mt-3", children: _jsx(Alert, { children: verifyError }) })), _jsx(Button, { type: "button", className: "!w-auto mt-3", loading: verifying, disabled: verifying, onClick: handleVerify, children: verifyError ? "Try again" : "Verify now" })] }))] }), dnsSetup.length > 0 && (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide mb-3", children: "DNS setup checklist" }), _jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Record", "Status", "Recommended value"].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: dnsSetup.map((check) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: RECORD_TYPE_LABELS[check.type] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: !check.configured ? (_jsx("span", { className: "text-xs text-text-muted", children: "Not configured" })) : check.matches ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Live" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Not found" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted", children: _jsx("code", { className: "text-xs", children: check.recommendedValue ?? "—" }) })] }, check.type))) })] }) })] }))] }));
88
+ const ownership = dnsSetup.find((c) => c.type === "ownership");
89
+ const ownershipValue = ownership?.recommendedValue ?? `rapidmx-domain-verification=${domain.verificationToken}`;
90
+ const ownershipName = ownership?.recordName || domain.name;
91
+ return (_jsxs("div", { className: "flex flex-col gap-5", children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Enabled" }), _jsx("dd", { children: domain.enabled ? "Yes" : "No" }), _jsx("dt", { className: "text-text-muted", children: "Verification" }), _jsx("dd", { children: domain.verified ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Verified" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Unverified" })) }), _jsx("dt", { className: "text-text-muted", children: "Last checked" }), _jsx("dd", { children: domain.lastCheckedAt ? new Date(domain.lastCheckedAt).toLocaleString() : "Never" })] }), !domain.verified && (_jsxs("div", { className: "mt-5 pt-5 border-t border-border", children: [_jsxs("p", { className: "text-sm mb-2", children: ["Add the following TXT record to ", _jsx("strong", { children: domain.name }), " to prove ownership, then verify:"] }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] items-start gap-x-4 gap-y-2 text-sm", children: [_jsx("dt", { className: "text-text-muted py-2", children: "Type" }), _jsx("dd", { className: "py-2", children: "TXT" }), _jsx("dt", { className: "text-text-muted py-2", children: "Name / host" }), _jsx("dd", { className: "bg-surface-alt border border-border rounded-sm py-1.5 px-3", children: _jsx(CopyableValue, { value: ownershipName, copyLabel: "Copy name for the ownership TXT record" }) }), _jsx("dt", { className: "text-text-muted py-2", children: "Value" }), _jsx("dd", { className: "bg-surface-alt border border-border rounded-sm py-1.5 px-3", children: _jsx(CopyableValue, { value: ownershipValue, copyLabel: "Copy value for the ownership TXT record" }) })] }), verifyError && (_jsx("div", { className: "mt-3", children: _jsx(Alert, { children: verifyError }) })), _jsx(Button, { type: "button", className: "!w-auto mt-3", loading: verifying, disabled: verifying, onClick: handleVerify, children: verifyError ? "Try again" : "Verify now" })] }))] }), dnsSetup.length > 0 && (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide mb-3", children: "DNS setup checklist" }), _jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Record", "Status", "Name / host", "Recommended value"].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: dnsSetup.map((check) => (_jsxs("tr", { children: [_jsxs("td", { className: "py-2.5 px-2.5 border-b border-border", children: [RECORD_TYPE_LABELS[check.type], _jsxs("div", { className: "text-xs text-text-muted", children: ["Type: ", check.recordKind] })] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: !check.configured ? (_jsx("span", { className: "text-xs text-text-muted", children: "Not configured" })) : check.matches ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Live" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Not found" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted min-w-[10rem]", children: check.recordName ? (_jsx(CopyableValue, { value: check.recordName, copyLabel: `Copy name for the ${RECORD_TYPE_NAMES[check.type]} record` })) : (_jsx("code", { className: "text-xs", children: "\u2014" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted", children: check.recommendedValue ? (_jsx(DnsRecordValue, { check: check, value: check.recommendedValue })) : (_jsx("code", { className: "text-xs", children: "\u2014" })) })] }, check.type))) })] }) }), _jsxs("p", { className: "text-xs text-text-muted mt-3", children: ["Some DNS providers want the name relative to the domain (for example ", _jsx("code", { children: "@" }), " for the domain itself) rather than the full name."] })] }))] }));
92
+ }
93
+ /** The value cell of a checklist row: an MX value as its two form fields (priority and mail server), anything else whole. */
94
+ function DnsRecordValue({ check, value }) {
95
+ const recordName = RECORD_TYPE_NAMES[check.type];
96
+ const mx = check.type === "mx" ? splitMxValue(value) : null;
97
+ if (!mx) {
98
+ return _jsx(CopyableValue, { value: value, copyLabel: `Copy value for the ${recordName} record` });
99
+ }
100
+ return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("span", { className: "text-xs w-20 shrink-0 pt-1", children: "Priority" }), _jsx(CopyableValue, { value: mx.priority, copyLabel: `Copy priority for the ${recordName} record` })] }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("span", { className: "text-xs w-20 shrink-0 pt-1", children: "Mail server" }), _jsx(CopyableValue, { value: mx.server, copyLabel: `Copy mail server for the ${recordName} record` })] })] }));
83
101
  }
@@ -37,4 +37,4 @@ export declare function useCalendarShell(): CalendarShellContextValue;
37
37
  * state — it lives in `apps/www/calendar/index.tsx`'s own local state, since navigating between
38
38
  * views/dates must be instant (no full page reload) and this framework has no client-side router.
39
39
  */
40
- export default function CalendarShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }: PropsWithChildren<CalendarShellProps>): React.JSX.Element;
40
+ export default function CalendarShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }: PropsWithChildren<CalendarShellProps>): React.JSX.Element;
@@ -10,6 +10,7 @@ import { listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.j
10
10
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
11
  import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
12
12
  import AppShell from "../../layout/AppShell.js";
13
+ import { useLocationSearch } from "../../../navigation/AppRouter.js";
13
14
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
14
15
  const CalendarShellContext = createContext({
15
16
  calendarFolders: [],
@@ -29,16 +30,19 @@ export function useCalendarShell() {
29
30
  * state — it lives in `apps/www/calendar/index.tsx`'s own local state, since navigating between
30
31
  * views/dates must be instant (no full page reload) and this framework has no client-side router.
31
32
  */
32
- export default function CalendarShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }) {
33
+ export default function CalendarShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }) {
33
34
  const [status, setStatus] = useState("checking");
34
35
  const [error, setError] = useState(null);
35
36
  const [mailboxes, setMailboxes] = useState([]);
36
37
  const [mailboxCalendars, setMailboxCalendars] = useState([]);
37
38
  const [requestedMailboxUid, setRequestedMailboxUid] = useState(null);
38
39
  const [folderRefreshToken, setFolderRefreshToken] = useState(0);
40
+ // Read from the router's location, so a mailbox change (a link, or `navigate()`) takes effect without a page load - and in an
41
+ // effect, not during render, so the server render and the hydrating render agree.
42
+ const search = useLocationSearch();
39
43
  useEffect(() => {
40
- setRequestedMailboxUid(new URLSearchParams(window.location.search).get("mailboxUid"));
41
- }, []);
44
+ setRequestedMailboxUid(new URLSearchParams(search).get("mailboxUid"));
45
+ }, [search]);
42
46
  useEffect(() => {
43
47
  if (!userUid) {
44
48
  return;
@@ -100,5 +104,5 @@ export default function CalendarShell({ userUid, authServerUrl, impersonating, i
100
104
  else if (userUid && status === "ready") {
101
105
  inner = (_jsx("div", { className: "flex-1 min-w-0 flex flex-col", children: _jsx(CalendarShellContext.Provider, { value: contextValue, children: children }) }));
102
106
  }
103
- return (_jsx(AppShell, { active: "calendar", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, pluginNav: pluginNav, children: inner }));
107
+ return (_jsx(AppShell, { active: "calendar", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, trustedRoles: trustedRoles, pluginNav: pluginNav, children: inner }));
104
108
  }
@@ -14,6 +14,8 @@ export interface ContactsToolbarProps {
14
14
  /** A `.vcf` file the user picked via the toolbar's own hidden file input — parsing/import is the
15
15
  * page's responsibility (it has the mailbox/folder context and the actual `createContact` calls). */
16
16
  onImportFile: (file: File) => void;
17
+ /** The keyboard's "New contact" is registered by the page: the button names its shortcut in its tooltip and `aria-keyshortcuts`. */
18
+ shortcuts?: boolean;
17
19
  }
18
20
  /**
19
21
  * Contacts' Outlook-style ribbon toolbar. Deliberately presentational — every action is a callback the
@@ -22,4 +24,4 @@ export interface ContactsToolbarProps {
22
24
  * needs. "New contact list" isn't duplicated here — `ContactsSidebar` already has its own inline
23
25
  * "+ New list" affordance next to the "Your contact lists" heading.
24
26
  */
25
- export default function ContactsToolbar({ selectedCount, allSelectedFavorited, onNewContact, onEdit, onDelete, onEmail, onToggleFavorite, onAddCategory, onExportVCard, onImportFile, }: ContactsToolbarProps): React.JSX.Element;
27
+ export default function ContactsToolbar({ selectedCount, allSelectedFavorited, onNewContact, onEdit, onDelete, onEmail, onToggleFavorite, onAddCategory, onExportVCard, onImportFile, shortcuts, }: ContactsToolbarProps): React.JSX.Element;
@@ -5,8 +5,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useRef } from "react";
7
7
  import { HiOutlineArrowDownTray, HiOutlineArrowUpTray, HiOutlineEnvelope, HiOutlinePencil, HiOutlineStar, HiOutlineTag, HiOutlineTrash, HiOutlineUserPlus, HiStar, } from "react-icons/hi2";
8
- function ToolbarButton({ label, icon: Icon, disabled, onClick, }) {
9
- return (_jsxs("button", { type: "button", onClick: onClick, disabled: disabled, className: "flex flex-col items-center gap-1 text-xs text-text-muted hover:text-text disabled:opacity-40 disabled:cursor-not-allowed px-2.5 py-1.5 rounded-sm hover:not-disabled:bg-surface-alt", children: [_jsx(Icon, { size: 18, "aria-hidden": "true" }), _jsx("span", { children: label })] }));
8
+ import { SHORTCUTS } from "../../keyboard/keymap.js";
9
+ import { useShortcutProps } from "../../keyboard/useShortcutProps.js";
10
+ function ToolbarButton({ label, icon: Icon, disabled, onClick, hint, }) {
11
+ return (_jsxs("button", { type: "button", onClick: onClick, disabled: disabled, ...hint, className: "flex flex-col items-center gap-1 text-xs text-text-muted hover:text-text disabled:opacity-40 disabled:cursor-not-allowed px-2.5 py-1.5 rounded-sm hover:not-disabled:bg-surface-alt", children: [_jsx(Icon, { size: 18, "aria-hidden": "true" }), _jsx("span", { children: label })] }));
10
12
  }
11
13
  function Divider() {
12
14
  return _jsx("div", { className: "w-px self-stretch my-1 bg-border", "aria-hidden": "true" });
@@ -18,7 +20,8 @@ function Divider() {
18
20
  * needs. "New contact list" isn't duplicated here — `ContactsSidebar` already has its own inline
19
21
  * "+ New list" affordance next to the "Your contact lists" heading.
20
22
  */
21
- export default function ContactsToolbar({ selectedCount, allSelectedFavorited, onNewContact, onEdit, onDelete, onEmail, onToggleFavorite, onAddCategory, onExportVCard, onImportFile, }) {
23
+ export default function ContactsToolbar({ selectedCount, allSelectedFavorited, onNewContact, onEdit, onDelete, onEmail, onToggleFavorite, onAddCategory, onExportVCard, onImportFile, shortcuts, }) {
24
+ const newContactHint = useShortcutProps("New contact", SHORTCUTS.contacts.create, !!shortcuts);
22
25
  const importInputRef = useRef(null);
23
26
  const hasSelection = selectedCount > 0;
24
27
  function handleImportFileChosen(e) {
@@ -28,5 +31,5 @@ export default function ContactsToolbar({ selectedCount, allSelectedFavorited, o
28
31
  onImportFile(file);
29
32
  }
30
33
  }
31
- return (_jsxs("div", { role: "toolbar", "aria-label": "Contacts actions", className: "border-b border-border bg-surface-alt flex items-center gap-0.5 px-2 py-1", children: [_jsx(ToolbarButton, { label: "New contact", icon: HiOutlineUserPlus, onClick: onNewContact }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Edit", icon: HiOutlinePencil, disabled: selectedCount !== 1, onClick: onEdit }), _jsx(ToolbarButton, { label: "Delete", icon: HiOutlineTrash, disabled: !hasSelection, onClick: onDelete }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Email", icon: HiOutlineEnvelope, disabled: !hasSelection, onClick: onEmail }), _jsx(ToolbarButton, { label: allSelectedFavorited ? "Unfavorite" : "Favorite", icon: allSelectedFavorited ? HiStar : HiOutlineStar, disabled: !hasSelection, onClick: onToggleFavorite }), _jsx(ToolbarButton, { label: "Add category", icon: HiOutlineTag, disabled: !hasSelection, onClick: onAddCategory }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Export", icon: HiOutlineArrowUpTray, disabled: !hasSelection, onClick: onExportVCard }), _jsx(ToolbarButton, { label: "Import", icon: HiOutlineArrowDownTray, onClick: () => importInputRef.current?.click() }), _jsx("input", { ref: importInputRef, type: "file", accept: ".vcf", className: "sr-only", "aria-label": "Import contacts file", onChange: handleImportFileChosen })] }));
34
+ return (_jsxs("div", { role: "toolbar", "aria-label": "Contacts actions", className: "border-b border-border bg-surface-alt flex items-center gap-0.5 px-2 py-1", children: [_jsx(ToolbarButton, { label: "New contact", icon: HiOutlineUserPlus, onClick: onNewContact, hint: shortcuts ? newContactHint : undefined }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Edit", icon: HiOutlinePencil, disabled: selectedCount !== 1, onClick: onEdit }), _jsx(ToolbarButton, { label: "Delete", icon: HiOutlineTrash, disabled: !hasSelection, onClick: onDelete }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Email", icon: HiOutlineEnvelope, disabled: !hasSelection, onClick: onEmail }), _jsx(ToolbarButton, { label: allSelectedFavorited ? "Unfavorite" : "Favorite", icon: allSelectedFavorited ? HiStar : HiOutlineStar, disabled: !hasSelection, onClick: onToggleFavorite }), _jsx(ToolbarButton, { label: "Add category", icon: HiOutlineTag, disabled: !hasSelection, onClick: onAddCategory }), _jsx(Divider, {}), _jsx(ToolbarButton, { label: "Export", icon: HiOutlineArrowUpTray, disabled: !hasSelection, onClick: onExportVCard }), _jsx(ToolbarButton, { label: "Import", icon: HiOutlineArrowDownTray, onClick: () => importInputRef.current?.click() }), _jsx("input", { ref: importInputRef, type: "file", accept: ".vcf", className: "sr-only", "aria-label": "Import contacts file", onChange: handleImportFileChosen })] }));
32
35
  }
@@ -18,4 +18,4 @@ export declare function useContactsShell(): ContactsShellContextValue;
18
18
  * sidebar is therefore just a mailbox switcher (when the caller has more than one); the actual contact
19
19
  * list/detail UI is `apps/www/contacts/index.tsx`'s own content, rendered as `children` here.
20
20
  */
21
- export default function ContactsShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }: PropsWithChildren<ContactsShellProps>): React.JSX.Element;
21
+ export default function ContactsShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }: PropsWithChildren<ContactsShellProps>): React.JSX.Element;
@@ -11,6 +11,7 @@ import { listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.j
11
11
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
12
12
  import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
13
13
  import AppShell from "../../layout/AppShell.js";
14
+ import { useLocationSearch, useNavigate } from "../../../navigation/AppRouter.js";
14
15
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
15
16
  const ContactsShellContext = createContext({ mailboxes: [] });
16
17
  /** Reads the mailbox/folder Contacts is currently showing, as resolved by the enclosing `ContactsShell`. */
@@ -24,7 +25,7 @@ export function useContactsShell() {
24
25
  * sidebar is therefore just a mailbox switcher (when the caller has more than one); the actual contact
25
26
  * list/detail UI is `apps/www/contacts/index.tsx`'s own content, rendered as `children` here.
26
27
  */
27
- export default function ContactsShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }) {
28
+ export default function ContactsShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, children, }) {
28
29
  const [status, setStatus] = useState("checking");
29
30
  const [error, setError] = useState(null);
30
31
  const [mailboxes, setMailboxes] = useState([]);
@@ -32,9 +33,18 @@ export default function ContactsShell({ userUid, authServerUrl, impersonating, i
32
33
  const [folderError, setFolderError] = useState(null);
33
34
  const [requestedMailboxUid, setRequestedMailboxUid] = useState(null);
34
35
  const [drawerOpen, setDrawerOpen] = useState(false);
36
+ // Read from the router's location, so a mailbox change (a link, or `navigate()`) takes effect without a page load - and in an
37
+ // effect, not during render, so the server render and the hydrating render agree.
38
+ const search = useLocationSearch();
39
+ const navigate = useNavigate();
35
40
  useEffect(() => {
36
- setRequestedMailboxUid(new URLSearchParams(window.location.search).get("mailboxUid"));
37
- }, []);
41
+ setRequestedMailboxUid(new URLSearchParams(search).get("mailboxUid"));
42
+ }, [search]);
43
+ // Choosing a folder (or mailbox) changes the URL without a page load now, so the drawer that held the choice - which used to go
44
+ // with the page - is closed here.
45
+ useEffect(() => {
46
+ setDrawerOpen(false);
47
+ }, [search]);
38
48
  useEffect(() => {
39
49
  if (!userUid) {
40
50
  return;
@@ -85,9 +95,9 @@ export default function ContactsShell({ userUid, authServerUrl, impersonating, i
85
95
  // `id`/its `<label>`'s `htmlFor` need a distinct value per instance to avoid a duplicate-id
86
96
  // label-association break.
87
97
  const sidebarContent = (idPrefix) => (_jsxs(_Fragment, { children: [mailboxes.length > 1 && (_jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold uppercase tracking-wide text-text-muted mb-1", htmlFor: `${idPrefix}-contacts-mailbox-switcher`, children: "Mailbox" }), _jsx("select", { id: `${idPrefix}-contacts-mailbox-switcher`, className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: mailboxUid, onChange: (e) => {
88
- window.location.href = `/contacts?mailboxUid=${encodeURIComponent(e.target.value)}`;
98
+ navigate(`/contacts?mailboxUid=${encodeURIComponent(e.target.value)}`);
89
99
  }, children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), folderError && _jsx(Alert, { children: folderError })] }));
90
100
  inner = (_jsxs(_Fragment, { children: [_jsx("aside", { className: "hidden md:flex w-56 shrink-0 bg-surface border-r border-border flex-col p-3 gap-3", children: sidebarContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Mailbox", children: _jsx("div", { className: "flex flex-col gap-3", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 flex flex-col", children: [(mailboxes.length > 1 || folderError) && (_jsx("button", { type: "button", className: "md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open mailbox switcher", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) })), _jsx(ContactsShellContext.Provider, { value: contextValue, children: children })] })] }));
91
101
  }
92
- return (_jsx(AppShell, { active: "contacts", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, pluginNav: pluginNav, children: inner }));
102
+ return (_jsx(AppShell, { active: "contacts", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, trustedRoles: trustedRoles, pluginNav: pluginNav, children: inner }));
93
103
  }
@@ -27,8 +27,13 @@ export interface AppShellProps extends PluginNavProps {
27
27
  impersonating?: boolean;
28
28
  /** Where `mailApi.ts`'s `stopImpersonating()` should call — see `MailShell`'s original doc comment. */
29
29
  impersonationBaseUrl?: string;
30
- /** `true` when the caller's JWT carries a trusted role — shows an "Admin" item in the user menu below. */
30
+ /** `true` when the caller's JWT carries a trusted role — shows an "Admin Console" item in the user menu below. A token
31
+ * only carries one once elevated, so a real administrator with an ordinary session is `false` here; the user menu
32
+ * asks auth-server about them separately (see `UserMenu`'s `detectAdmin`). */
31
33
  trusted?: boolean;
34
+ /** The role names the server treats as trusted (its `trusted_roles` config, injected via the route's `fetchProps`) -
35
+ * what that lookup looks for. Absent means the server's own default, `["admin"]`. */
36
+ trustedRoles?: string[];
32
37
  }
33
38
  export interface AppDef {
34
39
  id: AppShellApp;
@@ -39,11 +44,31 @@ export interface AppDef {
39
44
  export declare const APPS: AppDef[];
40
45
  /** `APPS` followed by the plugins' `appRail` items (generic icon), core ids winning - see `mergePluginNavItems`. */
41
46
  export declare function appRailItems(pluginNav?: PluginNav): NavItem[];
47
+ /** What only the persistent frame (`AppRouter`) passes to the chrome it keeps mounted. */
48
+ export interface AppChromeProps extends AppShellProps {
49
+ /** Changes whenever the router shows a different page: the chrome then moves focus to the content, scrolls to its top,
50
+ * sets the document title and announces the page. Absent outside the router, when nothing does. */
51
+ routeKey?: string;
52
+ /** The router is loading the next page's code - the content is `aria-busy`. */
53
+ busy?: boolean;
54
+ /** A screen that takes over the window (`FrameTakeover`) is showing: the rail, header, banner and footer are hidden - not
55
+ * unmounted, so what is inside them (and the page in the content region) keeps its state. */
56
+ hideChrome?: boolean;
57
+ }
42
58
  /**
43
59
  * The persistent chrome shared by every webmail app (Mail, Calendar, Contacts, Tasks): a left icon rail for
44
- * switching apps (full page loads — see `MailShell`'s original doc comment on this framework having no
45
- * client-side router), a header with the current app's name and `UserMenu`, and the impersonation banner.
60
+ * switching apps, a header with the current app's name and `UserMenu`, and the impersonation banner.
46
61
  * Each app's own shell (e.g. `MailShell`) renders its own contextual sidebar + content as `children`, inside
47
62
  * the area to the right of the icon rail and below the header.
63
+ *
64
+ * Rendered by `AppRouter`, once, for the life of the page, so that moving between apps replaces only `children` - see
65
+ * `AppShell` below for what a page's own shell renders instead.
66
+ */
67
+ export declare function AppChrome({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, routeKey, busy, hideChrome, children, }: PropsWithChildren<AppChromeProps>): React.JSX.Element;
68
+ /**
69
+ * What a page's own shell (`MailShell`, `CalendarShell`, ...) renders around its content. Outside the client-side router it
70
+ * is the whole `AppChrome`, as it always was. Inside it (`AppRouter`) the chrome is already mounted above the page - and stays
71
+ * mounted as the page is replaced - so this is only its children; everything it would have been given comes from the router
72
+ * frame instead (the props are the same for every page, and `active` is the route's).
48
73
  */
49
- export default function AppShell({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }: PropsWithChildren<AppShellProps>): React.JSX.Element;
74
+ export default function AppShell(props: PropsWithChildren<AppShellProps>): React.JSX.Element;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  ///////////////////////////////////////////////////////////////////////////////
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
@@ -22,6 +22,16 @@ import { authApiFetch } from "@rapidmx/react-shared/util/api.js";
22
22
  import { destroyUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
23
23
  import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
24
24
  import { mergePluginNavItems } from "../../plugins/pluginNav.js";
25
+ import { useInAppFrame } from "../../navigation/frameContext.js";
26
+ import { APP_HREFS } from "../../navigation/appHrefs.js";
27
+ import { useNavigate } from "../../navigation/routerContext.js";
28
+ import { GlobalShortcuts } from "../../keyboard/GlobalShortcuts.js";
29
+ import { ShortcutProvider } from "../../keyboard/ShortcutProvider.js";
30
+ import ShortcutsDialog from "../../keyboard/ShortcutsDialog.js";
31
+ import { inboxUnreadTotal } from "../../mail/folderCounts.js";
32
+ import { MailConnectionContext, useMailConnection } from "../../mail/useMailConnection.js";
33
+ import { useUnreadTitle } from "../../mail/useUnreadTitle.js";
34
+ import NewMailToasts from "../mail/NewMailToasts.js";
25
35
  /** How long sign-out waits for auth-server's logout before navigating anyway. */
26
36
  export const LOGOUT_TIMEOUT_MS = 3000;
27
37
  /**
@@ -46,10 +56,10 @@ async function logOutOfAuthServer(authServerUrl) {
46
56
  }
47
57
  }
48
58
  export const APPS = [
49
- { id: "mail", href: "/", label: "Mail", icon: HiOutlineEnvelope },
50
- { id: "calendar", href: "/calendar", label: "Calendar", icon: HiOutlineCalendarDays },
51
- { id: "contacts", href: "/contacts", label: "Contacts", icon: HiOutlineUsers },
52
- { id: "tasks", href: "/tasks", label: "Tasks", icon: HiOutlineClipboardDocumentList },
59
+ { id: "mail", href: APP_HREFS.mail, label: "Mail", icon: HiOutlineEnvelope },
60
+ { id: "calendar", href: APP_HREFS.calendar, label: "Calendar", icon: HiOutlineCalendarDays },
61
+ { id: "contacts", href: APP_HREFS.contacts, label: "Contacts", icon: HiOutlineUsers },
62
+ { id: "tasks", href: APP_HREFS.tasks, label: "Tasks", icon: HiOutlineClipboardDocumentList },
53
63
  ];
54
64
  /** Ids plugin `appRail` items can't take besides `APPS`' own - `"settings"` has no rail icon but is still a
55
65
  * core `active` value. */
@@ -60,15 +70,26 @@ export function appRailItems(pluginNav) {
60
70
  }
61
71
  /**
62
72
  * The persistent chrome shared by every webmail app (Mail, Calendar, Contacts, Tasks): a left icon rail for
63
- * switching apps (full page loads — see `MailShell`'s original doc comment on this framework having no
64
- * client-side router), a header with the current app's name and `UserMenu`, and the impersonation banner.
73
+ * switching apps, a header with the current app's name and `UserMenu`, and the impersonation banner.
65
74
  * Each app's own shell (e.g. `MailShell`) renders its own contextual sidebar + content as `children`, inside
66
75
  * the area to the right of the icon rail and below the header.
76
+ *
77
+ * Rendered by `AppRouter`, once, for the life of the page, so that moving between apps replaces only `children` - see
78
+ * `AppShell` below for what a page's own shell renders instead.
67
79
  */
68
- export default function AppShell({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, pluginNav, children, }) {
80
+ export function AppChrome({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, routeKey, busy, hideChrome, children, }) {
69
81
  const [stoppingImpersonation, setStoppingImpersonation] = useState(false);
82
+ // The keyboard shortcuts dialog: opened by `?`/Ctrl+/ (`GlobalShortcuts`) and by the account menu's item.
83
+ const [shortcutsOpen, setShortcutsOpen] = useState(false);
70
84
  const signingOutRef = useRef(false);
71
85
  const { branding, iconSrc } = useBranding();
86
+ // The mailboxes, their folders and the one push connection live here, in the frame that stays mounted as the router swaps pages - so
87
+ // new-mail pop-ups, the folder counters and the tab title's unread count work in Calendar, Contacts, Tasks and Settings too, and moving
88
+ // between apps never opens a second socket (the server allows ten per user). `MailShell` reads them from `MailConnectionContext`. Outside
89
+ // the router (`AppShell` rendered as a page's own chrome: tests, plugin pages) it stays off, and a Mail shell owns its connection itself.
90
+ const inFrame = useInAppFrame();
91
+ const navigate = useNavigate();
92
+ const mail = useMailConnection({ userUid, enabled: inFrame, open: navigate });
72
93
  useRedirectIfUnauthenticated(userUid, authServerUrl);
73
94
  // Mounted here, not scoped to Mail/Settings (the only shells that actually read unlocked keys),
74
95
  // specifically so activity in *any* app resets the idle clock - see that hook's own doc comment.
@@ -151,16 +172,54 @@ export default function AppShell({ active, userUid, authServerUrl, impersonating
151
172
  window.location.href = "/admin";
152
173
  }
153
174
  }
175
+ // What the persistent frame (`AppRouter`) needs on top of a page that never changes: the document title follows the page, and
176
+ // after the router replaces the page the keyboard focus, the scroll position and a screen reader are told about it - a
177
+ // page load did all of that by itself. Nothing here runs for a page shown outside the router (`routeKey` is undefined).
178
+ const contentRef = useRef(null);
179
+ const [announcement, setAnnouncement] = useState("");
180
+ const firstRouteKeyRef = useRef(routeKey);
181
+ const pageLabel = active === "settings" ? "Settings" : appRailItems(pluginNav).find((app) => app.id === active)?.label;
182
+ useEffect(() => {
183
+ if (routeKey !== undefined) {
184
+ const brand = branding?.title || branding?.companyName || "RapidMX";
185
+ document.title = pageLabel ? `${brand}: ${pageLabel}` : brand;
186
+ }
187
+ }, [routeKey, pageLabel, branding]);
188
+ // After the title effect above, so a page change (which rewrites the title) is followed by the unread count going back in front of it.
189
+ useUnreadTitle(inboxUnreadTotal(mail.mailboxFolders, mail.folderCounts.counts), {
190
+ enabled: inFrame,
191
+ resetKey: `${routeKey}|${pageLabel}|${branding?.title}|${branding?.companyName}`,
192
+ });
193
+ useEffect(() => {
194
+ if (routeKey === undefined || routeKey === firstRouteKeyRef.current) {
195
+ return;
196
+ }
197
+ setAnnouncement(pageLabel ?? "");
198
+ contentRef.current?.focus({ preventScroll: true });
199
+ window.scrollTo(0, 0);
200
+ }, [routeKey]);
154
201
  if (!userUid) {
155
202
  return _jsx("div", { className: "min-h-screen" });
156
203
  }
157
204
  const apps = appRailItems(pluginNav);
158
205
  // The header title: "settings" has no rail item, everything else is labelled by its own rail item.
159
206
  const activeLabel = active === "settings" ? "Settings" : apps.find((app) => app.id === active)?.label;
160
- return (_jsx(UnlockPromptProvider, { children: _jsx(ComposeProvider, { userUid: userUid, trusted: !!trusted && !impersonating, children: _jsxs("div", { className: "min-h-screen flex flex-col bg-surface-alt", children: [_jsx(BrandingHeader, { branding: branding }), impersonating && (_jsxs("div", { className: "h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4", children: [_jsxs("span", { children: ["You are viewing as ", _jsx("strong", { children: userUid }), "."] }), _jsx("button", { type: "button", onClick: handleStopImpersonating, disabled: stoppingImpersonation, className: "underline hover:no-underline disabled:opacity-60", children: stoppingImpersonation ? "Returning to admin…" : "Return to admin" })] })), _jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsxs("nav", { "aria-label": "Apps", className: "hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1", children: [_jsx("img", { src: iconSrc, width: "96", height: "96", alt: "", className: "mb-3" }), apps.map(({ id, href, label, icon: Icon }) => (_jsx("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: label, className: [
161
- "w-10 h-10 flex items-center justify-center rounded-sm",
162
- id === active
163
- ? "bg-primary/10 text-primary-dark"
164
- : "text-text-muted hover:bg-surface-alt hover:text-text",
165
- ].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] }), _jsx(BottomTabBar, { apps: apps, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [_jsxs("header", { className: "h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: activeLabel }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut, showAdminLink: trusted, showSettingsLink: true })] }), _jsx("div", { className: "flex-1 flex min-h-0 pb-14 md:pb-0", children: children })] })] }), _jsx(BrandingFooter, { branding: branding })] }) }) }));
207
+ return (_jsx(ShortcutProvider, { children: _jsxs(MailConnectionContext.Provider, { value: inFrame ? mail : null, children: [_jsx(GlobalShortcuts, { authServerUrl: authServerUrl, onToggleHelp: () => setShortcutsOpen((open) => !open) }), _jsx(ShortcutsDialog, { open: shortcutsOpen, onClose: () => setShortcutsOpen(false) }), _jsx(UnlockPromptProvider, { children: _jsx(ComposeProvider, { userUid: userUid, trusted: !!trusted && !impersonating, children: _jsxs("div", { className: "min-h-screen flex flex-col bg-surface-alt", children: [!hideChrome && _jsx(BrandingHeader, { branding: branding }), impersonating && !hideChrome && (_jsxs("div", { className: "h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4", children: [_jsxs("span", { children: ["You are viewing as ", _jsx("strong", { children: userUid }), "."] }), _jsx("button", { type: "button", onClick: handleStopImpersonating, disabled: stoppingImpersonation, className: "underline hover:no-underline disabled:opacity-60", children: stoppingImpersonation ? "Returning to admin…" : "Return to admin" })] })), _jsxs("div", { className: "flex-1 flex min-h-0", children: [!hideChrome && (_jsxs("nav", { "aria-label": "Apps", className: "hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1", children: [_jsx("img", { src: iconSrc, width: "96", height: "96", alt: "", className: "mb-3" }), apps.map(({ id, href, label, icon: Icon }) => (_jsx("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: label, className: [
208
+ "w-10 h-10 flex items-center justify-center rounded-sm",
209
+ id === active
210
+ ? "bg-primary/10 text-primary-dark"
211
+ : "text-text-muted hover:bg-surface-alt hover:text-text",
212
+ ].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] })), !hideChrome && _jsx(BottomTabBar, { apps: apps, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [!hideChrome && (_jsxs("header", { className: "h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: activeLabel }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut,
213
+ // An impersonating administrator acts as the impersonated user, so the console link is hidden then.
214
+ showAdminLink: !!trusted && !impersonating, detectAdmin: !trusted && !impersonating, trustedRoles: trustedRoles, showSettingsLink: true, showNotificationSettings: true, onShowShortcuts: () => setShortcutsOpen(true) })] })), _jsx("div", { ref: contentRef, id: "app-content", tabIndex: -1, "aria-busy": busy || undefined, className: ["flex-1 flex min-h-0 outline-none", hideChrome ? "" : "pb-14 md:pb-0"].join(" "), children: children })] })] }), routeKey !== undefined && (_jsx("div", { role: "status", "aria-live": "polite", className: "sr-only", children: announcement })), !hideChrome && _jsx(BrandingFooter, { branding: branding })] }) }) }), inFrame && (_jsx(NewMailToasts, { toasts: mail.notifications.toasts, onDismiss: mail.notifications.dismiss, offerDesktop: mail.notifications.offerDesktop, onEnableDesktop: () => void mail.notifications.enableDesktop(), onDeclineDesktop: mail.notifications.declineDesktop }))] }) }));
215
+ }
216
+ /**
217
+ * What a page's own shell (`MailShell`, `CalendarShell`, ...) renders around its content. Outside the client-side router it
218
+ * is the whole `AppChrome`, as it always was. Inside it (`AppRouter`) the chrome is already mounted above the page - and stays
219
+ * mounted as the page is replaced - so this is only its children; everything it would have been given comes from the router
220
+ * frame instead (the props are the same for every page, and `active` is the route's).
221
+ */
222
+ export default function AppShell(props) {
223
+ const inFrame = useInAppFrame();
224
+ return inFrame ? _jsx(_Fragment, { children: props.children }) : _jsx(AppChrome, { ...props });
166
225
  }
@@ -7,8 +7,9 @@ import { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
7
7
  */
8
8
  export declare function sanitizeBrandingHtml(html: string): string;
9
9
  /**
10
- * Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`), sanitized - shared by
11
- * the webmail and admin chromes. The escrow console deliberately renders no branding HTML at all.
10
+ * Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`), sanitized. The webmail
11
+ * chrome (`AppShell`) shows both; the admin console shows only the footer - its header is the console's own - and the
12
+ * escrow console deliberately renders no branding HTML at all.
12
13
  */
13
14
  export declare function BrandingHeader({ branding }: {
14
15
  branding: Branding | null;
@@ -42,8 +42,9 @@ function BrandingHtml({ html }) {
42
42
  return _jsx("div", { dangerouslySetInnerHTML: { __html: clean } });
43
43
  }
44
44
  /**
45
- * Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`), sanitized - shared by
46
- * the webmail and admin chromes. The escrow console deliberately renders no branding HTML at all.
45
+ * Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`), sanitized. The webmail
46
+ * chrome (`AppShell`) shows both; the admin console shows only the footer - its header is the console's own - and the
47
+ * escrow console deliberately renders no branding HTML at all.
47
48
  */
48
49
  export function BrandingHeader({ branding }) {
49
50
  return _jsx(BrandingHtml, { html: branding?.headerHtml });