@rapidmx/web-client 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/data-requests/index.tsx +480 -485
  3. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  4. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  5. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  6. package/apps/admin/index.tsx +19 -1
  7. package/apps/admin/ingest-queue/index.tsx +44 -5
  8. package/apps/admin/mailboxes/[uid].tsx +211 -184
  9. package/apps/admin/quarantine/index.tsx +46 -14
  10. package/apps/admin/transport-rules/[uid].tsx +30 -1
  11. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  12. package/apps/admin/transport-rules/index.tsx +24 -6
  13. package/apps/admin/transport-rules/new/index.tsx +30 -1
  14. package/apps/escrow/_layout.tsx +2 -3
  15. package/apps/escrow/audit-log/index.tsx +196 -168
  16. package/apps/escrow/matters/[uid].tsx +621 -521
  17. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  18. package/apps/shared/components/admin/layout/AdminShell.tsx +3 -1
  19. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  20. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  21. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  22. package/apps/shared/components/admin/settings/BrandingForm.tsx +15 -4
  23. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +18 -7
  24. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +10 -2
  25. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +6 -0
  26. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +45 -12
  27. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -595
  28. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -98
  29. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +49 -13
  30. package/apps/shared/components/admin/setup/SetupWizard.tsx +150 -34
  31. package/apps/shared/components/admin/signOut.ts +79 -0
  32. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  33. package/apps/shared/components/calendar/EventModal.tsx +717 -549
  34. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  35. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  36. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  37. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  38. package/apps/shared/components/calendar/allDay.ts +124 -0
  39. package/apps/shared/components/calendar/layout/CalendarShell.tsx +1 -1
  40. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  41. package/apps/shared/components/contacts/ContactForm.tsx +377 -348
  42. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  43. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  44. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  45. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  46. package/apps/shared/components/layout/AppShell.tsx +87 -15
  47. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  48. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +389 -298
  49. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  50. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  51. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -128
  52. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  53. package/apps/shared/components/mail/MessageDetailPane.tsx +1318 -630
  54. package/apps/shared/components/mail/compose/ComposeContext.tsx +20 -15
  55. package/apps/shared/components/mail/compose/ComposeWindow.tsx +854 -132
  56. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  57. package/apps/shared/components/mail/layout/MailShell.tsx +12 -2
  58. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  59. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  60. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  61. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  62. package/apps/shared/mail/listAllPages.ts +39 -0
  63. package/apps/shared/search/LocalIndexLifecycle.tsx +64 -55
  64. package/apps/shared/search/localIndexBuilder.ts +481 -179
  65. package/apps/shared/search/localIndexRpcClient.ts +228 -28
  66. package/apps/shared/search/localIndexSchema.ts +15 -6
  67. package/apps/shared/search/localIndexStorage.ts +98 -0
  68. package/apps/shared/search/localIndexVFS.ts +32 -1
  69. package/apps/shared/search/localIndexWorker.ts +648 -186
  70. package/apps/www/calendar/index.tsx +438 -417
  71. package/apps/www/contacts/[uid].tsx +21 -2
  72. package/apps/www/contacts/index.tsx +116 -12
  73. package/apps/www/index.tsx +1314 -1041
  74. package/apps/www/messages/[uid].tsx +18 -6
  75. package/apps/www/settings/auto-reply/index.tsx +134 -129
  76. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  77. package/apps/www/settings/encryption/index.tsx +739 -216
  78. package/apps/www/settings/filters/[uid].tsx +171 -155
  79. package/apps/www/settings/filters/new/index.tsx +8 -1
  80. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  81. package/apps/www/settings/privacy/index.tsx +494 -436
  82. package/apps/www/settings/read-receipts/index.tsx +148 -125
  83. package/apps/www/settings/sharing/index.tsx +17 -14
  84. package/apps/www/tasks/index.tsx +638 -579
  85. package/dist/apps/admin/audit-log/index.js +38 -8
  86. package/dist/apps/admin/data-requests/index.js +52 -72
  87. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  88. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  89. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  90. package/dist/apps/admin/index.js +4 -1
  91. package/dist/apps/admin/ingest-queue/index.js +24 -6
  92. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  93. package/dist/apps/admin/quarantine/index.js +20 -11
  94. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  95. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  96. package/dist/apps/admin/transport-rules/index.js +22 -6
  97. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  98. package/dist/apps/escrow/_layout.js +3 -3
  99. package/dist/apps/escrow/audit-log/index.js +26 -1
  100. package/dist/apps/escrow/matters/[uid].js +77 -42
  101. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  102. package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -1
  103. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  104. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  105. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  106. package/dist/apps/shared/components/admin/settings/BrandingForm.js +15 -4
  107. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +13 -7
  108. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +7 -2
  109. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +6 -0
  110. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +34 -12
  111. package/dist/apps/shared/components/admin/settings/PluginsManager.js +390 -78
  112. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +40 -11
  113. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +29 -13
  114. package/dist/apps/shared/components/admin/setup/SetupWizard.js +87 -21
  115. package/dist/apps/shared/components/admin/signOut.js +75 -0
  116. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  117. package/dist/apps/shared/components/calendar/EventModal.js +142 -21
  118. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  119. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  120. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  121. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  122. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  123. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +1 -1
  124. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  125. package/dist/apps/shared/components/contacts/ContactForm.js +32 -10
  126. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  127. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  128. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  129. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  130. package/dist/apps/shared/components/layout/AppShell.js +83 -14
  131. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  132. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +49 -12
  133. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  134. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  135. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +73 -14
  136. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  137. package/dist/apps/shared/components/mail/MessageDetailPane.js +441 -36
  138. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  139. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +699 -128
  140. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  141. package/dist/apps/shared/components/mail/layout/MailShell.js +12 -3
  142. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  143. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  144. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  145. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  146. package/dist/apps/shared/mail/listAllPages.js +25 -0
  147. package/dist/apps/shared/search/LocalIndexLifecycle.js +58 -54
  148. package/dist/apps/shared/search/localIndexBuilder.js +292 -37
  149. package/dist/apps/shared/search/localIndexRpcClient.js +188 -25
  150. package/dist/apps/shared/search/localIndexSchema.js +12 -6
  151. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  152. package/dist/apps/shared/search/localIndexVFS.js +68 -40
  153. package/dist/apps/shared/search/localIndexWorker.js +519 -173
  154. package/dist/apps/www/calendar/index.js +28 -10
  155. package/dist/apps/www/contacts/[uid].js +13 -2
  156. package/dist/apps/www/contacts/index.js +80 -10
  157. package/dist/apps/www/index.js +339 -108
  158. package/dist/apps/www/messages/[uid].js +18 -6
  159. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  160. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  161. package/dist/apps/www/settings/encryption/index.js +514 -97
  162. package/dist/apps/www/settings/filters/[uid].js +22 -7
  163. package/dist/apps/www/settings/filters/new/index.js +8 -2
  164. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  165. package/dist/apps/www/settings/privacy/index.js +57 -22
  166. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  167. package/dist/apps/www/settings/sharing/index.js +7 -11
  168. package/dist/apps/www/tasks/index.js +50 -10
  169. package/package.json +2 -2
