@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,7 +3,7 @@ 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 { closeMatter, getMatter } from "@rapidmx/react-shared/admin/mattersApi.js";
9
9
  import { approveAccessRequest, createAccessRequest, denyAccessRequest, getAccessRequestMaterial, listAccessRequests, } from "@rapidmx/react-shared/admin/escrowAccessRequestsApi.js";
@@ -11,35 +11,44 @@ import { createMatterExportRequest, listMatterExportRequests, matterExportReques
11
11
  import { searchMatter } from "@rapidmx/react-shared/admin/matterSearchApi.js";
12
12
  import { parseSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
13
13
  import EscrowShell from "../../shared/components/escrow/layout/EscrowShell.js";
14
+ import { LoadMoreButton, usePagedList } from "../../shared/components/admin/usePagedList.js";
14
15
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
15
16
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
16
17
  import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
17
18
  import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
18
19
  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";
19
- // `BaseEscrowAccessRequestRoute.find()` always returns every request under every scope the caller holds
20
- // (it unconditionally overwrites any `matterId` filter with its own held-scopes-derived one — see
21
- // `escrowAccessRequestsApi.ts`'s own doc comment) — there is no server-side way to ask for just this one
22
- // matter's requests. This page fetches a generously-sized single page and filters client-side instead of
23
- // paginating a second, matter-scoped list UI on top of an already-scoped one; a holder with more than this
24
- // many *total* in-flight requests across every matter they hold is not the common case this v1 targets.
25
- const REQUESTS_FETCH_LIMIT = 200;
20
+ /** Request statuses still waiting or running - shown as a neutral in-progress badge, never as success. Includes
21
+ * restapi's `processing` (exports/imports) and `in_progress` (an erasure that is running). */
22
+ const IN_PROGRESS_STATUSES = new Set(["pending", "processing", "in_progress"]);
26
23
  function statusBadgeClass(status) {
27
- if (status === "denied" || status === "failed")
24
+ if (status === "failed" || status === "denied")
28
25
  return "bg-danger-bg text-danger";
29
- if (status === "pending")
26
+ if (IN_PROGRESS_STATUSES.has(status))
30
27
  return "bg-surface-alt text-text-muted";
31
28
  return "bg-success text-white";
32
29
  }
30
+ /** A status as shown on its badge (`in_progress` -> `in progress`). */
31
+ function statusLabel(status) {
32
+ return status.replace(/_/g, " ");
33
+ }
33
34
  export default function MatterDetailPage(props) {
34
35
  return (_jsx(EscrowShell, { ...props, active: "matters", children: _jsx(MatterDetailContent, { uid: props.params.uid }) }));
35
36
  }
37
+ function formatDateRange(matter) {
38
+ return `${new Date(matter.dateRangeStart).toLocaleDateString()} – ${new Date(matter.dateRangeEnd).toLocaleDateString()}`;
39
+ }
36
40
  function MatterDetailContent({ uid }) {
37
41
  const [matter, setMatter] = useState(null);
38
- const [requests, setRequests] = useState([]);
39
42
  const [loading, setLoading] = useState(true);
40
43
  const [error, setError] = useState(null);
44
+ // Both lists are fetched for this matter only (`matterId`), newest first, a page at a time. Rows are still
45
+ // checked against this matter in case an older server ignores the filter.
46
+ const requests = usePagedList((params) => listAccessRequests({ ...params, matterId: uid }), "Could not load this matter's access requests.");
47
+ const exportRequests = usePagedList((params) => listMatterExportRequests({ ...params, matterId: uid }), "Could not load this matter's export requests.");
48
+ const [confirmingClose, setConfirmingClose] = useState(false);
41
49
  const [closing, setClosing] = useState(false);
42
50
  const [closeError, setCloseError] = useState(null);
51
+ const [approveTarget, setApproveTarget] = useState(null);
43
52
  const [showNewRequest, setShowNewRequest] = useState(false);
44
53
  const [newRequestMailboxUid, setNewRequestMailboxUid] = useState("");
45
54
  const [creatingRequest, setCreatingRequest] = useState(false);
@@ -51,41 +60,44 @@ function MatterDetailContent({ uid }) {
51
60
  const [materialText, setMaterialText] = useState("");
52
61
  const [materialError, setMaterialError] = useState(null);
53
62
  const [loadingMaterial, setLoadingMaterial] = useState(false);
54
- const [exportRequests, setExportRequests] = useState([]);
63
+ // Bumped whenever the material modal closes (or the matter does), so a slow "Get material" response for a
64
+ // modal that's already gone - or for an earlier request - is never shown.
65
+ const materialSeq = useRef(0);
55
66
  const [creatingExport, setCreatingExport] = useState(false);
56
67
  const [exportError, setExportError] = useState(null);
57
68
  const [searchText, setSearchText] = useState("");
58
69
  const [searching, setSearching] = useState(false);
59
70
  const [searchError, setSearchError] = useState(null);
60
71
  const [searchResults, setSearchResults] = useState(null);
61
- // Same "no server-side single-matter filter, fetch a generous page and filter client-side" shape
62
- // REQUESTS_FETCH_LIMIT's own doc comment already establishes for access requests -
63
- // listMatterExportRequests() has the identical held-scopes-derived scoping, not a per-matter one.
64
- function reload() {
72
+ // Bumped by every search and by closing the matter, so a search still in flight when the matter closes (or
73
+ // superseded by a newer search) never shows its results.
74
+ const searchSeq = useRef(0);
75
+ useEffect(() => {
65
76
  setLoading(true);
66
77
  setError(null);
67
- Promise.all([getMatter(uid), listAccessRequests({ limit: REQUESTS_FETCH_LIMIT }), listMatterExportRequests()])
68
- .then(([loadedMatter, allRequests, allExportRequests]) => {
69
- setMatter(loadedMatter);
70
- setRequests(allRequests.filter((r) => r.matterId === uid));
71
- setExportRequests(allExportRequests.filter((r) => r.matterId === uid));
72
- })
78
+ getMatter(uid)
79
+ .then(setMatter)
73
80
  .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this matter."))
74
81
  .finally(() => setLoading(false));
75
- }
76
- useEffect(() => {
77
- reload();
82
+ void requests.reload();
83
+ void exportRequests.reload();
78
84
  }, [uid]);
79
- // Only ever invoked from the "Close matter" button below, which itself only renders once `matter` is
85
+ // Only ever invoked from the close-confirmation modal below, which itself only renders once `matter` is
80
86
  // resolved (children only render once loaded — see the early returns further down) — the non-null
81
- // assertion reflects that real invariant, not an unchecked assumption. Matches `DomainDetailContent.
82
- // handleVerify`'s identical pattern.
87
+ // assertion reflects that real invariant, not an unchecked assumption.
83
88
  async function handleClose() {
84
89
  setClosing(true);
85
90
  setCloseError(null);
86
91
  try {
87
92
  const updated = await closeMatter(matter.uid);
88
93
  setMatter(updated);
94
+ setConfirmingClose(false);
95
+ // Nothing more may be read under a closed matter - drop any material or search results already on
96
+ // screen, including a search still in flight, and any approval still being confirmed.
97
+ closeMaterialModal();
98
+ searchSeq.current++;
99
+ setSearchResults(null);
100
+ setSearching(false);
89
101
  }
90
102
  catch (err) {
91
103
  setCloseError(err instanceof ApiRequestError ? err.message : "Could not close this matter.");
@@ -94,6 +106,10 @@ function MatterDetailContent({ uid }) {
94
106
  setClosing(false);
95
107
  }
96
108
  }
109
+ function closeCloseModal() {
110
+ setConfirmingClose(false);
111
+ setCloseError(null);
112
+ }
97
113
  async function handleCreateRequest(e) {
98
114
  e.preventDefault();
99
115
  setNewRequestError(null);
@@ -106,7 +122,7 @@ function MatterDetailContent({ uid }) {
106
122
  await createAccessRequest({ matterId: uid, mailboxUid: newRequestMailboxUid.trim() });
107
123
  setShowNewRequest(false);
108
124
  setNewRequestMailboxUid("");
109
- reload();
125
+ void requests.reload();
110
126
  }
111
127
  catch (err) {
112
128
  setNewRequestError(err instanceof ApiRequestError ? err.message : "Could not create this access request.");
@@ -115,12 +131,15 @@ function MatterDetailContent({ uid }) {
115
131
  setCreatingRequest(false);
116
132
  }
117
133
  }
118
- async function handleApprove(request) {
134
+ // Only ever invoked from the approve-confirmation modal below, which only renders once `approveTarget` is set.
135
+ async function handleApprove() {
136
+ const request = approveTarget;
137
+ setApproveTarget(null);
119
138
  setActingOn((prev) => ({ ...prev, [request.uid]: true }));
120
139
  setActionErrors((prev) => ({ ...prev, [request.uid]: "" }));
121
140
  try {
122
141
  const updated = await approveAccessRequest(request.uid);
123
- setRequests((prev) => prev.map((r) => (r.uid === updated.uid ? updated : r)));
142
+ requests.replaceItem(updated);
124
143
  }
125
144
  catch (err) {
126
145
  setActionErrors((prev) => ({
@@ -137,7 +156,7 @@ function MatterDetailContent({ uid }) {
137
156
  setActionErrors((prev) => ({ ...prev, [request.uid]: "" }));
138
157
  try {
139
158
  const updated = await denyAccessRequest(request.uid);
140
- setRequests((prev) => prev.map((r) => (r.uid === updated.uid ? updated : r)));
159
+ requests.replaceItem(updated);
141
160
  }
142
161
  catch (err) {
143
162
  setActionErrors((prev) => ({
@@ -150,30 +169,39 @@ function MatterDetailContent({ uid }) {
150
169
  }
151
170
  }
152
171
  async function handleGetMaterial(request) {
172
+ const seq = ++materialSeq.current;
153
173
  setMaterialFor(request);
154
174
  setMaterialText("");
155
175
  setMaterialError(null);
156
176
  setLoadingMaterial(true);
157
177
  try {
158
178
  const material = await getAccessRequestMaterial(request.uid);
159
- setMaterialText(JSON.stringify(material.masterKeyWraps, null, 2));
179
+ if (seq === materialSeq.current)
180
+ setMaterialText(JSON.stringify(material.masterKeyWraps, null, 2));
160
181
  }
161
182
  catch (err) {
162
- setMaterialError(err instanceof ApiRequestError ? err.message : "Could not read this request's material.");
183
+ if (seq === materialSeq.current) {
184
+ setMaterialError(err instanceof ApiRequestError ? err.message : "Could not read this request's material.");
185
+ }
163
186
  }
164
187
  finally {
165
- setLoadingMaterial(false);
188
+ if (seq === materialSeq.current)
189
+ setLoadingMaterial(false);
166
190
  }
167
191
  }
168
192
  function closeMaterialModal() {
193
+ materialSeq.current++;
169
194
  setMaterialFor(null);
195
+ setMaterialText("");
196
+ setMaterialError(null);
197
+ setLoadingMaterial(false);
170
198
  }
171
199
  async function handleCreateExport() {
172
200
  setExportError(null);
173
201
  setCreatingExport(true);
174
202
  try {
175
203
  await createMatterExportRequest(uid);
176
- reload();
204
+ void exportRequests.reload();
177
205
  }
178
206
  catch (err) {
179
207
  setExportError(err instanceof ApiRequestError ? err.message : "Could not start this export.");
@@ -184,6 +212,7 @@ function MatterDetailContent({ uid }) {
184
212
  }
185
213
  async function handleSearch(e) {
186
214
  e.preventDefault();
215
+ const seq = ++searchSeq.current;
187
216
  setSearchError(null);
188
217
  setSearching(true);
189
218
  try {
@@ -207,13 +236,17 @@ function MatterDetailContent({ uid }) {
207
236
  flags: parsed.flags,
208
237
  labels: parsed.labels,
209
238
  });
210
- setSearchResults(results);
239
+ if (seq === searchSeq.current)
240
+ setSearchResults(results);
211
241
  }
212
242
  catch (err) {
213
- setSearchError(err instanceof ApiRequestError ? err.message : "Could not search this matter.");
243
+ if (seq === searchSeq.current) {
244
+ setSearchError(err instanceof ApiRequestError ? err.message : "Could not search this matter.");
245
+ }
214
246
  }
215
247
  finally {
216
- setSearching(false);
248
+ if (seq === searchSeq.current)
249
+ setSearching(false);
217
250
  }
218
251
  }
219
252
  if (loading) {
@@ -222,13 +255,15 @@ function MatterDetailContent({ uid }) {
222
255
  if (error || !matter) {
223
256
  return _jsx(Alert, { children: error ?? "Matter not found." });
224
257
  }
225
- return (_jsxs("div", { className: "max-w-4xl flex flex-col gap-5", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/escrow", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All matters" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: matter.name })] }), !matter.closedAt && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", loading: closing, disabled: closing, onClick: handleClose, children: "Close matter" }))] }), closeError && _jsx(Alert, { children: closeError }), _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: "Description" }), _jsx("dd", { children: matter.description ?? "None" }), _jsx("dt", { className: "text-text-muted", children: "Escrow scope" }), _jsx("dd", { className: "font-mono text-xs", children: matter.escrowScopeId }), _jsx("dt", { className: "text-text-muted", children: "Custodian mailboxes" }), _jsx("dd", { children: matter.custodianMailboxUids.join(", ") }), _jsx("dt", { className: "text-text-muted", children: "Date range" }), _jsxs("dd", { children: [new Date(matter.dateRangeStart).toLocaleDateString(), " \u2013", " ", new Date(matter.dateRangeEnd).toLocaleDateString()] }), _jsx("dt", { className: "text-text-muted", children: "Status" }), _jsx("dd", { children: matter.closedAt ? `Closed ${new Date(matter.closedAt).toLocaleString()}` : "Open" })] }) }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("div", { className: "flex items-center justify-between mb-3", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide", children: "Access requests" }), !matter.closedAt && (_jsx(Button, { type: "button", className: "!w-auto", onClick: () => setShowNewRequest(true), children: "+ New access request" }))] }), requests.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No access requests yet." })) : (_jsx("ul", { className: "flex flex-col gap-3", children: requests.map((request) => (_jsxs("li", { className: "border border-border rounded-sm p-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-3 flex-wrap", children: [_jsxs("div", { children: [_jsx("div", { className: "text-sm font-semibold", children: request.mailboxUid }), _jsxs("div", { className: "text-xs text-text-muted", children: [request.approvals.length, " of ", request.requiredHoldersAtCreation, " approvals \u00B7 requested by ", request.requestedByUserUid] })] }), _jsx("span", { className: [
258
+ const matterRequests = requests.items.filter((request) => request.matterId === uid);
259
+ const matterExportRequests = exportRequests.items.filter((request) => request.matterId === uid);
260
+ return (_jsxs("div", { className: "max-w-4xl flex flex-col gap-5", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/escrow", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All matters" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: matter.name })] }), !matter.closedAt && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", onClick: () => setConfirmingClose(true), children: "Close matter" }))] }), _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: "Description" }), _jsx("dd", { children: matter.description ?? "None" }), _jsx("dt", { className: "text-text-muted", children: "Escrow scope" }), _jsx("dd", { className: "font-mono text-xs", children: matter.escrowScopeId }), _jsx("dt", { className: "text-text-muted", children: "Custodian mailboxes" }), _jsx("dd", { children: matter.custodianMailboxUids.join(", ") }), _jsx("dt", { className: "text-text-muted", children: "Date range" }), _jsx("dd", { children: formatDateRange(matter) }), _jsx("dt", { className: "text-text-muted", children: "Status" }), _jsx("dd", { children: matter.closedAt ? `Closed ${new Date(matter.closedAt).toLocaleString()}` : "Open" })] }) }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("div", { className: "flex items-center justify-between mb-3", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide", children: "Access requests" }), !matter.closedAt && (_jsx(Button, { type: "button", className: "!w-auto", onClick: () => setShowNewRequest(true), children: "+ New access request" }))] }), requests.loadError && _jsx(Alert, { children: requests.loadError }), matterRequests.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: requests.loading ? "Loading…" : "No access requests yet." })) : (_jsx("ul", { className: "flex flex-col gap-3", children: matterRequests.map((request) => (_jsxs("li", { className: "border border-border rounded-sm p-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-3 flex-wrap", children: [_jsxs("div", { children: [_jsx("div", { className: "text-sm font-semibold", children: request.mailboxUid }), _jsxs("div", { className: "text-xs text-text-muted", children: [request.approvals.length, " of ", request.requiredHoldersAtCreation, " approvals \u00B7 requested by ", request.requestedByUserUid] })] }), _jsx("span", { className: [
226
261
  "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill",
227
262
  request.status === "denied"
228
263
  ? "bg-danger-bg text-danger"
229
264
  : request.status === "pending"
230
265
  ? "bg-surface-alt text-text-muted"
231
266
  : "bg-success text-white",
232
- ].join(" "), children: request.status })] }), actionErrors[request.uid] && _jsx(Alert, { children: actionErrors[request.uid] }), _jsxs("div", { className: "flex gap-3 mt-3", children: [request.status === "pending" && (_jsxs(_Fragment, { 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", loading: actingOn[request.uid], disabled: actingOn[request.uid], onClick: () => handleDeny(request), children: "Deny" })] })), (request.status === "approved" || request.status === "fulfilled") && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => handleGetMaterial(request), children: "Get material" }))] })] }, request.uid))) }))] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("div", { className: "flex items-center justify-between mb-3", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide", children: "Export this matter" }), _jsx(Button, { type: "button", className: "!w-auto", loading: creatingExport, disabled: creatingExport, onClick: handleCreateExport, children: "+ New export" })] }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Exports messages (narrowed to this matter\u2019s own date range), plus full contacts, calendar events, tasks, and notes, from every custodian mailbox actually assigned to this matter\u2019s escrow scope, as one combined NDJSON file." }), exportError && _jsx(Alert, { children: exportError }), exportRequests.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No export requests yet." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: exportRequests.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: [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: matterExportRequestDownloadUrl(request.uid), className: "text-primary-dark hover:underline font-medium", children: "Download" }))] })] }, request.uid))) }))] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-3", children: "Search this matter\u2019s custodians" }), _jsxs("form", { onSubmit: handleSearch, className: "flex gap-3 mb-4", children: [_jsx("input", { "aria-label": "Search this matter", className: INPUT_CLASS, value: searchText, onChange: (e) => setSearchText(e.target.value), placeholder: "Free text or an operator filter, e.g. from:alice@example.com" }), _jsx(Button, { type: "submit", className: "!w-auto shrink-0", loading: searching, disabled: searching || !searchText.trim(), children: "Search" })] }), searchError && _jsx(Alert, { children: searchError }), searchResults &&
233
- (Object.keys(searchResults).length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No custodian mailboxes could be searched." })) : (_jsx("div", { className: "flex flex-col gap-4", children: Object.entries(searchResults).map(([mailboxUid, page]) => (_jsxs("div", { children: [_jsx("h3", { className: "text-sm font-semibold mb-2", children: mailboxUid }), page.results.length === 0 ? (_jsx("p", { className: "text-xs text-text-muted", children: "No matches." })) : (_jsx("ul", { className: "flex flex-col gap-1", children: page.results.map((result) => (_jsxs("li", { className: "text-sm py-1 px-2 bg-surface-alt rounded-sm", children: [result.entityType, " \u00B7 ", result.entityUid, result.snippet && _jsxs("span", { className: "text-text-muted", children: [" \u2014 ", result.snippet] })] }, `${result.entityType}-${result.entityUid}`))) }))] }, mailboxUid))) })))] }), _jsx(Modal, { open: showNewRequest, onClose: () => setShowNewRequest(false), title: "New access request", children: _jsxs("form", { onSubmit: handleCreateRequest, className: "flex flex-col gap-4", children: [newRequestError && _jsx(Alert, { children: newRequestError }), _jsx(FormField, { label: "Mailbox uid", htmlFor: "newRequestMailboxUid", children: _jsx("input", { id: "newRequestMailboxUid", type: "text", className: INPUT_CLASS, value: newRequestMailboxUid, onChange: (e) => setNewRequestMailboxUid(e.target.value) }) }), _jsx("p", { className: "text-xs text-text-muted", children: "Must be one of this matter's own custodian mailboxes, currently assigned to this matter's escrow scope. Your own creation counts as the first approval." }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: creatingRequest, onClick: () => setShowNewRequest(false), children: "Cancel" }), _jsx(Button, { type: "submit", className: "!w-auto", loading: creatingRequest, disabled: creatingRequest, children: "Create request" })] })] }) }), _jsx(Modal, { open: materialFor !== null, onClose: closeMaterialModal, title: "Escrow key material", children: loadingMaterial ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : materialError ? (_jsx(Alert, { children: materialError })) : (_jsxs(_Fragment, { children: [_jsxs("p", { className: "text-sm text-text-muted mb-3", children: ["Still-encrypted master key wraps for ", _jsx("strong", { children: materialFor?.mailboxUid }), " \u2014 this server cannot decrypt these either. Copy the JSON below into your own offline tooling."] }), _jsx("pre", { className: "text-xs font-mono bg-surface-alt border border-border rounded-sm p-3 overflow-x-auto whitespace-pre-wrap break-all", children: materialText })] })) })] }));
267
+ ].join(" "), children: request.status })] }), actionErrors[request.uid] && _jsx(Alert, { children: actionErrors[request.uid] }), _jsxs("div", { className: "flex gap-3 mt-3", children: [!matter.closedAt && request.status === "pending" && (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", className: "!w-auto", loading: actingOn[request.uid], disabled: actingOn[request.uid], onClick: () => setApproveTarget(request), children: "Approve" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: actingOn[request.uid], disabled: actingOn[request.uid], onClick: () => handleDeny(request), children: "Deny" })] })), !matter.closedAt && (request.status === "approved" || request.status === "fulfilled") && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => handleGetMaterial(request), children: "Get material" }))] })] }, request.uid))) })), _jsx(LoadMoreButton, { list: requests, label: "Load more access requests" })] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("div", { className: "flex items-center justify-between mb-3", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide", children: "Export this matter" }), !matter.closedAt && (_jsx(Button, { type: "button", className: "!w-auto", loading: creatingExport, disabled: creatingExport, onClick: handleCreateExport, children: "+ New export" }))] }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Exports messages (narrowed to this matter\u2019s own date range), plus full contacts, calendar events, tasks, and notes, from every custodian mailbox actually assigned to this matter\u2019s escrow scope, as one combined NDJSON file." }), exportError && _jsx(Alert, { children: exportError }), exportRequests.loadError && _jsx(Alert, { children: exportRequests.loadError }), matterExportRequests.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: exportRequests.loading ? "Loading…" : "No export requests yet." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: matterExportRequests.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: [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) }), !matter.closedAt && request.status === "ready" && (_jsx("a", { href: matterExportRequestDownloadUrl(request.uid), className: "text-primary-dark hover:underline font-medium", children: "Download" }))] })] }, request.uid))) }))] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-3", children: "Search this matter\u2019s custodians" }), matter.closedAt ? (_jsx("p", { className: "text-sm text-text-muted", children: "This matter is closed, so its custodians can no longer be searched or exported." })) : (_jsxs("form", { onSubmit: handleSearch, className: "flex gap-3 mb-4", children: [_jsx("input", { "aria-label": "Search this matter", className: INPUT_CLASS, value: searchText, onChange: (e) => setSearchText(e.target.value), placeholder: "Free text or an operator filter, e.g. from:alice@example.com" }), _jsx(Button, { type: "submit", className: "!w-auto shrink-0", loading: searching, disabled: searching || !searchText.trim(), children: "Search" })] })), searchError && _jsx(Alert, { children: searchError }), searchResults &&
268
+ (Object.keys(searchResults).length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No custodian mailboxes could be searched." })) : (_jsx("div", { className: "flex flex-col gap-4", children: Object.entries(searchResults).map(([mailboxUid, page]) => (_jsxs("div", { children: [_jsx("h3", { className: "text-sm font-semibold mb-2", children: mailboxUid }), page.results.length === 0 ? (_jsx("p", { className: "text-xs text-text-muted", children: "No matches." })) : (_jsx("ul", { className: "flex flex-col gap-1", children: page.results.map((result) => (_jsxs("li", { className: "text-sm py-1 px-2 bg-surface-alt rounded-sm", children: [result.entityType, " \u00B7 ", result.entityUid, result.snippet && _jsxs("span", { className: "text-text-muted", children: [" \u2014 ", result.snippet] })] }, `${result.entityType}-${result.entityUid}`))) }))] }, mailboxUid))) })))] }), _jsxs(Modal, { open: confirmingClose, onClose: closeCloseModal, title: "Close matter", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Close ", _jsx("strong", { children: matter.name }), "? Once closed, no new access requests, exports, searches, or key material reads are allowed under it. This cannot be reopened here."] }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm mb-4", children: [_jsx("dt", { className: "text-text-muted", children: "Custodians" }), _jsx("dd", { className: "break-all", children: matter.custodianMailboxUids.join(", ") }), _jsx("dt", { className: "text-text-muted", children: "Date range" }), _jsx("dd", { children: formatDateRange(matter) })] }), closeError && _jsx(Alert, { children: closeError }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: closing, onClick: closeCloseModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: closing, disabled: closing, onClick: handleClose, children: "Close matter" })] })] }), _jsxs(Modal, { open: approveTarget !== null, onClose: () => setApproveTarget(null), title: "Approve access request", children: [_jsx("p", { className: "text-sm mb-3", children: "Approving lets holders of this matter\u2019s escrow scope read the key material for this mailbox once enough holders have approved." }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm mb-4", children: [_jsx("dt", { className: "text-text-muted", children: "Matter" }), _jsx("dd", { children: matter.name }), _jsx("dt", { className: "text-text-muted", children: "Mailbox" }), _jsx("dd", { className: "break-all", children: approveTarget?.mailboxUid }), _jsx("dt", { className: "text-text-muted", children: "Date range" }), _jsx("dd", { children: formatDateRange(matter) }), _jsx("dt", { className: "text-text-muted", children: "Approvals" }), _jsxs("dd", { children: [approveTarget?.approvals.length, " of ", approveTarget?.requiredHoldersAtCreation, " so far, including requester ", approveTarget?.requestedByUserUid] })] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setApproveTarget(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => void handleApprove(), children: "Approve request" })] })] }), _jsx(Modal, { open: showNewRequest, onClose: () => setShowNewRequest(false), title: "New access request", children: _jsxs("form", { onSubmit: handleCreateRequest, className: "flex flex-col gap-4", children: [newRequestError && _jsx(Alert, { children: newRequestError }), _jsx(FormField, { label: "Mailbox uid", htmlFor: "newRequestMailboxUid", children: _jsx("input", { id: "newRequestMailboxUid", type: "text", className: INPUT_CLASS, value: newRequestMailboxUid, onChange: (e) => setNewRequestMailboxUid(e.target.value) }) }), _jsx("p", { className: "text-xs text-text-muted", children: "Must be one of this matter's own custodian mailboxes, currently assigned to this matter's escrow scope. Your own creation counts as the first approval." }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: creatingRequest, onClick: () => setShowNewRequest(false), children: "Cancel" }), _jsx(Button, { type: "submit", className: "!w-auto", loading: creatingRequest, disabled: creatingRequest, children: "Create request" })] })] }) }), _jsx(Modal, { open: materialFor !== null, onClose: closeMaterialModal, title: "Escrow key material", children: loadingMaterial ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : materialError ? (_jsx(Alert, { children: materialError })) : (_jsxs(_Fragment, { children: [_jsxs("p", { className: "text-sm text-text-muted mb-3", children: ["Still-encrypted master key wraps for ", _jsx("strong", { children: materialFor?.mailboxUid }), " \u2014 this server cannot decrypt these either. Copy the JSON below into your own offline tooling."] }), _jsx("pre", { className: "text-xs font-mono bg-surface-alt border border-border rounded-sm p-3 overflow-x-auto whitespace-pre-wrap break-all", children: materialText })] })) })] }));
234
269
  }
