@rapidmx/web-client 0.7.0 → 0.9.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 (49) hide show
  1. package/README.md +1 -1
  2. package/apps/shared/components/admin/elevation.ts +61 -0
  3. package/apps/shared/components/admin/layout/AdminShell.tsx +73 -7
  4. package/apps/shared/components/admin/settings/BrandingForm.tsx +2 -1
  5. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +86 -24
  6. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +51 -12
  7. package/apps/shared/components/layout/BrandingChrome.tsx +3 -2
  8. package/apps/shared/components/layout/MailboxProvisioning.tsx +44 -6
  9. package/apps/shared/components/layout/UserMenu.tsx +48 -13
  10. package/apps/shared/components/mail/ConversationList.tsx +15 -6
  11. package/apps/shared/components/mail/ConversationThreadPane.tsx +2 -2
  12. package/apps/shared/components/mail/MailAddress.tsx +88 -0
  13. package/apps/shared/components/mail/MessageDetailPane.tsx +11 -7
  14. package/apps/shared/components/mail/compose/ComposeWindow.tsx +40 -4
  15. package/apps/shared/components/mail/compose/SendFailureAlert.tsx +48 -0
  16. package/apps/shared/components/mail/layout/MailShell.tsx +37 -10
  17. package/apps/shared/mail/mergeFirstPage.ts +47 -0
  18. package/apps/shared/mail/useMailLiveUpdates.ts +224 -0
  19. package/apps/www/index.tsx +65 -2
  20. package/dist/apps/shared/components/admin/elevation.d.ts +24 -0
  21. package/dist/apps/shared/components/admin/elevation.js +56 -0
  22. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +12 -4
  23. package/dist/apps/shared/components/admin/layout/AdminShell.js +49 -6
  24. package/dist/apps/shared/components/admin/settings/BrandingForm.js +1 -1
  25. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +31 -13
  26. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +5 -1
  27. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +19 -3
  28. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +3 -2
  29. package/dist/apps/shared/components/layout/BrandingChrome.js +3 -2
  30. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +5 -2
  31. package/dist/apps/shared/components/layout/MailboxProvisioning.js +42 -7
  32. package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -7
  33. package/dist/apps/shared/components/layout/UserMenu.js +39 -13
  34. package/dist/apps/shared/components/mail/ConversationList.js +7 -4
  35. package/dist/apps/shared/components/mail/ConversationThreadPane.js +2 -2
  36. package/dist/apps/shared/components/mail/MailAddress.d.ts +27 -0
  37. package/dist/apps/shared/components/mail/MailAddress.js +39 -0
  38. package/dist/apps/shared/components/mail/MessageDetailPane.js +5 -3
  39. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +25 -6
  40. package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +14 -0
  41. package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +11 -0
  42. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +7 -0
  43. package/dist/apps/shared/components/mail/layout/MailShell.js +31 -11
  44. package/dist/apps/shared/mail/mergeFirstPage.d.ts +23 -0
  45. package/dist/apps/shared/mail/mergeFirstPage.js +30 -0
  46. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +48 -0
  47. package/dist/apps/shared/mail/useMailLiveUpdates.js +180 -0
  48. package/dist/apps/www/index.js +63 -2
  49. package/package.json +2 -2