@@ -3,13 +3,15 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { useEffect, useState } from "react";
6
+ import { useEffect, useRef, useState } from "react";
7
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
8
  import { listAuditLog } from "@rapidmx/react-shared/admin/auditLogApi.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";
12
12
  const PAGE_SIZE = 25;
13
+ /** How long typing in a filter box has to pause before the log is re-fetched. */
14
+ export const FILTER_DEBOUNCE_MS = 300;
13
15
  const FILTER_KEYS = ["mailboxUid", "actorUserUid", "action", "targetType"];
14
16
  /** Seeds the filter form from the query string on first render — lets a link (e.g. a future "View audit
15
17
  * log" deep link from a mailbox's detail page) land here pre-filtered, same convention as `quarantine`'s
@@ -32,26 +34,54 @@ export default function AuditLogPage(props) {
32
34
  return (_jsx(AdminShell, { ...props, active: "auditLog", children: _jsx(AuditLogContent, {}) }));
33
35
  }
34
36
  function AuditLogContent() {
37
+ // `filters` is what the boxes show; `queryFilters` is what was last fetched - it follows `filters` once typing
38
+ // pauses, and stays `null` until the URL's filters are read so no unfiltered fetch goes out first.
35
39
  const [filters, setFilters] = useState({});
40
+ const [queryFilters, setQueryFilters] = useState(null);
36
41
  const [page, setPage] = useState(0);
37
42
  const [entries, setEntries] = useState([]);
38
43
  const [loading, setLoading] = useState(true);
39
44
  const [error, setError] = useState(null);
45
+ const debounceTimer = useRef(undefined);
40
46
  useEffect(() => {
41
- setFilters(readFiltersFromUrl());
47
+ const initial = readFiltersFromUrl();
48
+ setFilters(initial);
49
+ setQueryFilters(initial);
50
+ return () => clearTimeout(debounceTimer.current);
42
51
  }, []);
43
52
  useEffect(() => {
53
+ if (!queryFilters) {
54
+ return;
55
+ }
56
+ // Only the latest filters/page's response is applied, so a slow earlier fetch can't overwrite it.
57
+ let cancelled = false;
44
58
  setLoading(true);
45
59
  setError(null);
46
- listAuditLog(filters, { page, limit: PAGE_SIZE })
47
- .then(setEntries)
48
- .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load the audit log."))
49
- .finally(() => setLoading(false));
50
- }, [filters, page]);
60
+ listAuditLog(queryFilters, { page, limit: PAGE_SIZE })
61
+ .then((data) => {
62
+ if (!cancelled)
63
+ setEntries(data);
64
+ })
65
+ .catch((err) => {
66
+ if (!cancelled)
67
+ setError(err instanceof ApiRequestError ? err.message : "Could not load the audit log.");
68
+ })
69
+ .finally(() => {
70
+ if (!cancelled)
71
+ setLoading(false);
72
+ });
73
+ return () => {
74
+ cancelled = true;
75
+ };
76
+ }, [queryFilters, page]);
51
77
  function updateFilter(key, value) {
52
78
  const next = { ...filters, [key]: value || undefined };
53
79
  setFilters(next);
54
- setPage(0);
80
+ clearTimeout(debounceTimer.current);
81
+ debounceTimer.current = setTimeout(() => {
82
+ setQueryFilters(next);
83
+ setPage(0);
84
+ }, FILTER_DEBOUNCE_MS);
55
85
  const params = new URLSearchParams();
56
86
  for (const k of FILTER_KEYS) {
57
87
  const v = next[k];
@@ -10,6 +10,7 @@ import { listImportRequests, uploadMailboxImport, } from "@rapidmx/react-shared/
10
10
  import { approveErasureRequest, denyErasureRequest, listErasureRequests, } from "@rapidmx/react-shared/mail/erasureRequestApi.js";
11
11
  import { listFolders } from "@rapidmx/react-shared/mail/mailApi.js";
12
12
  import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
13
+ import { LoadMoreButton, usePagedList } from "../../shared/components/admin/usePagedList.js";
13
14
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
14
15
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
15
16
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
@@ -18,43 +19,33 @@ const NON_MAIL_FOLDER_TYPES = new Set(["calendar", "contacts", "tasks", "notes"]
18
19
  function importFormatFromFilename(filename) {
19
20
  return filename.toLowerCase().endsWith(".pst") ? "pst" : "mbox";
20
21
  }
22
+ /** Request statuses still waiting or running - shown as a neutral in-progress badge, never as success. Includes
23
+ * restapi's `processing` (exports/imports) and `in_progress` (an erasure that is running). */
24
+ const IN_PROGRESS_STATUSES = new Set(["pending", "processing", "in_progress"]);
21
25
  function statusBadgeClass(status) {
22
26
  if (status === "failed" || status === "denied")
23
27
  return "bg-danger-bg text-danger";
24
- if (status === "pending" || status === "processing")
28
+ if (IN_PROGRESS_STATUSES.has(status))
25
29
  return "bg-surface-alt text-text-muted";
26
30
  return "bg-success text-white";
27
31
  }
