@rapidmx/web-client 0.12.0 → 0.14.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/apps/admin/domains/[uid].tsx +73 -2
- package/apps/admin/domains/index.tsx +2 -1
- package/apps/admin/domains/new/index.tsx +28 -3
- package/apps/shared/calendar/calendarReminders.ts +76 -0
- package/apps/shared/calendar/useCalendarReminders.ts +91 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +8 -0
- package/apps/shared/components/calendar/EventModal.tsx +236 -11
- package/apps/shared/components/layout/AppShell.tsx +3 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +37 -8
- package/apps/shared/notifications/NotificationCenter.tsx +2 -0
- package/apps/shared/notifications/NotificationHistoryDialog.tsx +1 -0
- package/apps/shared/notifications/store.ts +3 -1
- package/apps/shared/search/localIndexRpcClient.ts +33 -2
- package/apps/shared/search/searchTier2.ts +34 -16
- package/apps/www/index.tsx +49 -8
- package/dist/apps/admin/domains/[uid].js +38 -2
- package/dist/apps/admin/domains/index.js +1 -1
- package/dist/apps/admin/domains/new/index.js +13 -4
- package/dist/apps/shared/calendar/calendarReminders.d.ts +28 -0
- package/dist/apps/shared/calendar/calendarReminders.js +55 -0
- package/dist/apps/shared/calendar/useCalendarReminders.d.ts +23 -0
- package/dist/apps/shared/calendar/useCalendarReminders.js +79 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +2 -2
- package/dist/apps/shared/components/calendar/EventModal.d.ts +10 -0
- package/dist/apps/shared/components/calendar/EventModal.js +186 -12
- package/dist/apps/shared/components/layout/AppShell.js +3 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +36 -8
- package/dist/apps/shared/notifications/NotificationCenter.js +2 -1
- package/dist/apps/shared/notifications/NotificationHistoryDialog.js +1 -0
- package/dist/apps/shared/notifications/store.d.ts +1 -1
- package/dist/apps/shared/notifications/store.js +2 -0
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -0
- package/dist/apps/shared/search/localIndexRpcClient.js +28 -2
- package/dist/apps/shared/search/searchTier2.d.ts +7 -1
- package/dist/apps/shared/search/searchTier2.js +34 -17
- package/dist/apps/www/index.js +37 -8
- package/package.json +2 -2
package/apps/www/index.tsx
CHANGED
|
@@ -378,6 +378,16 @@ const LOAD_MORE_ROOT_MARGIN_PX = 200;
|
|
|
378
378
|
* "Load more" button instead. */
|
|
379
379
|
const MAX_EMPTY_PAGE_CONTINUATIONS = 3;
|
|
380
380
|
|
|
381
|
+
/** Hard ceiling on how many rows one list (the plain message list or the conversation list) keeps as live
|
|
382
|
+
* React state/DOM at once. `MAX_EMPTY_PAGE_CONTINUATIONS` above only bounds a streak of *empty* pages, not
|
|
383
|
+
* the total accumulated row count - infinite scroll on its own has no natural stopping point, so a reader
|
|
384
|
+
* who fully scrolls a large mailbox would otherwise keep every row ever fetched mounted forever. Past this
|
|
385
|
+
* cap, `loadMore()` stops fetching further pages and the list shows a "refine your search" banner instead of
|
|
386
|
+
* the load-more sentinel - deliberately not virtualization (a larger, riskier change this bug's own writeup
|
|
387
|
+
* called for only if trivial, which it isn't given this file's existing structure): capping the row count is
|
|
388
|
+
* the same fix's low-risk cousin, since the unbounded-memory failure mode is identical either way. */
|
|
389
|
+
const MAX_LOADED_ROWS = 500;
|
|
390
|
+
|
|
381
391
|
/** `true` when `more` has at least one row `shown` doesn't - i.e. appending it actually adds rows. Generic
|
|
382
392
|
* over the row's own identity so both the message list (`uid`) and the conversation list
|
|
383
393
|
* (`conversationId`) page the same way. */
|
|
@@ -397,8 +407,11 @@ function isWithinLoadMoreRange(sentinel: HTMLElement, root: HTMLElement): boolea
|
|
|
397
407
|
/** Appends `more` to `shown`, skipping any row already shown - a later page can repeat rows (Tier 1 and
|
|
398
408
|
* Tier 2/3 cursors advance independently, so the same message can come back from a different tier on a
|
|
399
409
|
* later page; a plain folder listing's pages shift when new mail arrives between fetches). Generic for the
|
|
400
|
-
* same reason `hasUnseenRows()` is.
|
|
401
|
-
|
|
410
|
+
* same reason `hasUnseenRows()` is.
|
|
411
|
+
*
|
|
412
|
+
* `cap`, when given, truncates the result to its first `cap` rows (dropping the tail) rather than letting
|
|
413
|
+
* accumulated pages grow without bound - see `MAX_LOADED_ROWS`'s own doc comment for why this exists. */
|
|
414
|
+
function appendUnseenRows<T>(shown: T[], more: T[], idOf: (row: T) => string, cap?: number): T[] {
|
|
402
415
|
const seen = new Set(shown.map(idOf));
|
|
403
416
|
const unseen: T[] = [];
|
|
404
417
|
for (const row of more) {
|
|
@@ -407,7 +420,11 @@ function appendUnseenRows<T>(shown: T[], more: T[], idOf: (row: T) => string): T
|
|
|
407
420
|
unseen.push(row);
|
|
408
421
|
}
|
|
409
422
|
}
|
|
410
|
-
|
|
423
|
+
if (unseen.length === 0) {
|
|
424
|
+
return shown;
|
|
425
|
+
}
|
|
426
|
+
const combined = [...shown, ...unseen];
|
|
427
|
+
return cap !== undefined && combined.length > cap ? combined.slice(0, cap) : combined;
|
|
411
428
|
}
|
|
412
429
|
|
|
413
430
|
const messageUid = (message: Message) => message.uid;
|
|
@@ -1209,10 +1226,15 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1209
1226
|
// has fully finished (the ref may still hold an earlier query's), in which case there's nowhere to
|
|
1210
1227
|
// continue from yet.
|
|
1211
1228
|
const searchCursor = decodeCursor(compositeCursorRef.current, fingerprint);
|
|
1229
|
+
// Reads the ref, not `conversations`/`messages` state, so this callback (and the observer effect
|
|
1230
|
+
// watching it) don't need to be recreated every time a page lands - the same reason every other
|
|
1231
|
+
// check below reads a ref rather than closing over state.
|
|
1232
|
+
const atRowCap = (preferences.showAsConversations ? conversationsRef.current.length : messagesRef.current.length) >= MAX_LOADED_ROWS;
|
|
1212
1233
|
if (
|
|
1213
1234
|
loadMoreInFlightRef.current ||
|
|
1214
1235
|
!hasMore ||
|
|
1215
1236
|
loading ||
|
|
1237
|
+
atRowCap ||
|
|
1216
1238
|
(!preferences.showAsConversations && !folderUid) ||
|
|
1217
1239
|
(isSearching && !searchCursor)
|
|
1218
1240
|
) {
|
|
@@ -1251,7 +1273,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1251
1273
|
return;
|
|
1252
1274
|
}
|
|
1253
1275
|
const addedRows = hasUnseenRows(conversationsRef.current, more, conversationKey);
|
|
1254
|
-
setConversations((prev) => appendUnseenRows(prev, more, conversationKey));
|
|
1276
|
+
setConversations((prev) => appendUnseenRows(prev, more, conversationKey, MAX_LOADED_ROWS));
|
|
1255
1277
|
setHasMore(more.length === MESSAGE_PAGE_SIZE);
|
|
1256
1278
|
listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
|
|
1257
1279
|
notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
|
|
@@ -1280,7 +1302,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1280
1302
|
return;
|
|
1281
1303
|
}
|
|
1282
1304
|
const addedRows = hasUnseenRows(messagesRef.current, more, messageUid);
|
|
1283
|
-
setMessages((prev) => appendUnseenRows(prev, more, messageUid));
|
|
1305
|
+
setMessages((prev) => appendUnseenRows(prev, more, messageUid, MAX_LOADED_ROWS));
|
|
1284
1306
|
setSnippets((prev) => ({ ...prev, ...moreSnippets }));
|
|
1285
1307
|
|
|
1286
1308
|
const nextTier3Offset = tier3Offset + tier3Page.length;
|
|
@@ -1304,7 +1326,7 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1304
1326
|
return;
|
|
1305
1327
|
}
|
|
1306
1328
|
const addedRows = hasUnseenRows(messagesRef.current, more, messageUid);
|
|
1307
|
-
setMessages((prev) => appendUnseenRows(prev, more, messageUid));
|
|
1329
|
+
setMessages((prev) => appendUnseenRows(prev, more, messageUid, MAX_LOADED_ROWS));
|
|
1308
1330
|
setHasMore(more.length === MESSAGE_PAGE_SIZE);
|
|
1309
1331
|
listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
|
|
1310
1332
|
notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
|
|
@@ -1461,6 +1483,15 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
1461
1483
|
* arrived, so paging keeps appending to the same accumulated set. */
|
|
1462
1484
|
const listedConversations = sortConversations(conversations, preferences.sortBy, preferences.sortOrder);
|
|
1463
1485
|
|
|
1486
|
+
/** `true` once whichever list is on screen has hit `MAX_LOADED_ROWS` *and* the server still has more to
|
|
1487
|
+
* give - `appendUnseenRows()` itself already stopped growing the array at that point, and `loadMore()`
|
|
1488
|
+
* refuses to fetch further pages (see its own `atRowCap` check); this just drives the "refine your
|
|
1489
|
+
* search" banner replacing the load-more sentinel below. The `hasMore` half matters at the edge: a
|
|
1490
|
+
* mailbox/search whose true size lands at or near the cap can have its very last page be a partial one,
|
|
1491
|
+
* which correctly turns `hasMore` false - without checking it here, the banner would still claim rows
|
|
1492
|
+
* are being hidden even though every one of them is already on screen. */
|
|
1493
|
+
const rowCapReached = hasMore && (preferences.showAsConversations ? conversations.length : messages.length) >= MAX_LOADED_ROWS;
|
|
1494
|
+
|
|
1464
1495
|
function leaveSelectMode() {
|
|
1465
1496
|
setSelectMode(false);
|
|
1466
1497
|
setSelectedUids(new Set());
|
|
@@ -2112,11 +2143,16 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2112
2143
|
selectedConversationIds={selectedConversationIds}
|
|
2113
2144
|
onToggleSelected={toggleConversationSelected}
|
|
2114
2145
|
/>
|
|
2115
|
-
{hasMore && (
|
|
2146
|
+
{hasMore && !rowCapReached && (
|
|
2116
2147
|
<div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
|
|
2117
2148
|
{loadMoreStatus}
|
|
2118
2149
|
</div>
|
|
2119
2150
|
)}
|
|
2151
|
+
{rowCapReached && (
|
|
2152
|
+
<p className="p-4 text-center text-xs text-text-muted">
|
|
2153
|
+
Showing the most recent {MAX_LOADED_ROWS} conversations — refine your search or filters to see more.
|
|
2154
|
+
</p>
|
|
2155
|
+
)}
|
|
2120
2156
|
</>
|
|
2121
2157
|
) : messages.length === 0 ? (
|
|
2122
2158
|
<>
|
|
@@ -2213,11 +2249,16 @@ function InboxContent({ userUid }: { userUid?: string }) {
|
|
|
2213
2249
|
</li>
|
|
2214
2250
|
))}
|
|
2215
2251
|
</ul>
|
|
2216
|
-
{hasMore && (
|
|
2252
|
+
{hasMore && !rowCapReached && (
|
|
2217
2253
|
<div ref={setSentinel} data-testid="load-more-sentinel" className="p-4 text-center text-xs text-text-muted">
|
|
2218
2254
|
{loadMoreStatus}
|
|
2219
2255
|
</div>
|
|
2220
2256
|
)}
|
|
2257
|
+
{rowCapReached && (
|
|
2258
|
+
<p className="p-4 text-center text-xs text-text-muted">
|
|
2259
|
+
Showing the most recent {MAX_LOADED_ROWS} messages — refine your search or filters to see more.
|
|
2260
|
+
</p>
|
|
2261
|
+
)}
|
|
2221
2262
|
{aggregateFolderType && (
|
|
2222
2263
|
<p className="p-4 text-center text-xs text-text-muted">
|
|
2223
2264
|
Showing the most recent mail from each mailbox. Open a specific mailbox’s folder to
|
|
@@ -5,12 +5,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { useState } from "react";
|
|
7
7
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
-
import { deleteDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
|
|
8
|
+
import { deleteDomain, updateDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
|
|
9
9
|
import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
|
|
10
10
|
import DomainDnsSetup from "../../shared/components/admin/settings/DomainDnsSetup.js";
|
|
11
11
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
12
12
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
13
|
+
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
13
14
|
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
15
|
+
const INPUT_CLASS = "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
14
16
|
export default function DomainDetailPage(props) {
|
|
15
17
|
return (_jsx(AdminShell, { ...props, active: "domains", children: _jsx(DomainDetailContent, { uid: props.params.uid }) }));
|
|
16
18
|
}
|
|
@@ -19,6 +21,15 @@ function DomainDetailContent({ uid }) {
|
|
|
19
21
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
|
20
22
|
const [deleting, setDeleting] = useState(false);
|
|
21
23
|
const [deleteError, setDeleteError] = useState(null);
|
|
24
|
+
// Not initialized from `domain.aliasOf` until it first loads (`null` means "not yet") - once set, kept as
|
|
25
|
+
// the user's own edit rather than fighting later unrelated reloads (e.g. after "Verify now").
|
|
26
|
+
const [aliasInput, setAliasInput] = useState(null);
|
|
27
|
+
const [savingAlias, setSavingAlias] = useState(false);
|
|
28
|
+
const [aliasError, setAliasError] = useState(null);
|
|
29
|
+
const [aliasSaved, setAliasSaved] = useState(false);
|
|
30
|
+
// Bumped after a successful aliasOf save to force `DomainDnsSetup` (which owns its own `domain` state,
|
|
31
|
+
// fetched only on mount/uid change) to reload and reflect the new value in its own status panel.
|
|
32
|
+
const [refreshCount, setRefreshCount] = useState(0);
|
|
22
33
|
function closeDeleteModal() {
|
|
23
34
|
setConfirmingDelete(false);
|
|
24
35
|
}
|
|
@@ -35,5 +46,30 @@ function DomainDetailContent({ uid }) {
|
|
|
35
46
|
setDeleting(false);
|
|
36
47
|
}
|
|
37
48
|
}
|
|
38
|
-
|
|
49
|
+
function handleLoaded(d) {
|
|
50
|
+
setDomain(d);
|
|
51
|
+
setAliasInput((current) => (current === null ? d?.aliasOf ?? "" : current));
|
|
52
|
+
}
|
|
53
|
+
async function handleSaveAlias() {
|
|
54
|
+
setSavingAlias(true);
|
|
55
|
+
setAliasError(null);
|
|
56
|
+
setAliasSaved(false);
|
|
57
|
+
try {
|
|
58
|
+
const updated = await updateDomain({ uid: domain.uid, version: domain.version, aliasOf: aliasInput.trim() || undefined });
|
|
59
|
+
setDomain(updated);
|
|
60
|
+
setAliasInput(updated.aliasOf ?? "");
|
|
61
|
+
setAliasSaved(true);
|
|
62
|
+
setRefreshCount((c) => c + 1);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
setAliasError(err instanceof ApiRequestError ? err.message : "Could not update this domain.");
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
setSavingAlias(false);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [domain && (_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin/domains", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All domains" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: domain.name })] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0 !border-danger !text-danger hover:!border-danger hover:!text-danger", onClick: () => setConfirmingDelete(true), children: "Delete domain" })] })), _jsx(DomainDnsSetup, { uid: uid, onLoaded: handleLoaded }, refreshCount), domain && (_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: "Alias" }), _jsx("p", { className: "text-sm text-text-muted mb-3", children: "Make this domain a pure alias of another one - it will still receive (and may send as) mail for the same addresses, but have no mailboxes of its own. Leave blank for a regular domain." }), aliasError && _jsx(Alert, { children: aliasError }), _jsx(FormField, { label: "Alias of", htmlFor: "aliasOf", children: _jsx("input", { id: "aliasOf", type: "text", className: INPUT_CLASS, value: aliasInput ?? "", onChange: (e) => {
|
|
72
|
+
setAliasInput(e.target.value);
|
|
73
|
+
setAliasSaved(false);
|
|
74
|
+
}, placeholder: "e.g. powerlevel.gg" }) }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: savingAlias, disabled: savingAlias || (aliasInput ?? "") === (domain.aliasOf ?? ""), onClick: handleSaveAlias, children: "Save" }), aliasSaved && _jsx("span", { className: "text-sm text-text-muted", children: "Saved." })] })] })), domain && (_jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete domain", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Are you sure you want to delete ", _jsx("strong", { children: domain.name }), "? This cannot be undone."] }), deleteError && _jsx(Alert, { children: deleteError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: deleting, onClick: closeDeleteModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: deleting, disabled: deleting, onClick: handleDelete, children: "Delete" })] })] }))] }));
|
|
39
75
|
}
|
|
@@ -27,5 +27,5 @@ function DomainsListContent() {
|
|
|
27
27
|
.finally(() => setLoading(false));
|
|
28
28
|
}, [page]);
|
|
29
29
|
const hasNextPage = domains.length === PAGE_SIZE;
|
|
30
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between mb-5", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Domains" }), _jsx("a", { href: "/admin/domains/new", children: _jsx(Button, { type: "button", className: "!w-auto", children: "+ New domain" }) })] }), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : domains.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No domains configured yet." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Name", "Enabled", "Verified", ""].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: domains.map((domain) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.name }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.enabled ? "Yes" : "No" }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", 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("td", { className: "py-2.5 px-2.5 border-b border-border text-right", children: _jsx("a", { href: `/admin/domains/${encodeURIComponent(domain.uid)}`, className: "text-primary-dark hover:underline font-medium", children: "View" }) })] }, domain.uid))) })] }) })), _jsxs("div", { className: "flex gap-3 items-center mt-4", children: [_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: page === 0 || loading, onClick: () => setPage((p) => Math.max(0, p - 1)), children: "Previous" }), _jsxs("span", { className: "text-sm text-text-muted", children: ["Page ", page + 1] }), _jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: !hasNextPage || loading, onClick: () => setPage((p) => p + 1), children: "Next" })] })] }));
|
|
30
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between mb-5", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Domains" }), _jsx("a", { href: "/admin/domains/new", children: _jsx(Button, { type: "button", className: "!w-auto", children: "+ New domain" }) })] }), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : domains.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No domains configured yet." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Name", "Enabled", "Verified", "Alias of", ""].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: domains.map((domain) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.name }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: domain.enabled ? "Yes" : "No" }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", 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("td", { className: "py-2.5 px-2.5 border-b border-border text-text-muted", children: domain.aliasOf || "—" }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-right", children: _jsx("a", { href: `/admin/domains/${encodeURIComponent(domain.uid)}`, className: "text-primary-dark hover:underline font-medium", children: "View" }) })] }, domain.uid))) })] }) })), _jsxs("div", { className: "flex gap-3 items-center mt-4", children: [_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: page === 0 || loading, onClick: () => setPage((p) => Math.max(0, p - 1)), children: "Previous" }), _jsxs("span", { className: "text-sm text-text-muted", children: ["Page ", page + 1] }), _jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: !hasNextPage || loading, onClick: () => setPage((p) => p + 1), children: "Next" })] })] }));
|
|
31
31
|
}
|
|
@@ -3,9 +3,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
-
import { useState } from "react";
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
7
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
-
import { createDomain } from "@rapidmx/react-shared/admin/domainsApi.js";
|
|
8
|
+
import { createDomain, listDomains } from "@rapidmx/react-shared/admin/domainsApi.js";
|
|
9
9
|
import AdminShell from "../../../shared/components/admin/layout/AdminShell.js";
|
|
10
10
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
11
11
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
@@ -16,8 +16,17 @@ export default function NewDomainPage(props) {
|
|
|
16
16
|
}
|
|
17
17
|
function NewDomainForm() {
|
|
18
18
|
const [name, setName] = useState("");
|
|
19
|
+
const [aliasOf, setAliasOf] = useState("");
|
|
20
|
+
// Only an existing, non-alias domain can be aliased (no chains) - see `Domain.aliasOf`'s own doc comment
|
|
21
|
+
// on the `@rapidmx/restapi` side.
|
|
22
|
+
const [primaryDomains, setPrimaryDomains] = useState([]);
|
|
19
23
|
const [error, setError] = useState(null);
|
|
20
24
|
const [saving, setSaving] = useState(false);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
listDomains({ limit: 200 })
|
|
27
|
+
.then((domains) => setPrimaryDomains(domains.filter((d) => !d.aliasOf)))
|
|
28
|
+
.catch(() => setPrimaryDomains([]));
|
|
29
|
+
}, []);
|
|
21
30
|
async function handleSubmit(e) {
|
|
22
31
|
e.preventDefault();
|
|
23
32
|
setError(null);
|
|
@@ -27,7 +36,7 @@ function NewDomainForm() {
|
|
|
27
36
|
}
|
|
28
37
|
setSaving(true);
|
|
29
38
|
try {
|
|
30
|
-
const domain = await createDomain({ name: name.trim() });
|
|
39
|
+
const domain = await createDomain({ name: name.trim(), aliasOf: aliasOf || undefined });
|
|
31
40
|
window.location.href = `/admin/domains/${encodeURIComponent(domain.uid)}`;
|
|
32
41
|
}
|
|
33
42
|
catch (err) {
|
|
@@ -37,5 +46,5 @@ function NewDomainForm() {
|
|
|
37
46
|
setSaving(false);
|
|
38
47
|
}
|
|
39
48
|
}
|
|
40
|
-
return (_jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "New domain" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "A new domain starts unverified. Once created, its detail page shows the DNS TXT record to publish to prove ownership before mail can be accepted on it." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, className: "bg-surface border border-border rounded-md p-6", children: [_jsx(FormField, { label: "Domain name", htmlFor: "name", children: _jsx("input", { id: "name", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value), placeholder: "example.com" }) }), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Create domain" }), _jsx("a", { href: "/admin/domains", children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", children: "Cancel" }) })] })] })] }));
|
|
49
|
+
return (_jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "New domain" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "A new domain starts unverified. Once created, its detail page shows the DNS TXT record to publish to prove ownership before mail can be accepted on it." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, className: "bg-surface border border-border rounded-md p-6", children: [_jsx(FormField, { label: "Domain name", htmlFor: "name", children: _jsx("input", { id: "name", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value), placeholder: "example.com" }) }), _jsxs(FormField, { label: "Alias of", htmlFor: "aliasOf", children: [_jsxs("select", { id: "aliasOf", className: INPUT_CLASS, value: aliasOf, onChange: (e) => setAliasOf(e.target.value), children: [_jsx("option", { value: "", children: "None - a regular domain with mailboxes of its own" }), primaryDomains.map((domain) => (_jsx("option", { value: domain.name, children: domain.name }, domain.uid)))] }), _jsx("p", { className: "text-xs text-text-muted mt-1.5", children: "A pure alias receives (and may send as) mail for the same addresses as the domain it aliases, but has no mailboxes of its own - it still needs its own DNS setup below." })] }), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Create domain" }), _jsx("a", { href: "/admin/domains", children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", children: "Cancel" }) })] })] })] }));
|
|
41
50
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Pure helpers behind `useCalendarReminders.ts` - kept apart from the hook so the push-payload parsing, the snooze
|
|
2
|
+
* arithmetic and the pop-up's wording are each testable without a React tree or a socket. */
|
|
3
|
+
import type { PushEvent } from "@rapidmx/react-shared/mail/pushClient.js";
|
|
4
|
+
/** How long "Snooze" waits before the reminder reappears - or less, if the meeting starts sooner (`snoozeDelayMs()`). */
|
|
5
|
+
export declare const SNOOZE_MS: number;
|
|
6
|
+
/** The payload restapi's `CalendarReminderJob` publishes (`{ eventUid, title, startDate, location }`) as a `"CalendarEvent"`/`"reminder"` push event. */
|
|
7
|
+
export interface CalendarReminderNotice {
|
|
8
|
+
eventUid: string;
|
|
9
|
+
title: string;
|
|
10
|
+
/** The due occurrence's own start (an ISO string) - the series' `startDate` for a non-recurring event, one occurrence's for a recurring one. */
|
|
11
|
+
startDate: string;
|
|
12
|
+
/** The event's own `location`, verbatim - `undefined`/`null` when the event has none. Not necessarily a URL;
|
|
13
|
+
* see `joinMeetingUrl()` for when it's shown as a join link. */
|
|
14
|
+
location?: string | null;
|
|
15
|
+
}
|
|
16
|
+
/** A `notice.location` worth offering as a one-click "Join Meeting" link - a plain `http(s)://` URL and nothing
|
|
17
|
+
* else (a room name, an address, free-text instructions are all real, common `location` values this must not
|
|
18
|
+
* treat as a link). Trimmed first, so incidental whitespace never hides a real link. */
|
|
19
|
+
export declare function joinMeetingUrl(location: string | null | undefined): string | undefined;
|
|
20
|
+
/** The reminder `event` carries, or `undefined` for anything else the shared push connection delivers (mail, folders, appearance, ...). */
|
|
21
|
+
export declare function calendarReminderOf(event: PushEvent): CalendarReminderNotice | undefined;
|
|
22
|
+
/** How long "Snooze" waits before showing the reminder again: `SNOOZE_MS`, or less if the meeting starts first - never negative (snoozing a
|
|
23
|
+
* reminder for a meeting that has already started shows it again at once rather than waiting a further five minutes). */
|
|
24
|
+
export declare function snoozeDelayMs(startDate: string, now?: number): number;
|
|
25
|
+
/** The pop-up's message line: when the meeting starts, relative to `now`. */
|
|
26
|
+
export declare function reminderMessage(startDate: string, now?: number): string;
|
|
27
|
+
/** The one notification id a reminder, and every "Snooze" of it, reuses - so snoozing replaces the same pop-up rather than stacking a new one. */
|
|
28
|
+
export declare function reminderNotificationId(eventUid: string, startDate: string): string;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/** Pure helpers behind `useCalendarReminders.ts` - kept apart from the hook so the push-payload parsing, the snooze
|
|
6
|
+
* arithmetic and the pop-up's wording are each testable without a React tree or a socket. */
|
|
7
|
+
/** How long "Snooze" waits before the reminder reappears - or less, if the meeting starts sooner (`snoozeDelayMs()`). */
|
|
8
|
+
export const SNOOZE_MS = 5 * 60000;
|
|
9
|
+
function isReminderNotice(data) {
|
|
10
|
+
const notice = data;
|
|
11
|
+
return (!!notice &&
|
|
12
|
+
typeof notice.eventUid === "string" &&
|
|
13
|
+
typeof notice.title === "string" &&
|
|
14
|
+
typeof notice.startDate === "string" &&
|
|
15
|
+
(notice.location === undefined || notice.location === null || typeof notice.location === "string"));
|
|
16
|
+
}
|
|
17
|
+
/** A `notice.location` worth offering as a one-click "Join Meeting" link - a plain `http(s)://` URL and nothing
|
|
18
|
+
* else (a room name, an address, free-text instructions are all real, common `location` values this must not
|
|
19
|
+
* treat as a link). Trimmed first, so incidental whitespace never hides a real link. */
|
|
20
|
+
export function joinMeetingUrl(location) {
|
|
21
|
+
const trimmed = location?.trim();
|
|
22
|
+
if (!trimmed) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
let url;
|
|
26
|
+
try {
|
|
27
|
+
url = new URL(trimmed);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return url.protocol === "http:" || url.protocol === "https:" ? trimmed : undefined;
|
|
33
|
+
}
|
|
34
|
+
/** The reminder `event` carries, or `undefined` for anything else the shared push connection delivers (mail, folders, appearance, ...). */
|
|
35
|
+
export function calendarReminderOf(event) {
|
|
36
|
+
return event.type === "CalendarEvent" && event.action === "reminder" && isReminderNotice(event.data) ? event.data : undefined;
|
|
37
|
+
}
|
|
38
|
+
/** How long "Snooze" waits before showing the reminder again: `SNOOZE_MS`, or less if the meeting starts first - never negative (snoozing a
|
|
39
|
+
* reminder for a meeting that has already started shows it again at once rather than waiting a further five minutes). */
|
|
40
|
+
export function snoozeDelayMs(startDate, now = Date.now()) {
|
|
41
|
+
return Math.max(0, Math.min(SNOOZE_MS, new Date(startDate).getTime() - now));
|
|
42
|
+
}
|
|
43
|
+
/** The pop-up's message line: when the meeting starts, relative to `now`. */
|
|
44
|
+
export function reminderMessage(startDate, now = Date.now()) {
|
|
45
|
+
const time = new Date(startDate).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
46
|
+
const diffMinutes = Math.round((new Date(startDate).getTime() - now) / 60000);
|
|
47
|
+
if (diffMinutes <= 0) {
|
|
48
|
+
return `Starting now - ${time}`;
|
|
49
|
+
}
|
|
50
|
+
return `Starting in ${diffMinutes} minute${diffMinutes === 1 ? "" : "s"} - ${time}`;
|
|
51
|
+
}
|
|
52
|
+
/** The one notification id a reminder, and every "Snooze" of it, reuses - so snoozing replaces the same pop-up rather than stacking a new one. */
|
|
53
|
+
export function reminderNotificationId(eventUid, startDate) {
|
|
54
|
+
return `calendar-reminder:${eventUid}:${startDate}`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Where a reminder's title leads. There is no per-event deep link yet (the calendar opens an event by local state, not a URL - see
|
|
2
|
+
* `apps/www/calendar/index.tsx`'s `openEvent()`), so every reminder opens the calendar itself. */
|
|
3
|
+
export declare const CALENDAR_HREF = "/calendar";
|
|
4
|
+
export interface UseCalendarRemindersOptions {
|
|
5
|
+
/** Nothing is shown without a signed-in user. */
|
|
6
|
+
userUid?: string;
|
|
7
|
+
/** Runs only while true - the persistent app frame turns it on, like `useMailConnection`'s identical option. */
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Pop-up meeting reminders. restapi's `CalendarReminderJob` fires a `"CalendarEvent"`/`"reminder"` push event (`{ eventUid, title, startDate }`)
|
|
12
|
+
* when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open
|
|
13
|
+
* (`getPushClient()`) - every accessible mailbox's own channel is already subscribed (`useMailLiveUpdates`'s `pushChannelsFor()`, and the
|
|
14
|
+
* event is published to the mailbox channel as well as the folder's own), so this hook only has to listen, never to manage channels of its
|
|
15
|
+
* own. Lives in the persistent app frame (`AppShell`), like `useSigningEnrollmentWatcher`, so a reminder shows wherever the user is signed in -
|
|
16
|
+
* Mail, Calendar, Contacts, Settings.
|
|
17
|
+
*
|
|
18
|
+
* Each reminder is a sticky pop-up (`notify()`, kind `"calendar"`) with two actions: **Dismiss** closes it and does nothing else. **Snooze**
|
|
19
|
+
* closes it and shows it again - reusing the same notification id, so it replaces rather than stacks - after `SNOOZE_MS` (five minutes), or
|
|
20
|
+
* sooner if the meeting starts first (`snoozeDelayMs()`). A snooze is this tab's own `setTimeout`, cleared on unmount or sign-out; like the
|
|
21
|
+
* reminder itself (the push event is never replayed), it does not survive the tab closing.
|
|
22
|
+
*/
|
|
23
|
+
export declare function useCalendarReminders({ userUid, enabled }: UseCalendarRemindersOptions): void;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect } from "react";
|
|
6
|
+
import { getPushClient } from "@rapidmx/react-shared/mail/pushClient.js";
|
|
7
|
+
import { notify } from "../notifications/store.js";
|
|
8
|
+
import { calendarReminderOf, joinMeetingUrl, reminderMessage, reminderNotificationId, snoozeDelayMs } from "./calendarReminders.js";
|
|
9
|
+
/** Where a reminder's title leads. There is no per-event deep link yet (the calendar opens an event by local state, not a URL - see
|
|
10
|
+
* `apps/www/calendar/index.tsx`'s `openEvent()`), so every reminder opens the calendar itself. */
|
|
11
|
+
export const CALENDAR_HREF = "/calendar";
|
|
12
|
+
/**
|
|
13
|
+
* Pop-up meeting reminders. restapi's `CalendarReminderJob` fires a `"CalendarEvent"`/`"reminder"` push event (`{ eventUid, title, startDate }`)
|
|
14
|
+
* when an event's `reminderMinutesBeforeStart` comes due, over the same shared push connection Mail already keeps open
|
|
15
|
+
* (`getPushClient()`) - every accessible mailbox's own channel is already subscribed (`useMailLiveUpdates`'s `pushChannelsFor()`, and the
|
|
16
|
+
* event is published to the mailbox channel as well as the folder's own), so this hook only has to listen, never to manage channels of its
|
|
17
|
+
* own. Lives in the persistent app frame (`AppShell`), like `useSigningEnrollmentWatcher`, so a reminder shows wherever the user is signed in -
|
|
18
|
+
* Mail, Calendar, Contacts, Settings.
|
|
19
|
+
*
|
|
20
|
+
* Each reminder is a sticky pop-up (`notify()`, kind `"calendar"`) with two actions: **Dismiss** closes it and does nothing else. **Snooze**
|
|
21
|
+
* closes it and shows it again - reusing the same notification id, so it replaces rather than stacks - after `SNOOZE_MS` (five minutes), or
|
|
22
|
+
* sooner if the meeting starts first (`snoozeDelayMs()`). A snooze is this tab's own `setTimeout`, cleared on unmount or sign-out; like the
|
|
23
|
+
* reminder itself (the push event is never replayed), it does not survive the tab closing.
|
|
24
|
+
*/
|
|
25
|
+
export function useCalendarReminders({ userUid, enabled }) {
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!enabled || !userUid) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const timers = new Set();
|
|
31
|
+
function show(notice) {
|
|
32
|
+
const joinUrl = joinMeetingUrl(notice.location);
|
|
33
|
+
notify({
|
|
34
|
+
id: reminderNotificationId(notice.eventUid, notice.startDate),
|
|
35
|
+
kind: "calendar",
|
|
36
|
+
title: notice.title,
|
|
37
|
+
message: reminderMessage(notice.startDate),
|
|
38
|
+
href: CALENDAR_HREF,
|
|
39
|
+
sticky: true,
|
|
40
|
+
actions: [
|
|
41
|
+
...(joinUrl
|
|
42
|
+
? [
|
|
43
|
+
{
|
|
44
|
+
label: "Join Meeting",
|
|
45
|
+
onClick: () => window.open(joinUrl, "_blank", "noopener,noreferrer"),
|
|
46
|
+
// Joining isn't resolving the reminder - Dismiss/Snooze are still there afterward.
|
|
47
|
+
keepOpen: true,
|
|
48
|
+
},
|
|
49
|
+
]
|
|
50
|
+
: []),
|
|
51
|
+
{ label: "Dismiss" },
|
|
52
|
+
{
|
|
53
|
+
label: "Snooze",
|
|
54
|
+
onClick: () => {
|
|
55
|
+
const timer = setTimeout(() => {
|
|
56
|
+
timers.delete(timer);
|
|
57
|
+
show(notice);
|
|
58
|
+
}, snoozeDelayMs(notice.startDate));
|
|
59
|
+
timers.add(timer);
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const offEvent = getPushClient().onEvent((event) => {
|
|
66
|
+
const notice = calendarReminderOf(event);
|
|
67
|
+
if (notice) {
|
|
68
|
+
show(notice);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
return () => {
|
|
72
|
+
offEvent();
|
|
73
|
+
for (const timer of timers) {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
}
|
|
76
|
+
timers.clear();
|
|
77
|
+
};
|
|
78
|
+
}, [enabled, userUid]);
|
|
79
|
+
}
|
|
@@ -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
|
|
@@ -103,7 +103,7 @@ export default function DomainDnsSetup({ uid, onLoaded }) {
|
|
|
103
103
|
const ownership = dnsSetup.find((c) => c.type === "ownership");
|
|
104
104
|
const ownershipValue = ownership?.recommendedValue ?? `rapidmx-domain-verification=${domain.verificationToken}`;
|
|
105
105
|
const ownershipName = ownership?.recordName || domain.name;
|
|
106
|
-
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] }), RECORD_TYPE_HELP[check.type] && (_jsx("div", { className: "text-xs text-text-muted mt-1 max-w-xs", children: RECORD_TYPE_HELP[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 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."] })] }))] }));
|
|
106
|
+
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.aliasOf && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Alias of" }), _jsxs("dd", { children: [domain.aliasOf, " ", _jsx("span", { className: "text-xs text-text-muted", children: "(no mailboxes of its own)" })] })] }))] }), !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] }), RECORD_TYPE_HELP[check.type] && (_jsx("div", { className: "text-xs text-text-muted mt-1 max-w-xs", children: RECORD_TYPE_HELP[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 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."] })] }))] }));
|
|
107
107
|
}
|
|
108
108
|
/** The value cell of a checklist row: an MX value as its two form fields (priority and mail server), a SRV value as
|
|
109
109
|
* its four form fields (priority, weight, port and target), anything else whole. */
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
3
3
|
import { CalendarOccurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
|
|
4
|
+
/**
|
|
5
|
+
* The Location text this modal writes when it mints a video meeting for an event, and the one value it will
|
|
6
|
+
* clear again when video conferencing is turned back off (see `applyVideoConferencing()`).
|
|
7
|
+
*
|
|
8
|
+
* Deliberately generic and identical for everyone: each attendee's own personal join link is substituted into
|
|
9
|
+
* their own copy of the invitation server-side (`@rapidmx/restapi`'s `MeetingSchedulingJob`), so no link -
|
|
10
|
+
* personal or otherwise - may be stored on the shared event itself, where every attendee would read the same
|
|
11
|
+
* one.
|
|
12
|
+
*/
|
|
13
|
+
export declare const VIDEO_LOCATION_PLACEHOLDER = "Video call \u2014 link in this invitation";
|
|
4
14
|
export interface EventModalProps {
|
|
5
15
|
open: boolean;
|
|
6
16
|
onClose: () => void;
|