@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.
- package/apps/admin/audit-log/index.tsx +35 -8
- package/apps/admin/data-requests/index.tsx +480 -485
- package/apps/admin/distribution-lists/[uid].tsx +19 -1
- package/apps/admin/escrow-scopes/[uid].tsx +348 -237
- package/apps/admin/escrow-scopes/new/index.tsx +8 -2
- package/apps/admin/index.tsx +19 -1
- package/apps/admin/ingest-queue/index.tsx +44 -5
- package/apps/admin/mailboxes/[uid].tsx +211 -184
- package/apps/admin/quarantine/index.tsx +46 -14
- package/apps/admin/transport-rules/[uid].tsx +30 -1
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
- package/apps/admin/transport-rules/index.tsx +24 -6
- package/apps/admin/transport-rules/new/index.tsx +30 -1
- package/apps/escrow/_layout.tsx +2 -3
- package/apps/escrow/audit-log/index.tsx +196 -168
- package/apps/escrow/matters/[uid].tsx +621 -521
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
- package/apps/shared/components/admin/layout/AdminShell.tsx +3 -1
- package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
- package/apps/shared/components/admin/settings/BrandingForm.tsx +15 -4
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +18 -7
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +10 -2
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +6 -0
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +45 -12
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -595
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -98
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +49 -13
- package/apps/shared/components/admin/setup/SetupWizard.tsx +150 -34
- package/apps/shared/components/admin/signOut.ts +79 -0
- package/apps/shared/components/admin/usePagedList.tsx +129 -0
- package/apps/shared/components/calendar/EventModal.tsx +717 -549
- package/apps/shared/components/calendar/MonthView.tsx +173 -139
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
- package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
- package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
- package/apps/shared/components/calendar/allDay.ts +124 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +1 -1
- package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
- package/apps/shared/components/contacts/ContactForm.tsx +377 -348
- package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
- package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
- package/apps/shared/components/contacts/contactKeys.ts +78 -0
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
- package/apps/shared/components/layout/AppShell.tsx +87 -15
- package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +389 -298
- package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
- package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -128
- package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
- package/apps/shared/components/mail/MessageDetailPane.tsx +1318 -630
- package/apps/shared/components/mail/compose/ComposeContext.tsx +20 -15
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +854 -132
- package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +12 -2
- package/apps/shared/components/mail/pinnedSigners.ts +124 -0
- package/apps/shared/components/mail/verificationSeals.ts +125 -0
- package/apps/shared/components/mail/writableMailboxes.ts +101 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
- package/apps/shared/mail/listAllPages.ts +39 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +64 -55
- package/apps/shared/search/localIndexBuilder.ts +481 -179
- package/apps/shared/search/localIndexRpcClient.ts +228 -28
- package/apps/shared/search/localIndexSchema.ts +15 -6
- package/apps/shared/search/localIndexStorage.ts +98 -0
- package/apps/shared/search/localIndexVFS.ts +32 -1
- package/apps/shared/search/localIndexWorker.ts +648 -186
- package/apps/www/calendar/index.tsx +438 -417
- package/apps/www/contacts/[uid].tsx +21 -2
- package/apps/www/contacts/index.tsx +116 -12
- package/apps/www/index.tsx +1314 -1041
- package/apps/www/messages/[uid].tsx +18 -6
- package/apps/www/settings/auto-reply/index.tsx +134 -129
- package/apps/www/settings/booking-types/[uid].tsx +306 -300
- package/apps/www/settings/encryption/index.tsx +739 -216
- package/apps/www/settings/filters/[uid].tsx +171 -155
- package/apps/www/settings/filters/new/index.tsx +8 -1
- package/apps/www/settings/focused-inbox/index.tsx +150 -148
- package/apps/www/settings/privacy/index.tsx +494 -436
- package/apps/www/settings/read-receipts/index.tsx +148 -125
- package/apps/www/settings/sharing/index.tsx +17 -14
- package/apps/www/tasks/index.tsx +638 -579
- package/dist/apps/admin/audit-log/index.js +38 -8
- package/dist/apps/admin/data-requests/index.js +52 -72
- package/dist/apps/admin/distribution-lists/[uid].js +1 -1
- package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
- package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
- package/dist/apps/admin/index.js +4 -1
- package/dist/apps/admin/ingest-queue/index.js +24 -6
- package/dist/apps/admin/mailboxes/[uid].js +20 -4
- package/dist/apps/admin/quarantine/index.js +20 -11
- package/dist/apps/admin/transport-rules/[uid].js +18 -2
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
- package/dist/apps/admin/transport-rules/index.js +22 -6
- package/dist/apps/admin/transport-rules/new/index.js +18 -2
- package/dist/apps/escrow/_layout.js +3 -3
- package/dist/apps/escrow/audit-log/index.js +26 -1
- package/dist/apps/escrow/matters/[uid].js +77 -42
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
- package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -1
- package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +15 -4
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +13 -7
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +7 -2
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +6 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +34 -12
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +390 -78
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +40 -11
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +29 -13
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +87 -21
- package/dist/apps/shared/components/admin/signOut.js +75 -0
- package/dist/apps/shared/components/admin/usePagedList.js +99 -0
- package/dist/apps/shared/components/calendar/EventModal.js +142 -21
- package/dist/apps/shared/components/calendar/MonthView.js +12 -5
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
- package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
- package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
- package/dist/apps/shared/components/calendar/allDay.js +111 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +1 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
- package/dist/apps/shared/components/contacts/ContactForm.js +32 -10
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
- package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
- package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
- package/dist/apps/shared/components/layout/AppShell.js +83 -14
- package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +49 -12
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
- package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +73 -14
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
- package/dist/apps/shared/components/mail/MessageDetailPane.js +441 -36
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +699 -128
- package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +12 -3
- package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
- package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
- package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
- package/dist/apps/shared/mail/listAllPages.js +25 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +58 -54
- package/dist/apps/shared/search/localIndexBuilder.js +292 -37
- package/dist/apps/shared/search/localIndexRpcClient.js +188 -25
- package/dist/apps/shared/search/localIndexSchema.js +12 -6
- package/dist/apps/shared/search/localIndexStorage.js +86 -0
- package/dist/apps/shared/search/localIndexVFS.js +68 -40
- package/dist/apps/shared/search/localIndexWorker.js +519 -173
- package/dist/apps/www/calendar/index.js +28 -10
- package/dist/apps/www/contacts/[uid].js +13 -2
- package/dist/apps/www/contacts/index.js +80 -10
- package/dist/apps/www/index.js +339 -108
- package/dist/apps/www/messages/[uid].js +18 -6
- package/dist/apps/www/settings/auto-reply/index.js +9 -4
- package/dist/apps/www/settings/booking-types/[uid].js +17 -12
- package/dist/apps/www/settings/encryption/index.js +514 -97
- package/dist/apps/www/settings/filters/[uid].js +22 -7
- package/dist/apps/www/settings/filters/new/index.js +8 -2
- package/dist/apps/www/settings/focused-inbox/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.js +57 -22
- package/dist/apps/www/settings/read-receipts/index.js +10 -3
- package/dist/apps/www/settings/sharing/index.js +7 -11
- package/dist/apps/www/tasks/index.js +50 -10
- 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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 === "
|
|
24
|
+
if (status === "failed" || status === "denied")
|
|
28
25
|
return "bg-danger-bg text-danger";
|
|
29
|
-
if (status
|
|
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
|
-
|
|
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
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
.then(
|
|
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
|
-
|
|
77
|
-
reload();
|
|
82
|
+
void requests.reload();
|
|
83
|
+
void exportRequests.reload();
|
|
78
84
|
}, [uid]);
|
|
79
|
-
// Only ever invoked from the
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
+
if (seq === materialSeq.current)
|
|
180
|
+
setMaterialText(JSON.stringify(material.masterKeyWraps, null, 2));
|
|
160
181
|
}
|
|
161
182
|
catch (err) {
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
+
if (seq === searchSeq.current)
|
|
240
|
+
setSearchResults(results);
|
|
211
241
|
}
|
|
212
242
|
catch (err) {
|
|
213
|
-
|
|
243
|
+
if (seq === searchSeq.current) {
|
|
244
|
+
setSearchError(err instanceof ApiRequestError ? err.message : "Could not search this matter.");
|
|
245
|
+
}
|
|
214
246
|
}
|
|
215
247
|
finally {
|
|
216
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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
|
-
|
|
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
|
-
|
|
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) :
|
|
42
|
+
resourceCapacity: resourceCapacity ? Number(resourceCapacity) : null,
|
|
43
43
|
autoAcceptBookings,
|
|
44
44
|
allowConflicts,
|
|
45
|
-
bookingWindowDays: bookingWindowDays ? Number(bookingWindowDays) :
|
|
46
|
-
maxDurationMinutes: maxDurationMinutes ? Number(maxDurationMinutes) :
|
|
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
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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 () => {
|