32
+ /** A status as shown on its badge (`in_progress` -> `in progress`). */
33
+ function statusLabel(status) {
34
+ return status.replace(/_/g, " ");
35
+ }
28
36
  export default function DataRequestsPage(props) {
29
- return (_jsx(AdminShell, { ...props, active: "dataRequests", children: _jsxs("div", { className: "flex flex-col gap-8", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "Data Requests" }), _jsx("p", { className: "text-sm text-text-muted", children: "Every GDPR export, mailbox-import, and account-erasure request across every mailbox." })] }), _jsx(ExportRequestsSection, {}), _jsx(ImportRequestsSection, {}), _jsx(ErasureRequestsSection, {})] }) }));
37
+ return (_jsx(AdminShell, { ...props, active: "dataRequests", children: _jsxs("div", { className: "flex flex-col gap-8", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "Data Requests" }), _jsx("p", { className: "text-sm text-text-muted", children: "Every GDPR export, mailbox-import, and account-erasure request across every mailbox, newest first." })] }), _jsx(ExportRequestsSection, {}), _jsx(ImportRequestsSection, {}), _jsx(ErasureRequestsSection, {})] }) }));
30
38
  }
31
39
  function ExportRequestsSection() {
32
- const [requests, setRequests] = useState([]);
33
- const [loading, setLoading] = useState(true);
34
- const [loadError, setLoadError] = useState(null);
40
+ // The create form below isn't gated behind `loading`, so a fast create-then-reload can resolve before the
41
+ // initial mount fetch does - `usePagedList()` only ever applies the most recently started load.
42
+ const list = usePagedList(listExportRequests, "Could not load export requests.");
35
43
  const [mailboxUid, setMailboxUid] = useState("");
36
44
  const [format, setFormat] = useState("json");
37
45
  const [creating, setCreating] = useState(false);
38
46
  const [createError, setCreateError] = useState(null);
39
- // The create form below isn't gated behind `loading`, so a fast create-then-reload can resolve before
40
- // the initial mount fetch does; without this guard the mount fetch's now-stale response would land
41
- // last and silently revert the list. Only the most-recently-issued load's response is ever applied.
42
- const loadSeq = useRef(0);
43
- function loadRequests() {
44
- const seq = ++loadSeq.current;
45
- return listExportRequests()
46
- .then((data) => {
47
- if (seq === loadSeq.current)
48
- setRequests(data);
49
- })
50
- .catch((err) => {
51
- if (seq === loadSeq.current)
52
- setLoadError(err instanceof ApiRequestError ? err.message : "Could not load export requests.");
53
- });
54
- }
55
47
  useEffect(() => {
56
- loadRequests().finally(() => setLoading(false));
57
- // eslint-disable-next-line react-hooks/exhaustive-deps
48
+ void list.reload();
58
49
  }, []);
59
50
  async function handleCreate(e) {
60
51
  e.preventDefault();
@@ -63,7 +54,7 @@ function ExportRequestsSection() {
63
54
  try {
64
55
  await createExportRequest({ format, mailboxUid: mailboxUid.trim() });
65
56
  setMailboxUid("");
66
- await loadRequests();
57
+ await list.reload();
67
58
  }
68
59
  catch (err) {
69
60
  setCreateError(err instanceof ApiRequestError ? err.message : "Could not start this export.");
@@ -72,12 +63,12 @@ function ExportRequestsSection() {
72
63
  setCreating(false);
73
64
  }
74
65
  }
75
- return (_jsxs("section", { children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted mb-3", children: "Export requests" }), _jsxs("form", { onSubmit: handleCreate, className: "flex items-end gap-3 mb-4 flex-wrap", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Mailbox UID" }), _jsx("input", { "aria-label": "Export mailbox UID", className: INPUT_CLASS, value: mailboxUid, onChange: (e) => setMailboxUid(e.target.value) })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Format" }), _jsxs("select", { "aria-label": "Export format", className: INPUT_CLASS, value: format, onChange: (e) => setFormat(e.target.value), children: [_jsx("option", { value: "json", children: "JSON" }), _jsx("option", { value: "mbox", children: "Mbox" })] })] }), _jsx(Button, { type: "submit", variant: "secondary", className: "!w-auto", loading: creating, disabled: creating || !mailboxUid.trim(), children: "Create export" })] }), createError && _jsx(Alert, { children: createError }), loadError && _jsx(Alert, { children: loadError }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : requests.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No export requests." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: requests.map((request) => (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsxs("span", { children: [request.mailboxUid, " \u00B7 ", request.format.toUpperCase(), " \u00B7", " ", new Date(request.dateCreated).toLocaleString(), request.status === "failed" && request.errorMessage && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.errorMessage] }))] }), _jsxs("span", { className: "flex items-center gap-3", children: [_jsx("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${statusBadgeClass(request.status)}`, children: request.status }), request.status === "ready" && (_jsx("a", { href: exportRequestDownloadUrl(request.uid), className: "text-primary-dark hover:underline font-medium", children: "Download" }))] })] }, request.uid))) }))] }));
66
+ return (_jsxs("section", { children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted mb-3", children: "Export requests" }), _jsxs("form", { onSubmit: handleCreate, className: "flex items-end gap-3 mb-4 flex-wrap", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Mailbox UID" }), _jsx("input", { "aria-label": "Export mailbox UID", className: INPUT_CLASS, value: mailboxUid, onChange: (e) => setMailboxUid(e.target.value) })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Format" }), _jsxs("select", { "aria-label": "Export format", className: INPUT_CLASS, value: format, onChange: (e) => setFormat(e.target.value), children: [_jsx("option", { value: "json", children: "JSON" }), _jsx("option", { value: "mbox", children: "Mbox" })] })] }), _jsx(Button, { type: "submit", variant: "secondary", className: "!w-auto", loading: creating, disabled: creating || !mailboxUid.trim(), children: "Create export" })] }), createError && _jsx(Alert, { children: createError }), list.loadError && _jsx(Alert, { children: list.loadError }), list.loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : list.items.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No export requests." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: list.items.map((request) => (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsxs("span", { children: [request.mailboxUid, " \u00B7 ", request.format.toUpperCase(), " \u00B7", " ", new Date(request.dateCreated).toLocaleString(), request.status === "failed" && request.errorMessage && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.errorMessage] }))] }), _jsxs("span", { className: "flex items-center gap-3", children: [_jsx("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${statusBadgeClass(request.status)}`, children: statusLabel(request.status) }), request.status === "ready" && (_jsx("a", { href: exportRequestDownloadUrl(request.uid), className: "text-primary-dark hover:underline font-medium", children: "Download" }))] })] }, request.uid))) })), _jsx(LoadMoreButton, { list: list, label: "Load more export requests" })] }));
76
67
  }
77
68
  function ImportRequestsSection() {
78
- const [requests, setRequests] = useState([]);
79
- const [loading, setLoading] = useState(true);
80
- const [loadError, setLoadError] = useState(null);
69
+ // Same stale-response guard as ExportRequestsSection - the Upload button is enabled independently of this
70
+ // section's own `loading`.
71
+ const list = usePagedList(listImportRequests, "Could not load import requests.");
81
72
  const [mailboxUid, setMailboxUid] = useState("");
82
73
  const [folders, setFolders] = useState([]);
83
74
  const [targetFolderUid, setTargetFolderUid] = useState("");
@@ -85,25 +76,8 @@ function ImportRequestsSection() {
85
76
  const [uploading, setUploading] = useState(false);
86
77
  const [uploadError, setUploadError] = useState(null);
87
78
  const fileInputRef = useRef(null);
88
- // Same stale-response guard as ExportRequestsSection's loadRequests() - the Upload button is enabled
89
- // independently of this section's own `loading`, so a fast upload-then-reload can resolve before the
90
- // initial mount fetch does.
91
- const loadSeq = useRef(0);
92
- function loadRequests() {
93
- const seq = ++loadSeq.current;
94
- return listImportRequests()
95
- .then((data) => {
96
- if (seq === loadSeq.current)
97
- setRequests(data);
98
- })
99
- .catch((err) => {
100
- if (seq === loadSeq.current)
101
- setLoadError(err instanceof ApiRequestError ? err.message : "Could not load import requests.");
102
- });
103
- }
104
79
  useEffect(() => {
105
- loadRequests().finally(() => setLoading(false));
106
- // eslint-disable-next-line react-hooks/exhaustive-deps
80
+ void list.reload();
107
81
  }, []);
108
82
  function handleLoadFolders() {
109
83
  const uid = mailboxUid.trim();
@@ -130,46 +104,48 @@ function ImportRequestsSection() {
130
104
  setUploadError(null);
131
105
  setUploading(true);
132
106
  uploadMailboxImport(file, { format: importFormatFromFilename(file.name), targetFolderUid, mailboxUid: mailboxUid.trim() })
133
- .then(() => loadRequests())
107
+ .then(() => list.reload())
134
108
  .catch((err) => setUploadError(err instanceof ApiRequestError ? err.message : "Could not upload this file."))
135
109
  .finally(() => setUploading(false));
136
110
  }
137
- return (_jsxs("section", { children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted mb-3", children: "Import requests" }), _jsxs("div", { className: "flex items-end gap-3 mb-2 flex-wrap", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Mailbox UID" }), _jsx("input", { "aria-label": "Import mailbox UID", className: INPUT_CLASS, value: mailboxUid, onChange: (e) => setMailboxUid(e.target.value), onBlur: handleLoadFolders })] }), folders.length > 0 && (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Destination folder" }), _jsx("select", { "aria-label": "Import destination folder", className: INPUT_CLASS, value: targetFolderUid, onChange: (e) => setTargetFolderUid(e.target.value), children: folders.map((folder) => (_jsx("option", { value: folder.uid, children: folder.name }, folder.uid))) })] })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: uploading, disabled: uploading || !targetFolderUid, onClick: () => fileInputRef.current?.click(), children: "Upload Mbox or PST file" }), _jsx("input", { ref: fileInputRef, type: "file", accept: ".mbox,.pst", "aria-label": "Upload mail archive", className: "hidden", onChange: handleFileChange })] }), folderError && _jsx(Alert, { children: folderError }), uploadError && _jsx(Alert, { children: uploadError }), loadError && _jsx(Alert, { children: loadError }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : requests.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No import requests." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: requests.map((request) => (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsxs("span", { children: [request.mailboxUid, " \u00B7 ", request.format.toUpperCase(), " \u00B7", " ", new Date(request.dateCreated).toLocaleString(), request.status === "completed" && (_jsxs("span", { className: "text-text-muted", children: [" ", "\u2014 ", request.importedCount ?? 0, " imported", request.failedCount ? `, ${request.failedCount} failed` : ""] })), request.status === "failed" && request.errorMessage && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.errorMessage] }))] }), _jsx("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${statusBadgeClass(request.status)}`, children: request.status })] }, request.uid))) }))] }));
111
+ return (_jsxs("section", { children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted mb-3", children: "Import requests" }), _jsxs("div", { className: "flex items-end gap-3 mb-2 flex-wrap", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Mailbox UID" }), _jsx("input", { "aria-label": "Import mailbox UID", className: INPUT_CLASS, value: mailboxUid, onChange: (e) => setMailboxUid(e.target.value), onBlur: handleLoadFolders })] }), folders.length > 0 && (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Destination folder" }), _jsx("select", { "aria-label": "Import destination folder", className: INPUT_CLASS, value: targetFolderUid, onChange: (e) => setTargetFolderUid(e.target.value), children: folders.map((folder) => (_jsx("option", { value: folder.uid, children: folder.name }, folder.uid))) })] })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: uploading, disabled: uploading || !targetFolderUid, onClick: () => fileInputRef.current?.click(), children: "Upload Mbox or PST file" }), _jsx("input", { ref: fileInputRef, type: "file", accept: ".mbox,.pst", "aria-label": "Upload mail archive", className: "hidden", onChange: handleFileChange })] }), folderError && _jsx(Alert, { children: folderError }), uploadError && _jsx(Alert, { children: uploadError }), list.loadError && _jsx(Alert, { children: list.loadError }), list.loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : list.items.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No import requests." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: list.items.map((request) => (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsxs("span", { children: [request.mailboxUid, " \u00B7 ", request.format.toUpperCase(), " \u00B7", " ", new Date(request.dateCreated).toLocaleString(), request.status === "completed" && (_jsxs("span", { className: "text-text-muted", children: [" ", "\u2014 ", request.importedCount ?? 0, " imported", request.failedCount ? `, ${request.failedCount} failed` : ""] })), request.status === "failed" && request.errorMessage && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.errorMessage] }))] }), _jsx("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${statusBadgeClass(request.status)}`, children: statusLabel(request.status) })] }, request.uid))) })), _jsx(LoadMoreButton, { list: list, label: "Load more import requests" })] }));
138
112
  }
139
113
  function ErasureRequestsSection() {
140
- const [requests, setRequests] = useState([]);
141
- const [loading, setLoading] = useState(true);
142
- const [loadError, setLoadError] = useState(null);
143
- const [actionErrors, setActionErrors] = useState({});
144
- const [actingOn, setActingOn] = useState({});
114
+ const list = usePagedList(listErasureRequests, "Could not load erasure requests.");
115
+ const [approveTarget, setApproveTarget] = useState(null);
116
+ const [approving, setApproving] = useState(false);
117
+ const [approveError, setApproveError] = useState(null);
145
118
  const [denyTarget, setDenyTarget] = useState(null);
146
119
  const [denyReason, setDenyReason] = useState("");
147
120
  const [denying, setDenying] = useState(false);
148
121
  const [denyError, setDenyError] = useState(null);
149
- function loadRequests() {
150
- return listErasureRequests()
151
- .then(setRequests)
152
- .catch((err) => setLoadError(err instanceof ApiRequestError ? err.message : "Could not load erasure requests."));
153
- }
154
122
  useEffect(() => {
155
- loadRequests().finally(() => setLoading(false));
156
- // eslint-disable-next-line react-hooks/exhaustive-deps
123
+ void list.reload();
157
124
  }, []);
158
- async function handleApprove(request) {
159
- setActingOn((prev) => ({ ...prev, [request.uid]: true }));
160
- setActionErrors((prev) => ({ ...prev, [request.uid]: "" }));
125
+ function closeApproveModal() {
126
+ setApproveTarget(null);
127
+ setApproveError(null);
128
+ }
129
+ // Escape/Close are ignored while a decision is being sent, so its result (or error) can't land on a modal
130
+ // reopened for a different request.
131
+ function dismissApproveModal() {
132
+ if (!approving)
133
+ closeApproveModal();
134
+ }
135
+ async function handleApprove() {
136
+ setApproveError(null);
137
+ setApproving(true);
161
138
  try {
162
- await approveErasureRequest(request.uid);
163
- await loadRequests();
139
+ // Only invoked from the approve modal below, which only renders once approveTarget is set.
140
+ await approveErasureRequest(approveTarget.uid);
141
+ closeApproveModal();
142
+ await list.reload();
164
143
  }
165
144
  catch (err) {
166
- setActionErrors((prev) => ({
167
- ...prev,
168
- [request.uid]: err instanceof ApiRequestError ? err.message : "Could not approve this request.",
169
- }));
145
+ setApproveError(err instanceof ApiRequestError ? err.message : "Could not approve this request.");
170
146
  }
171
147
  finally {
172
- setActingOn((prev) => ({ ...prev, [request.uid]: false }));
148
+ setApproving(false);
173
149
  }
174
150
  }
175
151
  function closeDenyModal() {
@@ -177,6 +153,10 @@ function ErasureRequestsSection() {
177
153
  setDenyReason("");
178
154
  setDenyError(null);
179
155
  }
156
+ function dismissDenyModal() {
157
+ if (!denying)
158
+ closeDenyModal();
159
+ }
180
160
  async function handleDeny() {
181
161
  setDenyError(null);
182
162
  setDenying(true);
@@ -184,7 +164,7 @@ function ErasureRequestsSection() {
184
164
  // Only invoked from the deny modal below, which only renders once denyTarget is set.
185
165
  await denyErasureRequest(denyTarget.uid, denyReason);
186
166
  closeDenyModal();
187
- await loadRequests();
167
+ await list.reload();
188
168
  }
189
169
  catch (err) {
190
170
  setDenyError(err instanceof ApiRequestError ? err.message : "Could not deny this request.");
@@ -193,5 +173,5 @@ function ErasureRequestsSection() {
193
173
  setDenying(false);
194
174
  }
195
175
  }
196
- return (_jsxs("section", { children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted mb-3", children: "Erasure requests" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Self-service only \u2014 a mailbox owner requests their own account's erasure; there is no admin-initiated path here, only review." }), loadError && _jsx(Alert, { children: loadError }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : requests.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No erasure requests." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: requests.map((request) => (_jsxs("li", { className: "border border-border rounded-sm p-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsxs("span", { className: "text-sm", children: [request.mailboxUid, " \u00B7 ", new Date(request.dateCreated).toLocaleString(), request.status === "denied" && request.reason && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.reason] })), request.status === "completed" && request.purgedCount !== undefined && (_jsxs("span", { className: "text-text-muted", children: [" \u2014 ", request.purgedCount, " records purged"] }))] }), _jsx("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${statusBadgeClass(request.status)}`, children: request.status })] }), actionErrors[request.uid] && _jsx(Alert, { children: actionErrors[request.uid] }), request.status === "pending" && (_jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: actingOn[request.uid], disabled: actingOn[request.uid], onClick: () => handleApprove(request), children: "Approve" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: actingOn[request.uid], onClick: () => setDenyTarget(request), children: "Deny" })] }))] }, request.uid))) })), _jsxs(Modal, { open: denyTarget !== null, onClose: closeDenyModal, title: "Deny erasure request", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm mb-4", children: [_jsx("span", { className: "font-semibold", children: "Reason" }), _jsx("input", { "aria-label": "Denial reason", className: INPUT_CLASS, value: denyReason, onChange: (e) => setDenyReason(e.target.value) })] }), denyError && _jsx(Alert, { children: denyError }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: denying, onClick: closeDenyModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: denying, disabled: denying || !denyReason.trim(), onClick: handleDeny, children: "Deny" })] })] })] }));
176
+ return (_jsxs("section", { children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted mb-3", children: "Erasure requests" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Self-service only \u2014 a mailbox owner requests their own account's erasure; there is no admin-initiated path here, only review." }), list.loadError && _jsx(Alert, { children: list.loadError }), list.loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : list.items.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No erasure requests." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: list.items.map((request) => (_jsxs("li", { className: "border border-border rounded-sm p-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsxs("span", { className: "text-sm", children: [request.mailboxUid, " \u00B7 ", new Date(request.dateCreated).toLocaleString(), request.status === "denied" && request.reason && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.reason] })), request.status === "completed" && request.purgedCount !== undefined && (_jsxs("span", { className: "text-text-muted", children: [" \u2014 ", request.purgedCount, " records purged"] }))] }), _jsx("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${statusBadgeClass(request.status)}`, children: statusLabel(request.status) })] }), request.status === "pending" && (_jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "button", className: "!w-auto", onClick: () => setApproveTarget(request), children: "Approve" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setDenyTarget(request), children: "Deny" })] }))] }, request.uid))) })), _jsx(LoadMoreButton, { list: list, label: "Load more erasure requests" }), _jsxs(Modal, { open: approveTarget !== null, onClose: dismissApproveModal, title: "Approve erasure request", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Approving permanently erases the mailbox ", _jsx("strong", { className: "break-all", children: approveTarget?.mailboxUid }), " ", "and everything in it - mail, contacts, calendars, tasks, and notes."] }), _jsx("p", { className: "text-sm font-semibold text-danger mb-4", children: "This is irreversible and cannot be undone." }), approveError && _jsx(Alert, { children: approveError }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: approving, onClick: closeApproveModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: approving, disabled: approving, onClick: handleApprove, children: "Erase mailbox" })] })] }), _jsxs(Modal, { open: denyTarget !== null, onClose: dismissDenyModal, title: "Deny erasure request", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm mb-4", children: [_jsx("span", { className: "font-semibold", children: "Reason" }), _jsx("input", { "aria-label": "Denial reason", className: INPUT_CLASS, value: denyReason, onChange: (e) => setDenyReason(e.target.value) })] }), denyError && _jsx(Alert, { children: denyError }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: denying, onClick: closeDenyModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: denying, disabled: denying || !denyReason.trim(), onClick: handleDeny, children: "Deny" })] })] })] }));
197
177
  }
@@ -30,5 +30,5 @@ function DistributionListDetailContent({ uid }) {
30
30
  if (error || !list) {
31
31
  return _jsx(Alert, { children: error ?? "Distribution list not found." });
32
32
  }
33
- return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin/distribution-lists", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All distribution lists" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: list.primarySmtpAddress })] }), _jsx("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: "Name" }), _jsx("dd", { children: list.name }), _jsx("dt", { className: "text-text-muted", children: "Description" }), _jsx("dd", { children: list.description ?? "None" }), _jsx("dt", { className: "text-text-muted", children: "Owner" }), _jsx("dd", { children: list.ownerUserUid ?? "None" }), _jsx("dt", { className: "text-text-muted", children: "Alias addresses" }), _jsx("dd", { children: list.aliasAddresses && list.aliasAddresses.length > 0 ? list.aliasAddresses.join(", ") : "None" }), _jsx("dt", { className: "text-text-muted", children: "Restrict senders" }), _jsx("dd", { children: list.restrictSenders ? "Only members may send" : "No" }), _jsx("dt", { className: "text-text-muted", children: "Created" }), _jsx("dd", { children: new Date(list.dateCreated).toLocaleString() })] }) }), _jsx(MemberListCard, { list: list, onUpdate: setList })] }));
33
+ return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin/distribution-lists", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All distribution lists" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: list.primarySmtpAddress })] }), _jsx("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: "Name" }), _jsx("dd", { children: list.name }), _jsx("dt", { className: "text-text-muted", children: "Description" }), _jsx("dd", { children: list.description ?? "None" }), _jsx("dt", { className: "text-text-muted", children: "Owner" }), _jsx("dd", { children: list.ownerUserUid ?? "None" }), _jsx("dt", { className: "text-text-muted", children: "Alias addresses" }), _jsx("dd", { children: list.aliasAddresses && list.aliasAddresses.length > 0 ? list.aliasAddresses.join(", ") : "None" }), _jsx("dt", { className: "text-text-muted", children: "Restrict senders" }), _jsxs("dd", { children: [list.restrictSenders ? "Only members may send" : "No", list.restrictSenders ? (_jsxs("p", { role: "status", className: "mt-1.5 py-2 px-3 rounded-sm text-xs bg-warning/15 text-text", children: ["Members are recognized only by a verified (DKIM) signature, which requires the mail server\u2019s Authentication-Results configuration (the trusted authserv id,", " ", _jsx("code", { children: "mail:security:trusted_authserv_id" }), "). If that isn\u2019t configured, all mail sent to this list is dropped."] })) : (_jsx("p", { className: "mt-1 text-xs text-text-muted", children: "Restricting senders to members requires the mail server\u2019s Authentication-Results configuration (the trusted authserv id); without it, all mail to the list is dropped." }))] }), _jsx("dt", { className: "text-text-muted", children: "Created" }), _jsx("dd", { children: new Date(list.dateCreated).toLocaleString() })] }) }), _jsx(MemberListCard, { list: list, onUpdate: setList })] }));
34
34
  }