@@ -18,9 +18,18 @@ export function emptyEscrowScopeKeyAndHoldersValue() {
18
18
  notifySubjectOnAccess: false,
19
19
  };
20
20
  }
21
- export default function EscrowScopeKeyAndHoldersFields({ value, onChange, disabled }) {
21
+ /** The message shown when an administrator tries to make themselves a holder: whoever configures a scope must not
22
+ * also be able to approve access under it (separation of duties - the server refuses it too). Holders who were
23
+ * already on the scope (`existingHolderUserUids`) aren't treated as newly added. */
24
+ export function selfAsHolderError(holderUserUids, adminUid, existingHolderUserUids = []) {
25
+ if (!adminUid || !holderUserUids.includes(adminUid) || existingHolderUserUids.includes(adminUid)) {
26
+ return null;
27
+ }
28
+ return "You can't add yourself as a holder of an escrow scope you configure. Ask another administrator to add you, or choose different holders.";
29
+ }
30
+ export default function EscrowScopeKeyAndHoldersFields({ value, onChange, disabled, keyReadOnly }) {
22
31
  function set(key, next) {
23
32
  onChange({ ...value, [key]: next });
24
33
  }
25
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-1", children: "Public key" }), _jsx("p", { className: "text-sm text-text-muted mb-4", children: "The fields of an already-issued certificate \u2014 this server never generates or holds the corresponding private key." }), _jsx(FormField, { label: "Public key (base64)", htmlFor: "publicKey", children: _jsx("textarea", { id: "publicKey", className: `${INPUT_CLASS} font-mono text-xs`, rows: 4, value: value.publicKey, disabled: disabled, onChange: (e) => set("publicKey", e.target.value) }) }), _jsx(FormField, { label: "Key type", htmlFor: "keyType", children: _jsx("input", { id: "keyType", type: "text", className: INPUT_CLASS, value: value.keyType, disabled: disabled, onChange: (e) => set("keyType", e.target.value), placeholder: "x509" }) }), _jsx(FormField, { label: "Fingerprint (hex SHA-256)", htmlFor: "fingerprint", children: _jsx("input", { id: "fingerprint", type: "text", className: `${INPUT_CLASS} font-mono`, value: value.fingerprint, disabled: disabled, onChange: (e) => set("fingerprint", e.target.value) }) }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-4", children: [_jsx(FormField, { label: "Not before", htmlFor: "notBefore", children: _jsx("input", { id: "notBefore", type: "datetime-local", className: INPUT_CLASS, value: value.notBefore, disabled: disabled, onChange: (e) => set("notBefore", e.target.value) }) }), _jsx(FormField, { label: "Not after", htmlFor: "notAfter", children: _jsx("input", { id: "notAfter", type: "datetime-local", className: INPUT_CLASS, value: value.notAfter, disabled: disabled, onChange: (e) => set("notAfter", e.target.value) }) })] })] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-1", children: "Holders & dual control" }), _jsx("p", { className: "text-sm text-text-muted mb-4", children: "Every uid below is granted the eDiscovery/compliance holder role for this scope \u2014 a deliberately separate role from server administration. At least one holder is required." }), _jsx(StringListField, { label: "Holder user uids", id: "holderUserUids", values: value.holderUserUids, onChange: (holderUserUids) => set("holderUserUids", holderUserUids), placeholder: "User uid to add", emptyMessage: "No holders added yet.", disabled: disabled }), _jsx(FormField, { label: "Required holders (M-of-N dual control)", htmlFor: "requiredHolders", children: _jsx("input", { id: "requiredHolders", type: "number", className: INPUT_CLASS, value: value.requiredHolders, disabled: disabled, onChange: (e) => set("requiredHolders", Number(e.target.value) || 1) }) }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: value.notifySubjectOnAccess, disabled: disabled, onChange: (e) => set("notifySubjectOnAccess", e.target.checked) }), "Notify subject on access"] })] })] }));
34
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-1", children: "Public key" }), _jsx("p", { className: "text-sm text-text-muted mb-4", children: "The fields of an already-issued certificate \u2014 this server never generates or holds the corresponding private key." }), _jsx(FormField, { label: "Public key (base64)", htmlFor: "publicKey", children: _jsx("textarea", { id: "publicKey", className: `${INPUT_CLASS} font-mono text-xs`, rows: 4, value: value.publicKey, disabled: disabled, readOnly: keyReadOnly, onChange: (e) => set("publicKey", e.target.value) }) }), _jsx(FormField, { label: "Key type", htmlFor: "keyType", children: _jsx("input", { id: "keyType", type: "text", className: INPUT_CLASS, value: value.keyType, disabled: disabled, readOnly: keyReadOnly, onChange: (e) => set("keyType", e.target.value), placeholder: "x509" }) }), _jsx(FormField, { label: "Fingerprint (hex SHA-256)", htmlFor: "fingerprint", children: _jsx("input", { id: "fingerprint", type: "text", className: `${INPUT_CLASS} font-mono`, value: value.fingerprint, disabled: disabled, readOnly: keyReadOnly, onChange: (e) => set("fingerprint", e.target.value) }) }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-4", children: [_jsx(FormField, { label: "Not before", htmlFor: "notBefore", children: _jsx("input", { id: "notBefore", type: "datetime-local", className: INPUT_CLASS, value: value.notBefore, disabled: disabled, readOnly: keyReadOnly, onChange: (e) => set("notBefore", e.target.value) }) }), _jsx(FormField, { label: "Not after", htmlFor: "notAfter", children: _jsx("input", { id: "notAfter", type: "datetime-local", className: INPUT_CLASS, value: value.notAfter, disabled: disabled, readOnly: keyReadOnly, onChange: (e) => set("notAfter", e.target.value) }) })] })] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-1", children: "Holders & dual control" }), _jsx("p", { className: "text-sm text-text-muted mb-4", children: "Every uid below is granted the eDiscovery/compliance holder role for this scope \u2014 a deliberately separate role from server administration. At least one holder is required." }), _jsx(StringListField, { label: "Holder user uids", id: "holderUserUids", values: value.holderUserUids, onChange: (holderUserUids) => set("holderUserUids", holderUserUids), placeholder: "User uid to add", emptyMessage: "No holders added yet.", disabled: disabled }), _jsx(FormField, { label: "Required holders (M-of-N dual control)", htmlFor: "requiredHolders", children: _jsx("input", { id: "requiredHolders", type: "number", className: INPUT_CLASS, value: value.requiredHolders, disabled: disabled, onChange: (e) => set("requiredHolders", Number(e.target.value) || 1) }) }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: value.notifySubjectOnAccess, disabled: disabled, onChange: (e) => set("notifySubjectOnAccess", e.target.checked) }), "Notify subject on access"] })] })] }));
26
35
  }