@@ -0,0 +1,47 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+
6
+ /** What `mergeFirstPage()` made of a fresh first page and the rows already on screen. */
7
+ export interface MergedPage<T> {
8
+ /** The rows to show. */
9
+ rows: T[];
10
+ /** How many of the fresh page's rows were not on screen yet - the mail that arrived since. */
11
+ added: number;
12
+ /** `true` when `rows` is exactly the fresh page, because the whole listing fits on it. */
13
+ complete: boolean;
14
+ }
15
+
16
+ /**
17
+ * Folds a freshly fetched first page of a list into the rows already loaded (the first page plus whatever "load more"
18
+ * added), for the quiet refresh that follows a push event or a poll - so new mail appears without the list being reset,
19
+ * re-scrolled or re-selected the way a full reload would.
20
+ *
21
+ * - A page shorter than `pageSize` is the whole listing, so it simply replaces what is shown - which also drops what was
22
+ * deleted or moved elsewhere.
23
+ * - A full page is only the top of a longer listing. Its rows go first, in the server's order, followed by the loaded rows
24
+ * it did not repeat (older ones the reader has scrolled to). What was removed elsewhere from a long, partly loaded list
25
+ * stays until the next real load; nothing here can tell it from a row that merely slid off the first page.
26
+ * - A row in both keeps whichever copy `pick` prefers - by default the fresh one - so, for messages, a row the reader has
27
+ * just changed (a higher `version`) is not put back by a fetch that started before the change landed.
28
+ */
29
+ export function mergeFirstPage<T>(
30
+ current: T[],
31
+ fresh: T[],
32
+ idOf: (row: T) => string,
33
+ pageSize: number,
34
+ pick: (current: T, fresh: T) => T = (_current, next) => next,
35
+ ): MergedPage<T> {
36
+ const currentById = new Map(current.map((row) => [idOf(row), row]));
37
+ const added = fresh.filter((row) => !currentById.has(idOf(row))).length;
38
+ const rows = fresh.map((row) => {
39
+ const existing = currentById.get(idOf(row));
40
+ return existing ? pick(existing, row) : row;
41
+ });
42
+ if (fresh.length < pageSize) {
43
+ return { rows, added, complete: true };
44
+ }
45
+ const freshIds = new Set(fresh.map(idOf));
46
+ return { rows: [...rows, ...current.filter((row) => !freshIds.has(idOf(row)))], added, complete: false };
47
+ }
@@ -0,0 +1,224 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useEffect, useMemo, useRef, useState } from "react";
6
+ import { Folder, Mailbox, listFolders } from "@rapidmx/react-shared/mail/mailApi.js";
7
+ import { getPushClient, PushEvent } from "@rapidmx/react-shared/mail/pushClient.js";
8
+ import type { MailboxFolders } from "../components/mail/layout/MailShell.js";
9
+ import { SIGN_OUT_CHANNEL } from "../search/localIndexRpcClient.js";
10
+
11
+ /** How often the safety-net poll runs while the tab is visible. The push socket does the real work; this is for a network
12
+ * that blocks or silently drops it, and for events published while it was reconnecting (Redis pub/sub is never replayed). */
13
+ export const LIVE_POLL_INTERVAL_MS = 45_000;
14
+
15
+ /** Events arriving this close together are answered with one refresh - a burst of new mail is one refetch, not one each. */
16
+ export const LIVE_EVENT_DEBOUNCE_MS = 400;
17
+
18
+ /** What changed, as far as a list on screen is concerned. */
19
+ export interface LiveUpdates {
20
+ /** Bumped each time the list should quietly refetch its first page. `0` until the first bump. */
21
+ tick: number;
22
+ /** The folders the events behind the latest bump touched - `null` when that isn't known (a poll, a reconnect, the tab
23
+ * coming back to the front), meaning any folder may have changed. */
24
+ folderUids: ReadonlySet<string> | null;
25
+ }
26
+
27
+ export const NO_LIVE_UPDATES: LiveUpdates = { tick: 0, folderUids: null };
28
+
29
+ /** The class names `NotificationUtils.sendMessage()` publishes are the model's (`MessageMongo`, `MessageSQL`, `FolderMongo`). */
30
+ const MESSAGE_EVENT = /^Message/;
31
+ const FOLDER_EVENT = /^Folder/;
32
+ const MESSAGE_ACTIONS = new Set(["create", "update", "delete"]);
33
+
34
+ /**
35
+ * The channels to subscribe to, most important first (the push client keeps the first `PUSH_MAX_CHANNELS`): every
36
+ * mailbox's Inbox - where mail lands - then each mailbox's other folders in the sidebar's own order, then the mailbox uids
37
+ * themselves (which announce new folders). A folder is a channel of its own; subscribing to a mailbox does not deliver its
38
+ * folders' events.
39
+ */
40
+ export function pushChannelsFor(mailboxFolders: MailboxFolders[], mailboxes: Mailbox[]): string[] {
41
+ const inboxes = mailboxFolders.flatMap((entry) => entry.folders.filter((folder) => folder.type === "inbox"));
42
+ const others = mailboxFolders.flatMap((entry) => entry.folders.filter((folder) => folder.type !== "inbox"));
43
+ return [...new Set([...inboxes, ...others].map((folder) => folder.uid).concat(mailboxes.map((mailbox) => mailbox.uid)))];
44
+ }
45
+
46
+ function isFolder(value: unknown): value is Folder {
47
+ const folder = value as Partial<Folder> | null;
48
+ return !!folder && typeof folder.uid === "string" && typeof folder.mailboxUid === "string" && typeof folder.type === "string";
49
+ }
50
+
51
+ /** The folder a message event is about: the message's own `folderUid`, else the channel it arrived on (when the server said). */
52
+ function folderOfMessageEvent(event: PushEvent): string | undefined {
53
+ const data = event.data as { folderUid?: unknown } | null | undefined;
54
+ if (typeof data?.folderUid === "string") {
55
+ return data.folderUid;
56
+ }
57
+ return event.channel;
58
+ }
59
+
60
+ export interface UseMailLiveUpdatesOptions {
61
+ /** Nothing runs without a signed-in user. */
62
+ userUid?: string;
63
+ mailboxes: Mailbox[];
64
+ mailboxFolders: MailboxFolders[];
65
+ /** Called with a folder another client (or this one) created, so the sidebar shows it. Called only for one not already known. */
66
+ onFolderCreated: (folder: Folder) => void;
67
+ }
68
+
69
+ export interface MailLiveUpdates {
70
+ live: LiveUpdates;
71
+ /** The latest unread count of each folder that has been refreshed since load, by folder uid - what the sidebar's badges show
72
+ * instead of the count the folder list was loaded with. */
73
+ unreadCounts: Record<string, number>;
74
+ }
75
+
76
+ /**
77
+ * Keeps Mail current without a page reload. One shared push connection per tab (`getPushClient()`) is subscribed to every
78
+ * folder of every accessible mailbox; a message event for any of them, a reconnect, a poll of the safety net (every
79
+ * `LIVE_POLL_INTERVAL_MS` while the tab is visible - and the only mechanism, silently, where the socket can't connect), the
80
+ * tab coming back to the front or the browser coming back online, all end in the same debounced refresh: `live` is bumped
81
+ * (the list on screen refetches its first page - see `mergeFirstPage()`) and every folder's unread count is re-read.
82
+ *
83
+ * Sign-out closes the socket for good: another tab's or this one's, heard on the same channel `AppShell` listens on.
84
+ * Renders nothing and does nothing where there is no window (server-side rendering).
85
+ */
86
+ export function useMailLiveUpdates({ userUid, mailboxes, mailboxFolders, onFolderCreated }: UseMailLiveUpdatesOptions): MailLiveUpdates {
87
+ const [live, setLive] = useState<LiveUpdates>(NO_LIVE_UPDATES);
88
+ const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
89
+ // The latest inputs, for the long-lived listeners below.
90
+ const latestRef = useRef({ mailboxes, mailboxFolders, onFolderCreated });
91
+ latestRef.current = { mailboxes, mailboxFolders, onFolderCreated };
92
+
93
+ const channels = useMemo(() => pushChannelsFor(mailboxFolders, mailboxes), [mailboxFolders, mailboxes]);
94
+ const channelKey = channels.join("|");
95
+ const channelsRef = useRef(channels);
96
+ channelsRef.current = channels;
97
+
98
+ useEffect(() => {
99
+ if (!userUid) {
100
+ return;
101
+ }
102
+ const client = getPushClient();
103
+ let stopped = false;
104
+ let timer: ReturnType<typeof setTimeout> | undefined;
105
+ let pendingFolders = new Set<string>();
106
+ let pendingUnknown = false;
107
+ let countsRun = 0;
108
+ let everOpen = false;
109
+
110
+ async function refreshCounts() {
111
+ const run = ++countsRun;
112
+ const results = await Promise.all(latestRef.current.mailboxes.map((mailbox) => listFolders(mailbox.uid).catch(() => undefined)));
113
+ if (stopped || run !== countsRun) {
114
+ return;
115
+ }
116
+ const fresh: Record<string, number> = {};
117
+ for (const folders of results) {
118
+ for (const folder of folders ?? []) {
119
+ fresh[folder.uid] = folder.unreadCount;
120
+ }
121
+ }
122
+ setUnreadCounts((previous) => {
123
+ const changed = Object.entries(fresh).some(([uid, count]) => previous[uid] !== count);
124
+ return changed ? { ...previous, ...fresh } : previous;
125
+ });
126
+ }
127
+
128
+ // Never runs once `stopped`: whatever sets it clears the timer first.
129
+ function flush() {
130
+ timer = undefined;
131
+ const folderUids = pendingUnknown ? null : new Set(pendingFolders);
132
+ pendingFolders = new Set();
133
+ pendingUnknown = false;
134
+ setLive((previous) => ({ tick: previous.tick + 1, folderUids }));
135
+ void refreshCounts();
136
+ }
137
+
138
+ /** Asks for a refresh: of the given folders, or (no argument) of whatever may have changed. */
139
+ function schedule(folderUid?: string) {
140
+ if (stopped) {
141
+ return;
142
+ }
143
+ if (folderUid) {
144
+ pendingFolders.add(folderUid);
145
+ } else {
146
+ pendingUnknown = true;
147
+ }
148
+ timer ??= setTimeout(flush, LIVE_EVENT_DEBOUNCE_MS);
149
+ }
150
+
151
+ const offEvent = client.onEvent((event) => {
152
+ if (MESSAGE_EVENT.test(event.type) && MESSAGE_ACTIONS.has(event.action ?? "")) {
153
+ schedule(folderOfMessageEvent(event));
154
+ } else if (FOLDER_EVENT.test(event.type) && event.action === "create" && isFolder(event.data)) {
155
+ const folder = event.data;
156
+ const known = latestRef.current.mailboxFolders.some((entry) => entry.folders.some((f) => f.uid === folder.uid));
157
+ if (!known) {
158
+ latestRef.current.onFolderCreated(folder);
159
+ }
160
+ }
161
+ });
162
+ // Anything published while the socket was down is gone for good, so every reconnect is followed by a refresh.
163
+ const offStatus = client.onStatus((status) => {
164
+ if (status === "open") {
165
+ if (everOpen) {
166
+ schedule();
167
+ }
168
+ everOpen = true;
169
+ }
170
+ });
171
+
172
+ const poll = setInterval(() => {
173
+ if (document.visibilityState === "visible") {
174
+ schedule();
175
+ }
176
+ }, LIVE_POLL_INTERVAL_MS);
177
+ function refreshNow() {
178
+ if (document.visibilityState === "visible") {
179
+ client.reconnectNow();
180
+ schedule();
181
+ }
182
+ }
183
+ function handleOnline() {
184
+ client.reconnectNow();
185
+ schedule();
186
+ }
187
+ document.addEventListener("visibilitychange", refreshNow);
188
+ window.addEventListener("focus", refreshNow);
189
+ window.addEventListener("online", handleOnline);
190
+
191
+ // A sign-out, in this tab or another, ended the session behind the socket: close it for good and stop refreshing.
192
+ const signOut = typeof BroadcastChannel === "undefined" ? undefined : new BroadcastChannel(SIGN_OUT_CHANNEL);
193
+ signOut?.addEventListener("message", (event: MessageEvent<{ type?: string }>) => {
194
+ if (event.data?.type === "sign-out") {
195
+ stopped = true;
196
+ clearTimeout(timer);
197
+ client.close();
198
+ }
199
+ });
200
+
201
+ client.setChannels(channelsRef.current);
202
+ client.start();
203
+
204
+ return () => {
205
+ stopped = true;
206
+ clearTimeout(timer);
207
+ clearInterval(poll);
208
+ offEvent();
209
+ offStatus();
210
+ document.removeEventListener("visibilitychange", refreshNow);
211
+ window.removeEventListener("focus", refreshNow);
212
+ window.removeEventListener("online", handleOnline);
213
+ signOut?.close();
214
+ };
215
+ }, [userUid]);
216
+
217
+ useEffect(() => {
218
+ if (userUid) {
219
+ getPushClient().setChannels(channels);
220
+ }
221
+ }, [userUid, channelKey]);
222
+
223
+ return { live, unreadCounts };
224
+ }
@@ -44,6 +44,8 @@ import MailShell, {
44
44
  MailShellProps,
45
45
  useMailShell,
46
46
  } from "../shared/components/mail/layout/MailShell.js";