@@ -8,7 +8,7 @@ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
8
  import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
9
9
  import { deleteEscrowScope, getEscrowScope, updateEscrowScope, } from "@rapidmx/react-shared/admin/escrowScopesApi.js";
10
10
  import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
11
- import EscrowScopeKeyAndHoldersFields from "../../shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js";
11
+ import EscrowScopeKeyAndHoldersFields, { selfAsHolderError, } from "../../shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js";
12
12
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
13
13
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
14
14
  import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
@@ -26,10 +26,75 @@ function toFieldsValue(scope) {
26
26
  notifySubjectOnAccess: scope.notifySubjectOnAccess,
27
27
  };
28
28
  }
29
+ /**
30
+ * Describes every change to the security-relevant parts of a scope - who holds it, how many holders must approve,
31
+ * and its public key - so the administrator confirms exactly what they're changing before it's saved.
32
+ */
33
+ export function describeSecurityChanges(original, next) {
34
+ const changes = [];
35
+ const added = next.holderUserUids.filter((holder) => !original.holderUserUids.includes(holder));
36
+ const removed = original.holderUserUids.filter((holder) => !next.holderUserUids.includes(holder));
37
+ if (added.length > 0) {
38
+ changes.push(`Add holders: ${added.join(", ")}`);
39
+ }
40
+ if (removed.length > 0) {
41
+ changes.push(`Remove holders: ${removed.join(", ")}`);
42
+ }
43
+ if (next.requiredHolders !== original.requiredHolders) {
44
+ changes.push(`Required approvals: ${original.requiredHolders} → ${next.requiredHolders}`);
45
+ }
46
+ if (next.notifySubjectOnAccess !== original.notifySubjectOnAccess) {
47
+ const onOff = (value) => (value ? "on" : "off");
48
+ changes.push(`Notify subject on access: ${onOff(original.notifySubjectOnAccess)} → ${onOff(next.notifySubjectOnAccess)}`);
49
+ }
50
+ const originalKey = toFieldsValue(original);
51
+ if (next.publicKey.trim() !== originalKey.publicKey || next.keyType.trim() !== originalKey.keyType) {
52
+ changes.push("Replace the public key");
53
+ }
54
+ if (next.fingerprint.trim() !== originalKey.fingerprint) {
55
+ changes.push(`Fingerprint: ${originalKey.fingerprint} → ${next.fingerprint.trim()}`);
56
+ }
57
+ if (validityChanged(originalKey, next)) {
58
+ changes.push(`Key validity: ${originalKey.notBefore} – ${originalKey.notAfter} → ${next.notBefore} – ${next.notAfter}`);
59
+ }
60
+ return changes;
61
+ }
62
+ function keyMaterialChanged(originalKey, next) {
63
+ return (next.publicKey.trim() !== originalKey.publicKey ||
64
+ next.keyType.trim() !== originalKey.keyType ||
65
+ next.fingerprint.trim() !== originalKey.fingerprint);
66
+ }
67
+ function validityChanged(originalKey, next) {
68
+ return next.notBefore !== originalKey.notBefore || next.notAfter !== originalKey.notAfter;
69
+ }
70
+ /**
71
+ * The `publicKey` to send with an update, or `undefined` when the admin didn't touch any key field - the form only
72
+ * shows dates to the minute, so re-sending a rebuilt key would silently truncate the stored validity window. A date
73
+ * field left as loaded keeps its original epoch-ms value. `revokedAt` isn't editable here: it's carried over when
74
+ * only the validity window changed, and dropped when the key material itself is replaced (a new key isn't revoked).
75
+ */
76
+ export function buildPublicKeyUpdate(original, next) {
77
+ const originalKey = toFieldsValue(original);
78
+ const materialChanged = keyMaterialChanged(originalKey, next);
79
+ if (!materialChanged && !validityChanged(originalKey, next)) {
80
+ return undefined;
81
+ }
82
+ const key = {
83
+ publicKey: next.publicKey.trim(),
84
+ type: next.keyType.trim(),
85
+ fingerprint: next.fingerprint.trim(),
86
+ notBefore: next.notBefore === originalKey.notBefore ? original.publicKey.notBefore : new Date(next.notBefore).getTime(),
87
+ notAfter: next.notAfter === originalKey.notAfter ? original.publicKey.notAfter : new Date(next.notAfter).getTime(),
88
+ };
89
+ if (!materialChanged && original.publicKey.revokedAt !== undefined) {
90
+ key.revokedAt = original.publicKey.revokedAt;
91
+ }
92
+ return key;
93
+ }
29
94
  export default function EscrowScopeDetailPage(props) {
30
- return (_jsx(AdminShell, { ...props, active: "escrowScopes", children: _jsx(EscrowScopeDetailContent, { uid: props.params.uid }) }));
95
+ return (_jsx(AdminShell, { ...props, active: "escrowScopes", children: _jsx(EscrowScopeDetailContent, { uid: props.params.uid, adminUid: props.userUid }) }));
31
96
  }