@@ -14,6 +14,7 @@ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
14
14
  import BottomTabBar from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
15
15
  import { BrandingFooter, BrandingHeader } from "../../layout/BrandingChrome.js";
16
16
  import UserMenu from "../../layout/UserMenu.js";
17
+ import { signOutOfConsole } from "../signOut.js";
17
18
  /** Sections shown in the persistent icon rail / mobile tab bar — every admin area reachable from
18
19
  * anywhere in the console. Deliberately excludes `quarantine`/`ingestQueue`: those are scoped to a
19
20
  * single mailbox and only ever reached via links on that mailbox's own detail page (see
@@ -118,7 +119,8 @@ export default function AdminShell({ active, userUid, authServerUrl, children })
118
119
  });
119
120
  }, [userUid]);
120
121
  function handleSignOut() {
121
- window.location.href = authServerUrl ?? "/";
122
+ // Ends the auth-server session and tells other tabs, not just navigates - see `signOutOfConsole()`.
123
+ void signOutOfConsole(authServerUrl);
122
124
  }
123
125
  let content;
124
126
  if (!userUid || status === "checking") {
@@ -0,0 +1,72 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useEffect, useState } from "react";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { listEscrowScopes } from "@rapidmx/react-shared/admin/escrowScopesApi.js";
9
+ import { updateMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
10
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
12
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
13
+ const SELECT_CLASS = "text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
14
+ /** Enough for any realistic deployment's scopes in one list. */
15
+ const SCOPE_LIST_LIMIT = 200;
16
+ /**
17
+ * Assigns a mailbox to an escrow scope (or none), which decides whose holders can recover its encrypted mail. Only
18
+ * shown in the admin console, whose trusted-role gate the server enforces again for `escrowScopeId`.
19
+ */
20
+ export default function EscrowScopeCard({ mailbox, onUpdate }) {
21
+ const [scopes, setScopes] = useState(null);
22
+ const [loadError, setLoadError] = useState(null);
23
+ const [escrowScopeId, setEscrowScopeId] = useState(mailbox.escrowScopeId ?? "");
24
+ const [saving, setSaving] = useState(false);
25
+ const [saveError, setSaveError] = useState(null);
26
+ const [saved, setSaved] = useState(false);
27
+ const [confirming, setConfirming] = useState(false);
28
+ useEffect(() => {
29
+ listEscrowScopes({ limit: SCOPE_LIST_LIMIT })
30
+ .then(setScopes)
31
+ .catch((err) => setLoadError(err instanceof ApiRequestError ? err.message : "Could not load escrow scopes."));
32
+ }, []);
33
+ // Changing the scope changes who can recover this mailbox's encrypted mail, so it's confirmed first.
34
+ function handleSubmit(e) {
35
+ e.preventDefault();
36
+ setConfirming(true);
37
+ }
38
+ async function save() {
39
+ setConfirming(false);
40
+ setSaveError(null);
41
+ setSaved(false);
42
+ setSaving(true);
43
+ try {
44
+ const updated = await updateMailbox({ uid: mailbox.uid, version: mailbox.version, escrowScopeId: escrowScopeId || null });
45
+ onUpdate(updated);
46
+ setSaved(true);
47
+ }
48
+ catch (err) {
49
+ setSaveError(err instanceof ApiRequestError ? err.message : "Could not save the escrow scope.");
50
+ }
51
+ finally {
52
+ setSaving(false);
53
+ }
54
+ }
55
+ const current = mailbox.escrowScopeId ?? "";
56
+ const unknownCurrent = !!current && !!scopes && !scopes.some((scope) => scope.uid === current);
57
+ return (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-1", children: "Escrow scope" }), _jsx("p", { className: "text-sm text-text-muted mb-4", children: "The holders of this scope can jointly recover this mailbox\u2019s encrypted mail, for example for legal discovery. With no scope, nobody can." }), loadError && _jsx(Alert, { children: loadError }), saveError && _jsx(Alert, { children: saveError }), saved && !saveError && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), scopes && (_jsxs("form", { onSubmit: handleSubmit, className: "flex items-end gap-3 flex-wrap", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Scope" }), _jsxs("select", { "aria-label": "Escrow scope", className: SELECT_CLASS, value: escrowScopeId, onChange: (e) => {
58
+ setEscrowScopeId(e.target.value);
59
+ setSaved(false);
60
+ }, children: [_jsx("option", { value: "", children: "No escrow" }), unknownCurrent && _jsxs("option", { value: current, children: ["Unknown scope (", current, ")"] }), scopes.map((scope) => (_jsx("option", { value: scope.uid, children: scope.name }, scope.uid)))] })] }), _jsx(Button, { type: "submit", className: "!w-auto", loading: saving, disabled: saving || escrowScopeId === current, children: "Save escrow scope" })] })), _jsxs(Modal, { open: confirming, onClose: () => setConfirming(false), title: "Change escrow scope", children: [_jsxs("p", { className: "text-sm mb-3", children: ["This changes who can jointly recover ", _jsx("strong", { className: "break-all", children: mailbox.primarySmtpAddress }), "\u2019s encrypted mail."] }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm mb-4", children: [_jsx("dt", { className: "text-text-muted", children: "From" }), _jsx("dd", { className: "break-all", children: _jsx(ScopeSummary, { uid: current, scopes: scopes ?? [] }) }), _jsx("dt", { className: "text-text-muted", children: "To" }), _jsx("dd", { className: "break-all", children: _jsx(ScopeSummary, { uid: escrowScopeId, scopes: scopes ?? [] }) })] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirming(false), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => void save(), children: "Confirm and save" })] })] })] }));
61
+ }
62
+ /** A scope's name, holders and required approvals - or what having no (or an unlisted) scope means. */
63
+ function ScopeSummary({ uid, scopes }) {
64
+ if (!uid) {
65
+ return _jsx("span", { children: "No escrow - nobody can recover this mailbox\u2019s encrypted mail" });
66
+ }
67
+ const scope = scopes.find((candidate) => candidate.uid === uid);
68
+ if (!scope) {
69
+ return _jsxs("span", { children: ["Unknown scope (", uid, ")"] });
70
+ }
71
+ return (_jsxs("span", { children: [_jsx("strong", { children: scope.name }), " - holders ", scope.holderUserUids.join(", "), "; ", scope.requiredHolders, " of", " ", scope.holderUserUids.length, " must approve"] }));
72
+ }
@@ -39,11 +39,11 @@ export default function ResourceSettingsCard({ mailbox, onUpdate }) {
39
39
  uid: mailbox.uid,
40
40
  version: mailbox.version,
41
41
  resourceType,
42
- resourceCapacity: resourceCapacity ? Number(resourceCapacity) : undefined,
42
+ resourceCapacity: resourceCapacity ? Number(resourceCapacity) : null,
43
43
  autoAcceptBookings,
44
44
  allowConflicts,
45
- bookingWindowDays: bookingWindowDays ? Number(bookingWindowDays) : undefined,
46
- maxDurationMinutes: maxDurationMinutes ? Number(maxDurationMinutes) : undefined,
45
+ bookingWindowDays: bookingWindowDays ? Number(bookingWindowDays) : null,
46
+ maxDurationMinutes: maxDurationMinutes ? Number(maxDurationMinutes) : null,
47
47
  });
48
48
  onUpdate(updated);
49
49
  setSaved(true);
@@ -8,18 +8,21 @@ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
8
  import { getMailboxAcl, grantMailboxAccess, revokeMailboxAccess } from "@rapidmx/react-shared/mail/mailApi.js";
9
9
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
10
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
11
12
  const DEFAULT_DELEGATE_ACTIONS = ["read", "list", "count", "exists"];
12
13
  /**
13
14
  * Lets an admin (or, eventually, the mailbox owner) view and edit a mailbox's delegate access — the
14
15
  * mechanism behind Exchange-style shared mailboxes. `records` on a mailbox's own ACL doubles as both the
15
16
  * owner's grant (if any) and every delegate's — see `BaseMailboxRoute`'s doc comment in `@rapidmx/restapi`.
16
17
  */
17
- export default function ShareAccessCard({ mailboxUid }) {
18
+ export default function ShareAccessCard({ mailboxUid, ownerUserUid }) {
18
19
  const [acl, setAcl] = useState(null);
19
20
  const [loading, setLoading] = useState(true);
20
21
  const [error, setError] = useState(null);
22
+ const [notice, setNotice] = useState(null);
21
23
  const [newUserUid, setNewUserUid] = useState("");
22
24
  const [saving, setSaving] = useState(false);
25
+ const [revokeTarget, setRevokeTarget] = useState(null);
23
26
  function reload() {
24
27
  setLoading(true);
25
28
  setError(null);
@@ -37,8 +40,19 @@ export default function ShareAccessCard({ mailboxUid }) {
37
40
  }
38
41
  setSaving(true);
39
42
  setError(null);
43
+ setNotice(null);
40
44
  try {
41
- await grantMailboxAccess(mailboxUid, userUid, DEFAULT_DELEGATE_ACTIONS);
45
+ // `grantMailboxAccess()` replaces any existing record for this uid, so granting read access to someone
46
+ // who already has more (e.g. the owner) would silently downgrade them. Merge with their current actions.
47
+ const current = await getMailboxAcl(mailboxUid);
48
+ const existing = current.records.find((record) => record.userOrRoleId === userUid)?.actions ?? [];
49
+ const missing = DEFAULT_DELEGATE_ACTIONS.filter((action) => !existing.includes(action));
50
+ if (missing.length === 0) {
51
+ setNotice(`${userUid} already has this access.`);
52
+ setNewUserUid("");
53
+ return;
54
+ }
55
+ await grantMailboxAccess(mailboxUid, userUid, [...existing, ...missing]);
42
56
  setNewUserUid("");
43
57
  reload();
44
58
  }
@@ -49,9 +63,13 @@ export default function ShareAccessCard({ mailboxUid }) {
49
63
  setSaving(false);
50
64
  }
51
65
  }
52
- async function handleRevoke(userOrRoleId) {
66
+ // Only ever invoked from the revoke-confirmation modal below, which only renders once `revokeTarget` is set.
67
+ async function handleRevoke() {
68
+ const userOrRoleId = revokeTarget;
69
+ setRevokeTarget(null);
53
70
  setSaving(true);
54
71
  setError(null);
72
+ setNotice(null);
55
73
  try {
56
74
  await revokeMailboxAccess(mailboxUid, userOrRoleId);
57
75
  reload();
@@ -63,5 +81,5 @@ export default function ShareAccessCard({ mailboxUid }) {
63
81
  setSaving(false);
64
82
  }
65
83
  }
66
- return (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-1", children: "Shared access" }), _jsx("p", { className: "text-sm text-text-muted mb-4", children: "Grants read access to this mailbox for another user, independent of ownership." }), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : (_jsxs("ul", { className: "flex flex-col gap-2 mb-4", children: [(acl?.records ?? []).length === 0 && (_jsx("li", { className: "text-sm text-text-muted", children: "No grants on this mailbox yet." })), acl?.records.map((record) => (_jsxs("li", { className: "flex items-center justify-between gap-3 py-2 px-3 border border-border rounded-sm", children: [_jsxs("div", { children: [_jsx("div", { className: "text-sm font-medium", children: record.userOrRoleId }), _jsx("div", { className: "text-xs text-text-muted", children: record.actions.join(", ") })] }), _jsx("button", { type: "button", onClick: () => handleRevoke(record.userOrRoleId), disabled: saving, className: "text-sm text-danger hover:underline disabled:opacity-55", children: "Revoke" })] }, record.userOrRoleId)))] })), _jsxs("form", { onSubmit: handleGrant, className: "flex gap-2", children: [_jsx("input", { type: "text", value: newUserUid, onChange: (e) => setNewUserUid(e.target.value), placeholder: "User uid to grant access to", className: "flex-1 text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary" }), _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Grant" })] })] }));
84
+ return (_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-1", children: "Shared access" }), _jsx("p", { className: "text-sm text-text-muted mb-4", children: "Grants read access to this mailbox for another user, independent of ownership." }), error && _jsx(Alert, { children: error }), notice && !error && _jsx("div", { className: "mb-4 text-sm text-text-muted", children: notice }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : (_jsxs("ul", { className: "flex flex-col gap-2 mb-4", children: [(acl?.records ?? []).length === 0 && (_jsx("li", { className: "text-sm text-text-muted", children: "No grants on this mailbox yet." })), acl?.records.map((record) => (_jsxs("li", { className: "flex items-center justify-between gap-3 py-2 px-3 border border-border rounded-sm", children: [_jsxs("div", { children: [_jsx("div", { className: "text-sm font-medium", children: record.userOrRoleId }), _jsx("div", { className: "text-xs text-text-muted", children: record.actions.join(", ") })] }), record.userOrRoleId === ownerUserUid ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "Owner" })) : (_jsx("button", { type: "button", onClick: () => setRevokeTarget(record.userOrRoleId), disabled: saving, className: "text-sm text-danger hover:underline disabled:opacity-55", children: "Revoke" }))] }, record.userOrRoleId)))] })), _jsxs("form", { onSubmit: handleGrant, className: "flex gap-2", children: [_jsx("input", { type: "text", value: newUserUid, onChange: (e) => setNewUserUid(e.target.value), placeholder: "User uid to grant access to", className: "flex-1 text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary" }), _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Grant" })] }), _jsxs(Modal, { open: revokeTarget !== null, onClose: () => setRevokeTarget(null), title: "Revoke access", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Revoke ", _jsx("strong", { className: "break-all", children: revokeTarget }), "\u2019s access to this mailbox? They lose it immediately."] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setRevokeTarget(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: () => void handleRevoke(), children: "Revoke" })] })] })] }));
67
85
  }
@@ -10,6 +10,11 @@ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
10
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
11
  const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
12
12
  const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
13
+ /** SVG logo/icon uploads are refused (the server refuses them too): an SVG served from this origin can carry script. */
14
+ export function isSvgFile(file) {
15
+ return file.type === "image/svg+xml" || file.name.toLowerCase().endsWith(".svg");
16
+ }
17
+ const RASTER_IMAGE_ACCEPT = "image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon";
13
18
  /** The branding editor, shared by the Branding page and the setup wizard. */
14
19
  export default function BrandingForm({ branding, onChange }) {
15
20
  const [companyName, setCompanyName] = useState(branding.companyName);
@@ -43,7 +48,10 @@ export default function BrandingForm({ branding, onChange }) {
43
48
  const file = e.target.files?.[0];
44
49
  if (!file)
45
50
  return;
46
- if (file.size > MAX_UPLOAD_BYTES) {
51
+ if (isSvgFile(file)) {
52
+ setError(`"${file.name}" is an SVG — upload a PNG, JPEG, GIF, or WebP logo instead.`);
53
+ }
54
+ else if (file.size > MAX_UPLOAD_BYTES) {
47
55
  setError(`"${file.name}" is too large — logos must be 5MB or smaller.`);
48
56
  }
49
57
  else {
@@ -55,7 +63,10 @@ export default function BrandingForm({ branding, onChange }) {
55
63
  const file = e.target.files?.[0];
56
64
  if (!file)
57
65
  return;
58
- if (file.size > MAX_UPLOAD_BYTES) {
66
+ if (isSvgFile(file)) {
67
+ setError(`"${file.name}" is an SVG — upload a PNG, JPEG, GIF, WebP, or ICO icon instead.`);
68
+ }
69
+ else if (file.size > MAX_UPLOAD_BYTES) {
59
70
  setError(`"${file.name}" is too large — icons must be 5MB or smaller.`);
60
71
  }
61
72
  else {
@@ -92,10 +103,10 @@ export default function BrandingForm({ branding, onChange }) {
92
103
  setSaving(false);
93
104
  }
94
105
  }
95
- return (_jsxs("div", { className: "max-w-2xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "Branding" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Customize the logo, nav-header icon, product name, and chrome shown to every visitor of the webmail and admin console \u2014 including anonymous booking-page visitors." }), error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), _jsxs("div", { className: "flex flex-col gap-6", children: [_jsxs("section", { className: "flex flex-col gap-3", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted", children: "Logo" }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsx("img", { src: branding.logoUrl || "/images/logo.svg", alt: "Current logo", width: "64", height: "64", className: "border border-border rounded-sm bg-surface-alt p-1" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: assetBusy === "logo-upload", disabled: assetBusy !== null, onClick: () => logoFileRef.current?.click(), children: "Upload logo" }), _jsx("input", { ref: logoFileRef, type: "file", accept: "image/*", "aria-label": "Upload logo", className: "hidden", onChange: handleLogoFileChange }), branding.logoUrl && (_jsx(Button, { type: "button", variant: "text", disabled: assetBusy !== null, loading: assetBusy === "logo-delete", onClick: () => void runAsset("logo-delete", async () => {
106
+ return (_jsxs("div", { className: "max-w-2xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "Branding" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Customize the logo, nav-header icon, product name, and chrome shown to every visitor of the webmail and admin console \u2014 including anonymous booking-page visitors." }), error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), _jsxs("div", { className: "flex flex-col gap-6", children: [_jsxs("section", { className: "flex flex-col gap-3", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted", children: "Logo" }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsx("img", { src: branding.logoUrl || "/images/logo.svg", alt: "Current logo", width: "64", height: "64", className: "border border-border rounded-sm bg-surface-alt p-1" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: assetBusy === "logo-upload", disabled: assetBusy !== null, onClick: () => logoFileRef.current?.click(), children: "Upload logo" }), _jsx("input", { ref: logoFileRef, type: "file", accept: RASTER_IMAGE_ACCEPT, "aria-label": "Upload logo", className: "hidden", onChange: handleLogoFileChange }), branding.logoUrl && (_jsx(Button, { type: "button", variant: "text", disabled: assetBusy !== null, loading: assetBusy === "logo-delete", onClick: () => void runAsset("logo-delete", async () => {
96
107
  await deleteBrandingLogo();
97
108
  return { ...branding, logoUrl: undefined };
98
- }), children: "Remove logo" }))] })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Or use an external image URL" }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { "aria-label": "Logo URL", className: INPUT_CLASS, value: logoUrlInput, onChange: (e) => setLogoUrlInput(e.target.value) }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: assetBusy !== null || logoUrlInput === (branding.logoUrl ?? ""), loading: assetBusy === "logo-url", onClick: () => void runAsset("logo-url", () => updateBranding({ logoUrl: logoUrlInput })), children: "Set" })] })] })] }), _jsxs("section", { className: "flex flex-col gap-3", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted", children: "Icon" }), _jsx("p", { className: "text-xs text-text-muted -mt-1", children: "A compact mark for navigation headers, independent of the full logo above. Falls back to the logo, then a default asset, when not set." }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsx("img", { src: branding.iconUrl || branding.logoUrl || "/images/logo.svg", alt: "Current icon", width: "64", height: "64", className: "border border-border rounded-sm bg-surface-alt p-1" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: assetBusy === "icon-upload", disabled: assetBusy !== null, onClick: () => iconFileRef.current?.click(), children: "Upload icon" }), _jsx("input", { ref: iconFileRef, type: "file", accept: "image/*", "aria-label": "Upload icon", className: "hidden", onChange: handleIconFileChange }), branding.iconUrl && (_jsx(Button, { type: "button", variant: "text", disabled: assetBusy !== null, loading: assetBusy === "icon-delete", onClick: () => void runAsset("icon-delete", async () => {
109
+ }), children: "Remove logo" }))] })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Or use an external image URL" }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { "aria-label": "Logo URL", className: INPUT_CLASS, value: logoUrlInput, onChange: (e) => setLogoUrlInput(e.target.value) }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: assetBusy !== null || logoUrlInput === (branding.logoUrl ?? ""), loading: assetBusy === "logo-url", onClick: () => void runAsset("logo-url", () => updateBranding({ logoUrl: logoUrlInput })), children: "Set" })] })] })] }), _jsxs("section", { className: "flex flex-col gap-3", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted", children: "Icon" }), _jsx("p", { className: "text-xs text-text-muted -mt-1", children: "A compact mark for navigation headers, independent of the full logo above. Falls back to the logo, then a default asset, when not set." }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsx("img", { src: branding.iconUrl || branding.logoUrl || "/images/logo.svg", alt: "Current icon", width: "64", height: "64", className: "border border-border rounded-sm bg-surface-alt p-1" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: assetBusy === "icon-upload", disabled: assetBusy !== null, onClick: () => iconFileRef.current?.click(), children: "Upload icon" }), _jsx("input", { ref: iconFileRef, type: "file", accept: RASTER_IMAGE_ACCEPT, "aria-label": "Upload icon", className: "hidden", onChange: handleIconFileChange }), branding.iconUrl && (_jsx(Button, { type: "button", variant: "text", disabled: assetBusy !== null, loading: assetBusy === "icon-delete", onClick: () => void runAsset("icon-delete", async () => {
99
110
  await deleteBrandingIcon();
100
111
  return { ...branding, iconUrl: undefined };
101
112
  }), children: "Remove icon" }))] })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Or use an external image URL" }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { "aria-label": "Icon URL", className: INPUT_CLASS, value: iconUrlInput, onChange: (e) => setIconUrlInput(e.target.value) }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: assetBusy !== null || iconUrlInput === (branding.iconUrl ?? ""), loading: assetBusy === "icon-url", onClick: () => void runAsset("icon-url", () => updateBranding({ iconUrl: iconUrlInput })), children: "Set" })] })] })] }), _jsxs("section", { className: "flex flex-col gap-3", children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted", children: "Custom stylesheet" }), _jsx("div", { className: "flex items-center gap-3", children: _jsx("span", { className: "text-sm text-text-muted truncate", children: branding.stylesheetUrl || "None configured" }) }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: assetBusy === "stylesheet-upload", disabled: assetBusy !== null, onClick: () => stylesheetFileRef.current?.click(), children: "Upload CSS file" }), _jsx("input", { ref: stylesheetFileRef, type: "file", accept: "text/css,.css", "aria-label": "Upload stylesheet", className: "hidden", onChange: handleStylesheetFileChange }), branding.stylesheetUrl && (_jsx(Button, { type: "button", variant: "text", disabled: assetBusy !== null, loading: assetBusy === "stylesheet-delete", onClick: () => void runAsset("stylesheet-delete", async () => {