47
+ import MailAddress from "../shared/components/mail/MailAddress.js";
48
+ import { mergeFirstPage } from "../shared/mail/mergeFirstPage.js";
47
49
  import MessageDetailPane from "../shared/components/mail/MessageDetailPane.js";
48
50
  import ConversationList from "../shared/components/mail/ConversationList.js";
49
51
  import ConversationThreadPane from "../shared/components/mail/ConversationThreadPane.js";
@@ -447,7 +449,7 @@ export default function InboxPage(props: MailShellProps) {
447
449
  }
448
450
 
449
451
  function InboxContent({ userUid }: { userUid?: string }) {
450
- const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated } = useMailShell();
452
+ const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType, onFolderCreated, live } = useMailShell();
451
453
  const isMobile = useIsMobile();
452
454
  const { requestUnlock } = useUnlockPrompt();
453
455
  const [messages, setMessages] = useState<Message[]>([]);
@@ -1009,6 +1011,67 @@ function InboxContent({ userUid }: { userUid?: string }) {
1009
1011
  activeMailboxUid,
1010
1012
  ]);
1011
1013
 
1014
+ // New mail without a reload. `live` is bumped by a push event, a reconnect, the safety-net poll or the tab coming back (see
1015
+ // `useMailLiveUpdates()`); this then quietly refetches the first page of whatever is listed and folds it in - unlike the
1016
+ // effect above it resets nothing: not the selection, the open thread, select mode, the scroll position or the rows
1017
+ // already paged in, and nothing it fetches is marked read. It stands aside for a search (whose rows aren't a folder's),
1018
+ // a listing still loading and a "load more" in flight, and for events about folders the list isn't showing - a
1019
+ // conversation list, which can span folders, refreshes for any. Any failure is silent: the next tick tries again.
1020
+ const liveRunRef = useRef(0);
1021
+ useEffect(() => {
1022
+ if (live.tick === 0 || isSearching || loading || loadMoreInFlightRef.current || (!folderUid && !aggregateFolderType)) {
1023
+ return;
1024
+ }
1025
+ if (!preferences.showAsConversations && live.folderUids) {
1026
+ const shown = new Set(
1027
+ aggregateFolderType
1028
+ ? mailboxFolders.flatMap((entry) => entry.folders.filter((f) => f.type === aggregateFolderType).map((f) => f.uid))
1029
+ : [folderUid!],
1030
+ );
1031
+ if (![...live.folderUids].some((uid) => shown.has(uid))) {
1032
+ return;
1033
+ }
1034
+ }
1035
+ // Superseded by a newer refresh, or by any reload of the list itself (which bumps the search run id).
1036
+ const listRun = searchRunIdRef.current;
1037
+ const myRun = ++liveRunRef.current;
1038
+ const isCurrent = () => searchRunIdRef.current === listRun && liveRunRef.current === myRun;
1039
+ void (async () => {
1040
+ try {
1041
+ if (preferences.showAsConversations) {
1042
+ const fresh = await listConversations(activeMailboxUid, conversationParams(0));
1043
+ if (!isCurrent()) {
1044
+ return;
1045
+ }
1046
+ const merged = mergeFirstPage(conversationsRef.current, fresh, conversationKey, MESSAGE_PAGE_SIZE);
1047
+ setConversations(merged.rows);
1048
+ listedOffsetRef.current = merged.complete ? fresh.length : listedOffsetRef.current + merged.added;
1049
+ setHasMore(!merged.complete);
1050
+ } else if (aggregateFolderType) {
1051
+ // No paging here (see `fetchAggregateMessages()`): the fresh merged first pages are the list.
1052
+ const fresh = await fetchAggregateMessages(mailboxFolders, aggregateFolderType, effectiveFilter);
1053
+ if (isCurrent()) {
1054
+ setMessages(fresh);
1055
+ }
1056
+ } else {
1057
+ const fresh = await listMessages(folderUid!, listParams);
1058
+ if (!isCurrent()) {
1059
+ return;
1060
+ }
1061
+ // A row the reader has just changed (a higher version) is not put back by a fetch that began before.
1062
+ const merged = mergeFirstPage(messagesRef.current, fresh, messageUid, MESSAGE_PAGE_SIZE, (current, next) =>
1063
+ current.version > next.version ? current : next,
1064
+ );
1065
+ setMessages(merged.rows);
1066
+ listedOffsetRef.current = merged.complete ? fresh.length : listedOffsetRef.current + merged.added;
1067
+ setHasMore(!merged.complete);
1068
+ }
1069
+ } catch {
1070
+ // Quiet by design - see above.
1071
+ }
1072
+ })();
1073
+ }, [live.tick]);
1074
+
1012
1075
  function handleSearchAllMail() {
1013
1076
  setSearchAllMailKey(searchAllMailScope);
1014
1077
  }