32
- function EscrowScopeDetailContent({ uid }) {
97
+ function EscrowScopeDetailContent({ uid, adminUid }) {
33
98
  const [original, setOriginal] = useState(null);
34
99
  const [name, setName] = useState("");
35
100
  const [description, setDescription] = useState("");
@@ -41,6 +106,7 @@ function EscrowScopeDetailContent({ uid }) {
41
106
  const [confirmingDelete, setConfirmingDelete] = useState(false);
42
107
  const [deleting, setDeleting] = useState(false);
43
108
  const [deleteError, setDeleteError] = useState(null);
109
+ const [pendingChanges, setPendingChanges] = useState(null);
44
110
  useEffect(() => {
45
111
  setLoading(true);
46
112
  setError(null);
@@ -79,6 +145,20 @@ function EscrowScopeDetailContent({ uid }) {
79
145
  setError("Required holders must be between 1 and the number of holders.");
80
146
  return;
81
147
  }
148
+ const selfError = selfAsHolderError(fields.holderUserUids, adminUid, original.holderUserUids);
149
+ if (selfError) {
150
+ setError(selfError);
151
+ return;
152
+ }
153
+ const changes = describeSecurityChanges(original, fields);
154
+ if (changes.length > 0) {
155
+ setPendingChanges(changes);
156
+ return;
157
+ }
158
+ await save();
159
+ }
160
+ async function save() {
161
+ setPendingChanges(null);
82
162
  setSaving(true);
83
163
  setSaved(false);
84
164
  try {
@@ -86,14 +166,10 @@ function EscrowScopeDetailContent({ uid }) {
86
166
  uid: original.uid,
87
167
  version: original.version,
88
168
  name: name.trim(),
89
- description: description.trim() || undefined,
90
- publicKey: {
91
- publicKey: fields.publicKey.trim(),
92
- type: fields.keyType.trim(),
93
- fingerprint: fields.fingerprint.trim(),
94
- notBefore: new Date(fields.notBefore).getTime(),
95
- notAfter: new Date(fields.notAfter).getTime(),
96
- },
169
+ // `null` (not omitted) so clearing the field actually clears the stored description - an update is
170
+ // a merge, and an absent field is left as it was.
171
+ description: (description.trim() || null),
172
+ publicKey: buildPublicKeyUpdate(original, fields),
97
173
  holderUserUids: fields.holderUserUids,
98
174
  requiredHolders: fields.requiredHolders,
99
175
  notifySubjectOnAccess: fields.notifySubjectOnAccess,
@@ -109,7 +185,10 @@ function EscrowScopeDetailContent({ uid }) {
109
185
  setSaving(false);
110
186
  }
111
187
  }
188
+ // Ignored while deleting (Escape/Close; Cancel is disabled then too).
112
189
  function closeDeleteModal() {
190
+ if (deleting)
191
+ return;
113
192
  setConfirmingDelete(false);
114
193
  }
115
194
  // Only ever invoked from the delete-confirmation modal below, which itself only renders once
@@ -137,5 +216,5 @@ function EscrowScopeDetailContent({ uid }) {
137
216
  if (!original || !fields) {
138
217
  return _jsx(Alert, { children: "Escrow scope not found." });
139
218
  }
140
- return (_jsxs("div", { className: "max-w-3xl", children: [_jsxs("div", { className: "flex items-start justify-between gap-4 mb-1", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin/escrow-scopes", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All escrow scopes" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: original.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 scope" })] }), error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-5 mt-4", children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx(FormField, { label: "Name", htmlFor: "name", children: _jsx("input", { id: "name", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value) }) }), _jsx(FormField, { label: "Description (optional)", htmlFor: "description", children: _jsx("input", { id: "description", type: "text", className: INPUT_CLASS, value: description, onChange: (e) => setDescription(e.target.value) }) })] }), _jsx(EscrowScopeKeyAndHoldersFields, { value: fields, onChange: setFields }), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save changes" }) })] }), _jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete escrow scope", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Are you sure you want to delete ", _jsx("strong", { children: original.name }), "? This cannot be undone, and fails if any Matter still references this scope."] }), 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" })] })] })] }));
219
+ return (_jsxs("div", { className: "max-w-3xl", children: [_jsxs("div", { className: "flex items-start justify-between gap-4 mb-1", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin/escrow-scopes", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All escrow scopes" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: original.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 scope" })] }), error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-5 mt-4", children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx(FormField, { label: "Name", htmlFor: "name", children: _jsx("input", { id: "name", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value) }) }), _jsx(FormField, { label: "Description (optional)", htmlFor: "description", children: _jsx("input", { id: "description", type: "text", className: INPUT_CLASS, value: description, onChange: (e) => setDescription(e.target.value) }) })] }), _jsx(EscrowScopeKeyAndHoldersFields, { value: fields, onChange: setFields }), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save changes" }) })] }), _jsxs(Modal, { open: pendingChanges !== null, onClose: () => setPendingChanges(null), title: "Confirm escrow scope changes", children: [_jsxs("p", { className: "text-sm mb-3", children: ["These changes decide who can recover escrowed mail under ", _jsx("strong", { children: original.name }), ":"] }), _jsx("ul", { className: "list-disc pl-5 text-sm flex flex-col gap-1 mb-3", children: pendingChanges?.map((change) => (_jsx("li", { className: "break-all", children: change }, change))) }), _jsx("p", { className: "text-xs text-text-muted", children: "The server refuses changes while an access request under this scope is still awaiting approval." }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setPendingChanges(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => void save(), children: "Confirm and save" })] })] }), _jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete escrow scope", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Are you sure you want to delete ", _jsx("strong", { children: original.name }), "? This cannot be undone, and fails if any Matter still references this scope."] }), 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" })] })] })] }));
141
220
  }
