@rapidmx/web-client 0.2.0 → 0.3.1
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/data-requests/index.tsx +485 -0
- package/apps/admin/escrow-scopes/[uid].tsx +237 -0
- package/apps/admin/escrow-scopes/index.tsx +124 -0
- package/apps/admin/escrow-scopes/new/index.tsx +128 -0
- package/apps/admin/mailboxes/[uid].tsx +66 -8
- package/apps/admin/retention-policy/index.tsx +132 -0
- package/apps/escrow/_layout.tsx +41 -0
- package/apps/escrow/audit-log/index.tsx +168 -0
- package/apps/escrow/index.tsx +139 -0
- package/apps/escrow/matters/[uid].tsx +521 -0
- package/apps/escrow/matters/new/index.tsx +173 -0
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +171 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +24 -0
- package/apps/shared/components/contacts/ContactDetailPane.tsx +44 -0
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +134 -0
- package/apps/shared/components/forms/StringListField.tsx +104 -0
- package/apps/shared/components/layout/AppShell.tsx +4 -0
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +265 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +8 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +214 -9
- package/apps/shared/components/mail/compose/ComposeContext.tsx +10 -1
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +271 -24
- package/apps/shared/components/mail/layout/MailShell.tsx +302 -287
- package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -0
- package/apps/www/index.tsx +122 -11
- package/apps/www/messages/[uid].tsx +15 -1
- package/apps/www/settings/encryption/index.tsx +664 -0
- package/apps/www/settings/labels/index.tsx +201 -0
- package/apps/www/settings/privacy/index.tsx +436 -0
- package/dist/apps/admin/data-requests/index.js +197 -0
- package/dist/apps/admin/escrow-scopes/[uid].js +141 -0
- package/dist/apps/admin/escrow-scopes/index.js +31 -0
- package/dist/apps/admin/escrow-scopes/new/index.js +69 -0
- package/dist/apps/admin/mailboxes/[uid].js +26 -2
- package/dist/apps/admin/retention-policy/index.js +65 -0
- package/dist/apps/escrow/_layout.js +10 -0
- package/dist/apps/escrow/audit-log/index.js +66 -0
- package/dist/apps/escrow/index.js +31 -0
- package/dist/apps/escrow/matters/[uid].js +234 -0
- package/dist/apps/escrow/matters/new/index.js +75 -0
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +26 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +19 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +10 -1
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +75 -0
- package/dist/apps/shared/components/forms/StringListField.js +39 -0
- package/dist/apps/shared/components/layout/AppShell.js +4 -0
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +142 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +2 -2
- package/dist/apps/shared/components/mail/MessageDetailPane.js +105 -6
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +184 -21
- package/dist/apps/shared/components/mail/layout/MailShell.js +11 -2
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -0
- package/dist/apps/www/index.js +110 -10
- package/dist/apps/www/messages/[uid].js +12 -2
- package/dist/apps/www/settings/encryption/index.js +330 -0
- package/dist/apps/www/settings/labels/index.js +95 -0
- package/dist/apps/www/settings/privacy/index.js +167 -0
- package/package.json +9 -3
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { FormEvent, useEffect, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import { closeMatter, getMatter, Matter } from "@rapidmx/react-shared/admin/mattersApi.js";
|
|
8
|
+
import {
|
|
9
|
+
approveAccessRequest,
|
|
10
|
+
createAccessRequest,
|
|
11
|
+
denyAccessRequest,
|
|
12
|
+
EscrowAccessRequest,
|
|
13
|
+
getAccessRequestMaterial,
|
|
14
|
+
listAccessRequests,
|
|
15
|
+
} from "@rapidmx/react-shared/admin/escrowAccessRequestsApi.js";
|
|
16
|
+
import {
|
|
17
|
+
createMatterExportRequest,
|
|
18
|
+
listMatterExportRequests,
|
|
19
|
+
matterExportRequestDownloadUrl,
|
|
20
|
+
MatterExportRequest,
|
|
21
|
+
} from "@rapidmx/react-shared/admin/matterExportApi.js";
|
|
22
|
+
import { searchMatter, SearchResultPage } from "@rapidmx/react-shared/admin/matterSearchApi.js";
|
|
23
|
+
import { parseSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
|
|
24
|
+
import EscrowShell, { EscrowShellProps } from "../../shared/components/escrow/layout/EscrowShell.js";
|
|
25
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
26
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
27
|
+
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
28
|
+
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
29
|
+
|
|
30
|
+
const INPUT_CLASS =
|
|
31
|
+
"w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
32
|
+
|
|
33
|
+
// `BaseEscrowAccessRequestRoute.find()` always returns every request under every scope the caller holds
|
|
34
|
+
// (it unconditionally overwrites any `matterId` filter with its own held-scopes-derived one — see
|
|
35
|
+
// `escrowAccessRequestsApi.ts`'s own doc comment) — there is no server-side way to ask for just this one
|
|
36
|
+
// matter's requests. This page fetches a generously-sized single page and filters client-side instead of
|
|
37
|
+
// paginating a second, matter-scoped list UI on top of an already-scoped one; a holder with more than this
|
|
38
|
+
// many *total* in-flight requests across every matter they hold is not the common case this v1 targets.
|
|
39
|
+
const REQUESTS_FETCH_LIMIT = 200;
|
|
40
|
+
|
|
41
|
+
function statusBadgeClass(status: string): string {
|
|
42
|
+
if (status === "denied" || status === "failed") return "bg-danger-bg text-danger";
|
|
43
|
+
if (status === "pending") return "bg-surface-alt text-text-muted";
|
|
44
|
+
return "bg-success text-white";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default function MatterDetailPage(props: Omit<EscrowShellProps, "active"> & { params: { uid: string } }) {
|
|
48
|
+
return (
|
|
49
|
+
<EscrowShell {...props} active="matters">
|
|
50
|
+
<MatterDetailContent uid={props.params.uid} />
|
|
51
|
+
</EscrowShell>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function MatterDetailContent({ uid }: { uid: string }) {
|
|
56
|
+
const [matter, setMatter] = useState<Matter | null>(null);
|
|
57
|
+
const [requests, setRequests] = useState<EscrowAccessRequest[]>([]);
|
|
58
|
+
const [loading, setLoading] = useState(true);
|
|
59
|
+
const [error, setError] = useState<string | null>(null);
|
|
60
|
+
|
|
61
|
+
const [closing, setClosing] = useState(false);
|
|
62
|
+
const [closeError, setCloseError] = useState<string | null>(null);
|
|
63
|
+
|
|
64
|
+
const [showNewRequest, setShowNewRequest] = useState(false);
|
|
65
|
+
const [newRequestMailboxUid, setNewRequestMailboxUid] = useState("");
|
|
66
|
+
const [creatingRequest, setCreatingRequest] = useState(false);
|
|
67
|
+
const [newRequestError, setNewRequestError] = useState<string | null>(null);
|
|
68
|
+
|
|
69
|
+
// Keyed by request uid — several rows can be independently mid-action at once.
|
|
70
|
+
const [actionErrors, setActionErrors] = useState<Record<string, string>>({});
|
|
71
|
+
const [actingOn, setActingOn] = useState<Record<string, boolean>>({});
|
|
72
|
+
|
|
73
|
+
const [materialFor, setMaterialFor] = useState<EscrowAccessRequest | null>(null);
|
|
74
|
+
const [materialText, setMaterialText] = useState("");
|
|
75
|
+
const [materialError, setMaterialError] = useState<string | null>(null);
|
|
76
|
+
const [loadingMaterial, setLoadingMaterial] = useState(false);
|
|
77
|
+
|
|
78
|
+
const [exportRequests, setExportRequests] = useState<MatterExportRequest[]>([]);
|
|
79
|
+
const [creatingExport, setCreatingExport] = useState(false);
|
|
80
|
+
const [exportError, setExportError] = useState<string | null>(null);
|
|
81
|
+
|
|
82
|
+
const [searchText, setSearchText] = useState("");
|
|
83
|
+
const [searching, setSearching] = useState(false);
|
|
84
|
+
const [searchError, setSearchError] = useState<string | null>(null);
|
|
85
|
+
const [searchResults, setSearchResults] = useState<Record<string, SearchResultPage> | null>(null);
|
|
86
|
+
|
|
87
|
+
// Same "no server-side single-matter filter, fetch a generous page and filter client-side" shape
|
|
88
|
+
// REQUESTS_FETCH_LIMIT's own doc comment already establishes for access requests -
|
|
89
|
+
// listMatterExportRequests() has the identical held-scopes-derived scoping, not a per-matter one.
|
|
90
|
+
function reload() {
|
|
91
|
+
setLoading(true);
|
|
92
|
+
setError(null);
|
|
93
|
+
Promise.all([getMatter(uid), listAccessRequests({ limit: REQUESTS_FETCH_LIMIT }), listMatterExportRequests()])
|
|
94
|
+
.then(([loadedMatter, allRequests, allExportRequests]) => {
|
|
95
|
+
setMatter(loadedMatter);
|
|
96
|
+
setRequests(allRequests.filter((r) => r.matterId === uid));
|
|
97
|
+
setExportRequests(allExportRequests.filter((r) => r.matterId === uid));
|
|
98
|
+
})
|
|
99
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this matter."))
|
|
100
|
+
.finally(() => setLoading(false));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
reload();
|
|
105
|
+
}, [uid]);
|
|
106
|
+
|
|
107
|
+
// Only ever invoked from the "Close matter" button below, which itself only renders once `matter` is
|
|
108
|
+
// resolved (children only render once loaded — see the early returns further down) — the non-null
|
|
109
|
+
// assertion reflects that real invariant, not an unchecked assumption. Matches `DomainDetailContent.
|
|
110
|
+
// handleVerify`'s identical pattern.
|
|
111
|
+
async function handleClose() {
|
|
112
|
+
setClosing(true);
|
|
113
|
+
setCloseError(null);
|
|
114
|
+
try {
|
|
115
|
+
const updated = await closeMatter(matter!.uid);
|
|
116
|
+
setMatter(updated);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
setCloseError(err instanceof ApiRequestError ? err.message : "Could not close this matter.");
|
|
119
|
+
} finally {
|
|
120
|
+
setClosing(false);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function handleCreateRequest(e: FormEvent) {
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
setNewRequestError(null);
|
|
127
|
+
if (!newRequestMailboxUid.trim()) {
|
|
128
|
+
setNewRequestError("A mailbox uid is required.");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
setCreatingRequest(true);
|
|
132
|
+
try {
|
|
133
|
+
await createAccessRequest({ matterId: uid, mailboxUid: newRequestMailboxUid.trim() });
|
|
134
|
+
setShowNewRequest(false);
|
|
135
|
+
setNewRequestMailboxUid("");
|
|
136
|
+
reload();
|
|
137
|
+
} catch (err) {
|
|
138
|
+
setNewRequestError(err instanceof ApiRequestError ? err.message : "Could not create this access request.");
|
|
139
|
+
} finally {
|
|
140
|
+
setCreatingRequest(false);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function handleApprove(request: EscrowAccessRequest) {
|
|
145
|
+
setActingOn((prev) => ({ ...prev, [request.uid]: true }));
|
|
146
|
+
setActionErrors((prev) => ({ ...prev, [request.uid]: "" }));
|
|
147
|
+
try {
|
|
148
|
+
const updated = await approveAccessRequest(request.uid);
|
|
149
|
+
setRequests((prev) => prev.map((r) => (r.uid === updated.uid ? updated : r)));
|
|
150
|
+
} catch (err) {
|
|
151
|
+
setActionErrors((prev) => ({
|
|
152
|
+
...prev,
|
|
153
|
+
[request.uid]: err instanceof ApiRequestError ? err.message : "Could not approve this request.",
|
|
154
|
+
}));
|
|
155
|
+
} finally {
|
|
156
|
+
setActingOn((prev) => ({ ...prev, [request.uid]: false }));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function handleDeny(request: EscrowAccessRequest) {
|
|
161
|
+
setActingOn((prev) => ({ ...prev, [request.uid]: true }));
|
|
162
|
+
setActionErrors((prev) => ({ ...prev, [request.uid]: "" }));
|
|
163
|
+
try {
|
|
164
|
+
const updated = await denyAccessRequest(request.uid);
|
|
165
|
+
setRequests((prev) => prev.map((r) => (r.uid === updated.uid ? updated : r)));
|
|
166
|
+
} catch (err) {
|
|
167
|
+
setActionErrors((prev) => ({
|
|
168
|
+
...prev,
|
|
169
|
+
[request.uid]: err instanceof ApiRequestError ? err.message : "Could not deny this request.",
|
|
170
|
+
}));
|
|
171
|
+
} finally {
|
|
172
|
+
setActingOn((prev) => ({ ...prev, [request.uid]: false }));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function handleGetMaterial(request: EscrowAccessRequest) {
|
|
177
|
+
setMaterialFor(request);
|
|
178
|
+
setMaterialText("");
|
|
179
|
+
setMaterialError(null);
|
|
180
|
+
setLoadingMaterial(true);
|
|
181
|
+
try {
|
|
182
|
+
const material = await getAccessRequestMaterial(request.uid);
|
|
183
|
+
setMaterialText(JSON.stringify(material.masterKeyWraps, null, 2));
|
|
184
|
+
} catch (err) {
|
|
185
|
+
setMaterialError(err instanceof ApiRequestError ? err.message : "Could not read this request's material.");
|
|
186
|
+
} finally {
|
|
187
|
+
setLoadingMaterial(false);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function closeMaterialModal() {
|
|
192
|
+
setMaterialFor(null);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function handleCreateExport() {
|
|
196
|
+
setExportError(null);
|
|
197
|
+
setCreatingExport(true);
|
|
198
|
+
try {
|
|
199
|
+
await createMatterExportRequest(uid);
|
|
200
|
+
reload();
|
|
201
|
+
} catch (err) {
|
|
202
|
+
setExportError(err instanceof ApiRequestError ? err.message : "Could not start this export.");
|
|
203
|
+
} finally {
|
|
204
|
+
setCreatingExport(false);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function handleSearch(e: FormEvent) {
|
|
209
|
+
e.preventDefault();
|
|
210
|
+
setSearchError(null);
|
|
211
|
+
setSearching(true);
|
|
212
|
+
try {
|
|
213
|
+
// Same "parse the operator grammar once, client-side, before hitting the server" convention
|
|
214
|
+
// apps/www/index.tsx's own inbox search already establishes - searchMatter()/buildSearchParams()
|
|
215
|
+
// expect the caller to have already extracted operators into discrete params, not embedded in
|
|
216
|
+
// the raw `q` text (see queryGrammar.ts's own doc comment). Passing the unparsed box text
|
|
217
|
+
// straight through, as this previously did, silently treated e.g. "from:alice@example.com" as
|
|
218
|
+
// four literal free-text words instead of a sender filter.
|
|
219
|
+
const parsed = parseSearchQuery(searchText);
|
|
220
|
+
const results = await searchMatter(uid, parsed.text, {
|
|
221
|
+
types: parsed.entityTypes,
|
|
222
|
+
from: parsed.from,
|
|
223
|
+
to: parsed.to,
|
|
224
|
+
cc: parsed.cc,
|
|
225
|
+
subject: parsed.subject,
|
|
226
|
+
hasAttachment: parsed.hasAttachment,
|
|
227
|
+
before: parsed.before,
|
|
228
|
+
after: parsed.after,
|
|
229
|
+
folderUid: parsed.folderUid,
|
|
230
|
+
flags: parsed.flags,
|
|
231
|
+
labels: parsed.labels,
|
|
232
|
+
});
|
|
233
|
+
setSearchResults(results);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
setSearchError(err instanceof ApiRequestError ? err.message : "Could not search this matter.");
|
|
236
|
+
} finally {
|
|
237
|
+
setSearching(false);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (loading) {
|
|
242
|
+
return <p className="text-sm text-text-muted">Loading…</p>;
|
|
243
|
+
}
|
|
244
|
+
if (error || !matter) {
|
|
245
|
+
return <Alert>{error ?? "Matter not found."}</Alert>;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<div className="max-w-4xl flex flex-col gap-5">
|
|
250
|
+
<div className="flex items-start justify-between gap-4">
|
|
251
|
+
<div>
|
|
252
|
+
<a href="/escrow" className="text-sm text-primary-dark hover:underline">
|
|
253
|
+
← All matters
|
|
254
|
+
</a>
|
|
255
|
+
<h1 className="text-xl font-bold tracking-tight mt-1">{matter.name}</h1>
|
|
256
|
+
</div>
|
|
257
|
+
{!matter.closedAt && (
|
|
258
|
+
<Button
|
|
259
|
+
type="button"
|
|
260
|
+
variant="secondary"
|
|
261
|
+
className="!w-auto shrink-0"
|
|
262
|
+
loading={closing}
|
|
263
|
+
disabled={closing}
|
|
264
|
+
onClick={handleClose}
|
|
265
|
+
>
|
|
266
|
+
Close matter
|
|
267
|
+
</Button>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
{closeError && <Alert>{closeError}</Alert>}
|
|
272
|
+
|
|
273
|
+
<div className="bg-surface border border-border rounded-md p-6">
|
|
274
|
+
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
|
275
|
+
<dt className="text-text-muted">Description</dt>
|
|
276
|
+
<dd>{matter.description ?? "None"}</dd>
|
|
277
|
+
<dt className="text-text-muted">Escrow scope</dt>
|
|
278
|
+
<dd className="font-mono text-xs">{matter.escrowScopeId}</dd>
|
|
279
|
+
<dt className="text-text-muted">Custodian mailboxes</dt>
|
|
280
|
+
<dd>{matter.custodianMailboxUids.join(", ")}</dd>
|
|
281
|
+
<dt className="text-text-muted">Date range</dt>
|
|
282
|
+
<dd>
|
|
283
|
+
{new Date(matter.dateRangeStart).toLocaleDateString()} –{" "}
|
|
284
|
+
{new Date(matter.dateRangeEnd).toLocaleDateString()}
|
|
285
|
+
</dd>
|
|
286
|
+
<dt className="text-text-muted">Status</dt>
|
|
287
|
+
<dd>{matter.closedAt ? `Closed ${new Date(matter.closedAt).toLocaleString()}` : "Open"}</dd>
|
|
288
|
+
</dl>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
<div className="bg-surface border border-border rounded-md p-6">
|
|
292
|
+
<div className="flex items-center justify-between mb-3">
|
|
293
|
+
<h2 className="text-base font-bold uppercase tracking-wide">Access requests</h2>
|
|
294
|
+
{!matter.closedAt && (
|
|
295
|
+
<Button type="button" className="!w-auto" onClick={() => setShowNewRequest(true)}>
|
|
296
|
+
+ New access request
|
|
297
|
+
</Button>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
{requests.length === 0 ? (
|
|
302
|
+
<p className="text-sm text-text-muted">No access requests yet.</p>
|
|
303
|
+
) : (
|
|
304
|
+
<ul className="flex flex-col gap-3">
|
|
305
|
+
{requests.map((request) => (
|
|
306
|
+
<li key={request.uid} className="border border-border rounded-sm p-4">
|
|
307
|
+
<div className="flex items-center justify-between gap-3 flex-wrap">
|
|
308
|
+
<div>
|
|
309
|
+
<div className="text-sm font-semibold">{request.mailboxUid}</div>
|
|
310
|
+
<div className="text-xs text-text-muted">
|
|
311
|
+
{request.approvals.length} of {request.requiredHoldersAtCreation} approvals
|
|
312
|
+
· requested by {request.requestedByUserUid}
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
<span
|
|
316
|
+
className={[
|
|
317
|
+
"text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill",
|
|
318
|
+
request.status === "denied"
|
|
319
|
+
? "bg-danger-bg text-danger"
|
|
320
|
+
: request.status === "pending"
|
|
321
|
+
? "bg-surface-alt text-text-muted"
|
|
322
|
+
: "bg-success text-white",
|
|
323
|
+
].join(" ")}
|
|
324
|
+
>
|
|
325
|
+
{request.status}
|
|
326
|
+
</span>
|
|
327
|
+
</div>
|
|
328
|
+
|
|
329
|
+
{actionErrors[request.uid] && <Alert>{actionErrors[request.uid]}</Alert>}
|
|
330
|
+
|
|
331
|
+
<div className="flex gap-3 mt-3">
|
|
332
|
+
{request.status === "pending" && (
|
|
333
|
+
<>
|
|
334
|
+
<Button
|
|
335
|
+
type="button"
|
|
336
|
+
className="!w-auto"
|
|
337
|
+
loading={actingOn[request.uid]}
|
|
338
|
+
disabled={actingOn[request.uid]}
|
|
339
|
+
onClick={() => handleApprove(request)}
|
|
340
|
+
>
|
|
341
|
+
Approve
|
|
342
|
+
</Button>
|
|
343
|
+
<Button
|
|
344
|
+
type="button"
|
|
345
|
+
variant="secondary"
|
|
346
|
+
className="!w-auto"
|
|
347
|
+
loading={actingOn[request.uid]}
|
|
348
|
+
disabled={actingOn[request.uid]}
|
|
349
|
+
onClick={() => handleDeny(request)}
|
|
350
|
+
>
|
|
351
|
+
Deny
|
|
352
|
+
</Button>
|
|
353
|
+
</>
|
|
354
|
+
)}
|
|
355
|
+
{(request.status === "approved" || request.status === "fulfilled") && (
|
|
356
|
+
<Button
|
|
357
|
+
type="button"
|
|
358
|
+
variant="secondary"
|
|
359
|
+
className="!w-auto"
|
|
360
|
+
onClick={() => handleGetMaterial(request)}
|
|
361
|
+
>
|
|
362
|
+
Get material
|
|
363
|
+
</Button>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
366
|
+
</li>
|
|
367
|
+
))}
|
|
368
|
+
</ul>
|
|
369
|
+
)}
|
|
370
|
+
</div>
|
|
371
|
+
|
|
372
|
+
<div className="bg-surface border border-border rounded-md p-6">
|
|
373
|
+
<div className="flex items-center justify-between mb-3">
|
|
374
|
+
<h2 className="text-base font-bold uppercase tracking-wide">Export this matter</h2>
|
|
375
|
+
<Button type="button" className="!w-auto" loading={creatingExport} disabled={creatingExport} onClick={handleCreateExport}>
|
|
376
|
+
+ New export
|
|
377
|
+
</Button>
|
|
378
|
+
</div>
|
|
379
|
+
<p className="text-xs text-text-muted mb-3">
|
|
380
|
+
Exports messages (narrowed to this matter’s own date range), plus full contacts,
|
|
381
|
+
calendar events, tasks, and notes, from every custodian mailbox actually assigned to this
|
|
382
|
+
matter’s escrow scope, as one combined NDJSON file.
|
|
383
|
+
</p>
|
|
384
|
+
{exportError && <Alert>{exportError}</Alert>}
|
|
385
|
+
{exportRequests.length === 0 ? (
|
|
386
|
+
<p className="text-sm text-text-muted">No export requests yet.</p>
|
|
387
|
+
) : (
|
|
388
|
+
<ul className="flex flex-col gap-2">
|
|
389
|
+
{exportRequests.map((request) => (
|
|
390
|
+
<li
|
|
391
|
+
key={request.uid}
|
|
392
|
+
className="flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm"
|
|
393
|
+
>
|
|
394
|
+
<span>
|
|
395
|
+
{new Date(request.dateCreated).toLocaleString()}
|
|
396
|
+
{request.status === "failed" && request.errorMessage && (
|
|
397
|
+
<span className="text-danger"> — {request.errorMessage}</span>
|
|
398
|
+
)}
|
|
399
|
+
</span>
|
|
400
|
+
<span className="flex items-center gap-3">
|
|
401
|
+
<span
|
|
402
|
+
className={`text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${statusBadgeClass(request.status)}`}
|
|
403
|
+
>
|
|
404
|
+
{request.status}
|
|
405
|
+
</span>
|
|
406
|
+
{request.status === "ready" && (
|
|
407
|
+
<a
|
|
408
|
+
href={matterExportRequestDownloadUrl(request.uid)}
|
|
409
|
+
className="text-primary-dark hover:underline font-medium"
|
|
410
|
+
>
|
|
411
|
+
Download
|
|
412
|
+
</a>
|
|
413
|
+
)}
|
|
414
|
+
</span>
|
|
415
|
+
</li>
|
|
416
|
+
))}
|
|
417
|
+
</ul>
|
|
418
|
+
)}
|
|
419
|
+
</div>
|
|
420
|
+
|
|
421
|
+
<div className="bg-surface border border-border rounded-md p-6">
|
|
422
|
+
<h2 className="text-base font-bold uppercase tracking-wide mb-3">Search this matter’s custodians</h2>
|
|
423
|
+
<form onSubmit={handleSearch} className="flex gap-3 mb-4">
|
|
424
|
+
<input
|
|
425
|
+
aria-label="Search this matter"
|
|
426
|
+
className={INPUT_CLASS}
|
|
427
|
+
value={searchText}
|
|
428
|
+
onChange={(e) => setSearchText(e.target.value)}
|
|
429
|
+
placeholder="Free text or an operator filter, e.g. from:alice@example.com"
|
|
430
|
+
/>
|
|
431
|
+
<Button type="submit" className="!w-auto shrink-0" loading={searching} disabled={searching || !searchText.trim()}>
|
|
432
|
+
Search
|
|
433
|
+
</Button>
|
|
434
|
+
</form>
|
|
435
|
+
{searchError && <Alert>{searchError}</Alert>}
|
|
436
|
+
{searchResults &&
|
|
437
|
+
(Object.keys(searchResults).length === 0 ? (
|
|
438
|
+
<p className="text-sm text-text-muted">No custodian mailboxes could be searched.</p>
|
|
439
|
+
) : (
|
|
440
|
+
<div className="flex flex-col gap-4">
|
|
441
|
+
{Object.entries(searchResults).map(([mailboxUid, page]) => (
|
|
442
|
+
<div key={mailboxUid}>
|
|
443
|
+
<h3 className="text-sm font-semibold mb-2">{mailboxUid}</h3>
|
|
444
|
+
{page.results.length === 0 ? (
|
|
445
|
+
<p className="text-xs text-text-muted">No matches.</p>
|
|
446
|
+
) : (
|
|
447
|
+
<ul className="flex flex-col gap-1">
|
|
448
|
+
{page.results.map((result) => (
|
|
449
|
+
<li
|
|
450
|
+
key={`${result.entityType}-${result.entityUid}`}
|
|
451
|
+
className="text-sm py-1 px-2 bg-surface-alt rounded-sm"
|
|
452
|
+
>
|
|
453
|
+
{result.entityType} · {result.entityUid}
|
|
454
|
+
{result.snippet && <span className="text-text-muted"> — {result.snippet}</span>}
|
|
455
|
+
</li>
|
|
456
|
+
))}
|
|
457
|
+
</ul>
|
|
458
|
+
)}
|
|
459
|
+
</div>
|
|
460
|
+
))}
|
|
461
|
+
</div>
|
|
462
|
+
))}
|
|
463
|
+
</div>
|
|
464
|
+
|
|
465
|
+
<Modal open={showNewRequest} onClose={() => setShowNewRequest(false)} title="New access request">
|
|
466
|
+
<form onSubmit={handleCreateRequest} className="flex flex-col gap-4">
|
|
467
|
+
{newRequestError && <Alert>{newRequestError}</Alert>}
|
|
468
|
+
<FormField label="Mailbox uid" htmlFor="newRequestMailboxUid">
|
|
469
|
+
<input
|
|
470
|
+
id="newRequestMailboxUid"
|
|
471
|
+
type="text"
|
|
472
|
+
className={INPUT_CLASS}
|
|
473
|
+
value={newRequestMailboxUid}
|
|
474
|
+
onChange={(e) => setNewRequestMailboxUid(e.target.value)}
|
|
475
|
+
/>
|
|
476
|
+
</FormField>
|
|
477
|
+
<p className="text-xs text-text-muted">
|
|
478
|
+
Must be one of this matter's own custodian mailboxes, currently assigned to this matter's
|
|
479
|
+
escrow scope. Your own creation counts as the first approval.
|
|
480
|
+
</p>
|
|
481
|
+
<div className="flex gap-3 justify-end">
|
|
482
|
+
<Button
|
|
483
|
+
type="button"
|
|
484
|
+
variant="secondary"
|
|
485
|
+
className="!w-auto"
|
|
486
|
+
disabled={creatingRequest}
|
|
487
|
+
onClick={() => setShowNewRequest(false)}
|
|
488
|
+
>
|
|
489
|
+
Cancel
|
|
490
|
+
</Button>
|
|
491
|
+
<Button type="submit" className="!w-auto" loading={creatingRequest} disabled={creatingRequest}>
|
|
492
|
+
Create request
|
|
493
|
+
</Button>
|
|
494
|
+
</div>
|
|
495
|
+
</form>
|
|
496
|
+
</Modal>
|
|
497
|
+
|
|
498
|
+
<Modal open={materialFor !== null} onClose={closeMaterialModal} title="Escrow key material">
|
|
499
|
+
{/* Deliberately raw JSON, not a decrypt/viewer UI: this console never attempts to decrypt
|
|
500
|
+
anything, per specs/end-to-end_encryption.md's "this server never holds the scope's
|
|
501
|
+
private key" - the holder is expected to copy this into their own offline tooling. See
|
|
502
|
+
this session's own report for this documented scope trim. */}
|
|
503
|
+
{loadingMaterial ? (
|
|
504
|
+
<p className="text-sm text-text-muted">Loading…</p>
|
|
505
|
+
) : materialError ? (
|
|
506
|
+
<Alert>{materialError}</Alert>
|
|
507
|
+
) : (
|
|
508
|
+
<>
|
|
509
|
+
<p className="text-sm text-text-muted mb-3">
|
|
510
|
+
Still-encrypted master key wraps for <strong>{materialFor?.mailboxUid}</strong> — this
|
|
511
|
+
server cannot decrypt these either. Copy the JSON below into your own offline tooling.
|
|
512
|
+
</p>
|
|
513
|
+
<pre className="text-xs font-mono bg-surface-alt border border-border rounded-sm p-3 overflow-x-auto whitespace-pre-wrap break-all">
|
|
514
|
+
{materialText}
|
|
515
|
+
</pre>
|
|
516
|
+
</>
|
|
517
|
+
)}
|
|
518
|
+
</Modal>
|
|
519
|
+
</div>
|
|
520
|
+
);
|
|
521
|
+
}
|