@@ -1742,7 +1805,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
1742
1805
  ].join(" ")}
1743
1806
  >
1744
1807
  <div className="flex items-center justify-between gap-2 text-sm">
1745
- <span className="truncate">{message.from.displayName || message.from.address}</span>
1808
+ <MailAddress recipient={message.from} />
1746
1809
  <span className="text-xs text-text-muted shrink-0">
1747
1810
  {new Date(message.receivedDate).toLocaleDateString()}
1748
1811
  </span>
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The `ApiRequestError.code` a `@RequiresElevation()` endpoint answers a caller whose token isn't elevated with
3
+ * (a 403 - `ApiErrors.AUTH_REQUIRES_ELEVATION` in `@rapidrest/service-core`). Distinct from `api-103` (the caller is
4
+ * elevated but lacks the trusted role), which no amount of elevating fixes.
5
+ */
6
+ export declare const ELEVATION_REQUIRED_CODE = "api-104";
7
+ /** `sessionStorage` key holding the time (`Date.now()`) the browser was last sent to auth-server to elevate. */
8
+ export declare const ELEVATION_ATTEMPT_KEY = "rapidmx-admin-elevation-attempt";
9
+ /** How long after sending the browser to elevate a second `api-104` is taken to mean the elevation didn't take
10
+ * effect (e.g. auth-server's elevated cookie never reaching this origin), rather than a fresh need to elevate. */
11
+ export declare const ELEVATION_RETRY_WINDOW_MS: number;
12
+ /** Whether `err` is a 403 from an elevation-gated endpoint for a caller whose token isn't elevated. */
13
+ export declare function isElevationRequired(err: unknown): boolean;
14
+ /** auth-server's elevation page: it sends the browser back to `returnTo` once the user has confirmed their
15
+ * identity, or to its own account page if they cancel. */
16
+ export declare function elevationUrl(authServerUrl: string, returnTo: string): string;
17
+ /** Whether the browser was sent to elevate within `ELEVATION_RETRY_WINDOW_MS` of `now`. `false` when nothing was
18
+ * recorded, the record is unreadable or from the future, or storage is unavailable. */
19
+ export declare function elevationAttemptedRecently(now?: number): boolean;
20
+ /** Remembers, for this tab, that the browser is about to be sent to elevate. A no-op where storage is unavailable
21
+ * (then only the first-round protection is lost - elevating needs the user to confirm each time round). */
22
+ export declare function recordElevationAttempt(now?: number): void;
23
+ /** Forgets any recorded attempt - once elevation has worked, or when the user asks to try again. */
24
+ export declare function clearElevationAttempt(): void;
@@ -0,0 +1,56 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
6
+ /**
7
+ * The `ApiRequestError.code` a `@RequiresElevation()` endpoint answers a caller whose token isn't elevated with
8
+ * (a 403 - `ApiErrors.AUTH_REQUIRES_ELEVATION` in `@rapidrest/service-core`). Distinct from `api-103` (the caller is
9
+ * elevated but lacks the trusted role), which no amount of elevating fixes.
10
+ */
11
+ export const ELEVATION_REQUIRED_CODE = "api-104";
12
+ /** `sessionStorage` key holding the time (`Date.now()`) the browser was last sent to auth-server to elevate. */
13
+ export const ELEVATION_ATTEMPT_KEY = "rapidmx-admin-elevation-attempt";
14
+ /** How long after sending the browser to elevate a second `api-104` is taken to mean the elevation didn't take
15
+ * effect (e.g. auth-server's elevated cookie never reaching this origin), rather than a fresh need to elevate. */
16
+ export const ELEVATION_RETRY_WINDOW_MS = 2 * 60 * 1000;
17
+ /** Whether `err` is a 403 from an elevation-gated endpoint for a caller whose token isn't elevated. */
18
+ export function isElevationRequired(err) {
19
+ return err instanceof ApiRequestError && err.status === 403 && err.code === ELEVATION_REQUIRED_CODE;
20
+ }
21
+ /** auth-server's elevation page: it sends the browser back to `returnTo` once the user has confirmed their
22
+ * identity, or to its own account page if they cancel. */
23
+ export function elevationUrl(authServerUrl, returnTo) {
24
+ return `${authServerUrl}/auth/elevate?return_to=${encodeURIComponent(returnTo)}`;
25
+ }
26
+ /** Whether the browser was sent to elevate within `ELEVATION_RETRY_WINDOW_MS` of `now`. `false` when nothing was
27
+ * recorded, the record is unreadable or from the future, or storage is unavailable. */
28
+ export function elevationAttemptedRecently(now = Date.now()) {
29
+ try {
30
+ const recorded = Number(sessionStorage.getItem(ELEVATION_ATTEMPT_KEY));
31
+ const elapsed = now - recorded;
32
+ return recorded > 0 && elapsed >= 0 && elapsed < ELEVATION_RETRY_WINDOW_MS;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ /** Remembers, for this tab, that the browser is about to be sent to elevate. A no-op where storage is unavailable
39
+ * (then only the first-round protection is lost - elevating needs the user to confirm each time round). */
40
+ export function recordElevationAttempt(now = Date.now()) {
41
+ try {
42
+ sessionStorage.setItem(ELEVATION_ATTEMPT_KEY, String(now));
43
+ }
44
+ catch {
45
+ // See the doc comment.
46
+ }
47
+ }
48
+ /** Forgets any recorded attempt - once elevation has worked, or when the user asks to try again. */
49
+ export function clearElevationAttempt() {
50
+ try {
51
+ sessionStorage.removeItem(ELEVATION_ATTEMPT_KEY);
52
+ }
53
+ catch {
54
+ // Nothing was recorded.
55
+ }
56
+ }
@@ -24,9 +24,17 @@ export interface AdminShellProps extends PluginNavProps {
24
24
  * off-rail sections' - see `mergePluginNavItems`. */
25
25
  export declare function adminNavItems(pluginNav?: PluginNav): NavItem[];
26
26
  /**
27
- * Gates every `apps/admin` page behind the `admin` trusted role. Uses `GET /api/admin/release-notes` (any
28
- * `BaseAdminRoute` endpoint works — this one is side-effect-free) purely as a canary: a 200 means the
29
- * caller's JWT carries a trusted role, a 403 means it doesn't. There is no local step-up/elevation flow
30
- * (that would need a cross-origin call to auth-server's own elevation endpoint — not wired up yet).
27
+ * Gates every `apps/admin` page behind the `admin` trusted role, and behind an elevated session. Uses
28
+ * `GET /api/admin/release-notes` (any `BaseAdminRoute` endpoint works — this one is side-effect-free) purely as a
29
+ * canary. The endpoint is class-level `@RequiresElevation()`, checked *before* the trusted-role check, so a 200 means the
30
+ * caller's JWT is elevated and carries a trusted role: show the console. Otherwise:
31
+ *
32
+ * 403 `api-104` means the JWT isn't elevated (an administrator's normal sign-in). There is no local step-up form; the
33
+ * browser is sent to auth-server's `/auth/elevate?return_to=<this page>`, which returns it here once the user has
34
+ * confirmed their identity (or to its own account page if they cancel). Sent at most once per
35
+ * `ELEVATION_RETRY_WINDOW_MS`, so an elevated cookie that never reaches this origin can't bounce the browser back
36
+ * and forth - see `elevation.ts`, and the "didn't take effect" alert with its own "Try again" below.
37
+ *
38
+ * 403 `api-103` (elevated, but not an administrator), any other 403, and 401 mean "no administrator access".
31
39
  */
32
40
  export default function AdminShell({ active, userUid, authServerUrl, pluginNav, children }: PropsWithChildren<AdminShellProps>): React.JSX.Element;
@@ -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 () => {