@@ -7,15 +7,15 @@ import { useState } from "react";
7
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
8
  import { createEscrowScope } from "@rapidmx/react-shared/admin/escrowScopesApi.js";
9
9
  import AdminShell from "../../../shared/components/admin/layout/AdminShell.js";
10
- import EscrowScopeKeyAndHoldersFields, { emptyEscrowScopeKeyAndHoldersValue, } from "../../../shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js";
10
+ import EscrowScopeKeyAndHoldersFields, { emptyEscrowScopeKeyAndHoldersValue, selfAsHolderError, } from "../../../shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.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
13
  import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
14
14
  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";
15
15
  export default function NewEscrowScopePage(props) {
16
- return (_jsx(AdminShell, { ...props, active: "escrowScopes", children: _jsx(NewEscrowScopeForm, {}) }));
16
+ return (_jsx(AdminShell, { ...props, active: "escrowScopes", children: _jsx(NewEscrowScopeForm, { adminUid: props.userUid }) }));
17
17
  }
18
- function NewEscrowScopeForm() {
18
+ function NewEscrowScopeForm({ adminUid }) {
19
19
  const [name, setName] = useState("");
20
20
  const [description, setDescription] = useState("");
21
21
  const [keyAndHolders, setKeyAndHolders] = useState(emptyEscrowScopeKeyAndHoldersValue());
@@ -40,6 +40,11 @@ function NewEscrowScopeForm() {
40
40
  setError("Required holders must be between 1 and the number of holders.");
41
41
  return;
42
42
  }
43
+ const selfError = selfAsHolderError(keyAndHolders.holderUserUids, adminUid);
44
+ if (selfError) {
45
+ setError(selfError);
46
+ return;
47
+ }
43
48
  setSaving(true);
44
49
  try {
45
50
  const created = await createEscrowScope({
@@ -11,6 +11,7 @@ import AdminShell from "../shared/components/admin/layout/AdminShell.js";
11
11
  import MailboxTable from "../shared/components/admin/mailboxes/MailboxTable.js";
12
12
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
13
13
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
14
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
14
15
  const PAGE_SIZE = 25;
15
16
  export default function MailboxesListPage(props) {
16
17
  return (_jsx(AdminShell, { ...props, active: "mailboxes", children: _jsx(MailboxesListContent, {}) }));
@@ -30,6 +31,7 @@ function MailboxesListContent() {
30
31
  }, [page]);
31
32
  const hasNextPage = mailboxes.length === PAGE_SIZE;
32
33
  const [reopening, setReopening] = useState(false);
34
+ const [confirmingSetup, setConfirmingSetup] = useState(false);
33
35
  async function handleRunSetup() {
34
36
  setReopening(true);
35
37
  try {
@@ -39,7 +41,8 @@ function MailboxesListContent() {
39
41
  catch (err) {
40
42
  setError(err instanceof ApiRequestError ? err.message : "Could not reopen setup.");
41
43
  setReopening(false);
44
+ setConfirmingSetup(false);
42
45
  }
43
46
  }
44
- 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: "Mailboxes" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: reopening, disabled: reopening, onClick: () => void handleRunSetup(), children: "Run setup again" }), _jsx("a", { href: "/admin/mailboxes/new", children: _jsx(Button, { type: "button", className: "!w-auto", children: "+ New mailbox" }) })] })] }), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : (_jsx(MailboxTable, { mailboxes: mailboxes })), _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" })] })] }));
47
+ 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: "Mailboxes" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: reopening, onClick: () => setConfirmingSetup(true), children: "Run setup again" }), _jsx("a", { href: "/admin/mailboxes/new", children: _jsx(Button, { type: "button", className: "!w-auto", children: "+ New mailbox" }) })] })] }), error && _jsx(Alert, { children: error }), _jsxs(Modal, { open: confirmingSetup, onClose: () => setConfirmingSetup(false), title: "Run setup again?", children: [_jsx("p", { className: "text-sm mb-4", children: "Setup starts again from its first step, and the admin console sends administrators to it until it's finished. Your existing settings, domains and mailboxes are kept." }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingSetup(false), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: reopening, disabled: reopening, onClick: () => void handleRunSetup(), children: "Run setup" })] })] }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : (_jsx(MailboxTable, { mailboxes: mailboxes })), _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" })] })] }));
45
48
  }