@rapidmx/web-client 0.8.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.
- package/README.md +1 -1
- package/apps/shared/components/admin/elevation.ts +61 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +73 -7
- package/apps/shared/components/admin/settings/BrandingForm.tsx +2 -1
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +86 -24
- package/apps/shared/components/layout/BrandingChrome.tsx +3 -2
- package/apps/shared/components/layout/MailboxProvisioning.tsx +44 -6
- package/apps/shared/components/layout/UserMenu.tsx +48 -13
- package/apps/shared/components/mail/ConversationList.tsx +15 -6
- package/apps/shared/components/mail/ConversationThreadPane.tsx +2 -2
- package/apps/shared/components/mail/MailAddress.tsx +88 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +11 -7
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +40 -4
- package/apps/shared/components/mail/compose/SendFailureAlert.tsx +48 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +37 -10
- package/apps/shared/mail/mergeFirstPage.ts +47 -0
- package/apps/shared/mail/useMailLiveUpdates.ts +224 -0
- package/apps/www/index.tsx +65 -2
- package/dist/apps/shared/components/admin/elevation.d.ts +24 -0
- package/dist/apps/shared/components/admin/elevation.js +56 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +12 -4
- package/dist/apps/shared/components/admin/layout/AdminShell.js +49 -6
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +1 -1
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +31 -13
- package/dist/apps/shared/components/layout/BrandingChrome.d.ts +3 -2
- package/dist/apps/shared/components/layout/BrandingChrome.js +3 -2
- package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +5 -2
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +42 -7
- package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -7
- package/dist/apps/shared/components/layout/UserMenu.js +39 -13
- package/dist/apps/shared/components/mail/ConversationList.js +7 -4
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +2 -2
- package/dist/apps/shared/components/mail/MailAddress.d.ts +27 -0
- package/dist/apps/shared/components/mail/MailAddress.js +39 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +5 -3
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +25 -6
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +11 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +7 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +31 -11
- package/dist/apps/shared/mail/mergeFirstPage.d.ts +23 -0
- package/dist/apps/shared/mail/mergeFirstPage.js +30 -0
- package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +48 -0
- package/dist/apps/shared/mail/useMailLiveUpdates.js +180 -0
- package/dist/apps/www/index.js +63 -2
- package/package.json +2 -2
|
@@ -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
|
+
}
|
package/apps/www/index.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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
|
|
28
|
-
* `BaseAdminRoute` endpoint works — this one is side-effect-free) purely as a
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
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
|
|
88
|
-
* `BaseAdminRoute` endpoint works — this one is side-effect-free) purely as a
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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: [
|
|
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 () => {
|