@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.
Files changed (59) hide show
  1. package/apps/admin/data-requests/index.tsx +485 -0
  2. package/apps/admin/escrow-scopes/[uid].tsx +237 -0
  3. package/apps/admin/escrow-scopes/index.tsx +124 -0
  4. package/apps/admin/escrow-scopes/new/index.tsx +128 -0
  5. package/apps/admin/mailboxes/[uid].tsx +66 -8
  6. package/apps/admin/retention-policy/index.tsx +132 -0
  7. package/apps/escrow/_layout.tsx +41 -0
  8. package/apps/escrow/audit-log/index.tsx +168 -0
  9. package/apps/escrow/index.tsx +139 -0
  10. package/apps/escrow/matters/[uid].tsx +521 -0
  11. package/apps/escrow/matters/new/index.tsx +173 -0
  12. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +171 -0
  13. package/apps/shared/components/admin/layout/AdminShell.tsx +24 -0
  14. package/apps/shared/components/contacts/ContactDetailPane.tsx +44 -0
  15. package/apps/shared/components/escrow/layout/EscrowShell.tsx +134 -0
  16. package/apps/shared/components/forms/StringListField.tsx +104 -0
  17. package/apps/shared/components/layout/AppShell.tsx +4 -0
  18. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +265 -0
  19. package/apps/shared/components/mail/ConversationThreadPane.tsx +8 -1
  20. package/apps/shared/components/mail/MessageDetailPane.tsx +214 -9
  21. package/apps/shared/components/mail/compose/ComposeContext.tsx +10 -1
  22. package/apps/shared/components/mail/compose/ComposeWindow.tsx +271 -24
  23. package/apps/shared/components/mail/layout/MailShell.tsx +302 -287
  24. package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -0
  25. package/apps/www/index.tsx +122 -11
  26. package/apps/www/messages/[uid].tsx +15 -1
  27. package/apps/www/settings/encryption/index.tsx +664 -0
  28. package/apps/www/settings/labels/index.tsx +201 -0
  29. package/apps/www/settings/privacy/index.tsx +436 -0
  30. package/dist/apps/admin/data-requests/index.js +197 -0
  31. package/dist/apps/admin/escrow-scopes/[uid].js +141 -0
  32. package/dist/apps/admin/escrow-scopes/index.js +31 -0
  33. package/dist/apps/admin/escrow-scopes/new/index.js +69 -0
  34. package/dist/apps/admin/mailboxes/[uid].js +26 -2
  35. package/dist/apps/admin/retention-policy/index.js +65 -0
  36. package/dist/apps/escrow/_layout.js +10 -0
  37. package/dist/apps/escrow/audit-log/index.js +66 -0
  38. package/dist/apps/escrow/index.js +31 -0
  39. package/dist/apps/escrow/matters/[uid].js +234 -0
  40. package/dist/apps/escrow/matters/new/index.js +75 -0
  41. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +26 -0
  42. package/dist/apps/shared/components/admin/layout/AdminShell.js +19 -1
  43. package/dist/apps/shared/components/contacts/ContactDetailPane.js +10 -1
  44. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +75 -0
  45. package/dist/apps/shared/components/forms/StringListField.js +39 -0
  46. package/dist/apps/shared/components/layout/AppShell.js +4 -0
  47. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +142 -0
  48. package/dist/apps/shared/components/mail/ConversationThreadPane.js +2 -2
  49. package/dist/apps/shared/components/mail/MessageDetailPane.js +105 -6
  50. package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -1
  51. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +184 -21
  52. package/dist/apps/shared/components/mail/layout/MailShell.js +11 -2
  53. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -0
  54. package/dist/apps/www/index.js +110 -10
  55. package/dist/apps/www/messages/[uid].js +12 -2
  56. package/dist/apps/www/settings/encryption/index.js +330 -0
  57. package/dist/apps/www/settings/labels/index.js +95 -0
  58. package/dist/apps/www/settings/privacy/index.js +167 -0
  59. package/package.json +9 -3
@@ -0,0 +1,66 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } 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 { apiFetch, ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { listAuditLogEntries, verifyAuditChain, } from "@rapidmx/react-shared/admin/escrowAuditLogApi.js";
9
+ import EscrowShell from "../../shared/components/escrow/layout/EscrowShell.js";
10
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
12
+ const PAGE_SIZE = 25;
13
+ const ACTION_LABELS = {
14
+ "escrow_access_request.created": "Access request created",
15
+ "escrow_access_request.approved": "Access request approved",
16
+ "escrow_access_request.material_read": "Material read",
17
+ };
18
+ export default function EscrowAuditLogPage(props) {
19
+ return (_jsx(EscrowShell, { ...props, active: "auditLog", children: _jsx(EscrowAuditLogContent, {}) }));
20
+ }
21
+ function EscrowAuditLogContent() {
22
+ const [page, setPage] = useState(0);
23
+ const [entries, setEntries] = useState([]);
24
+ const [loading, setLoading] = useState(true);
25
+ const [error, setError] = useState(null);
26
+ // `GET /admin/release-notes` is the same side-effect-free trusted-role canary `AdminShell` itself
27
+ // uses (any `BaseAdminRoute` endpoint works — see that route's own doc comment) — reused here only to
28
+ // decide whether to show the "Verify chain integrity" button at all, not to gate this whole page the
29
+ // way `AdminShell` gates `apps/admin`. `EscrowAuditLogRoute.verify()` is `@RequiresTrustedRole()`-only
30
+ // server-side regardless of what this probe finds; hiding the button for a non-trusted holder just
31
+ // avoids an inevitable, confusing 403 on click, it isn't itself the enforcement.
32
+ const [isTrustedAdmin, setIsTrustedAdmin] = useState(false);
33
+ const [verifying, setVerifying] = useState(false);
34
+ const [verifyError, setVerifyError] = useState(null);
35
+ const [verifyResult, setVerifyResult] = useState(null);
36
+ useEffect(() => {
37
+ apiFetch("/admin/release-notes")
38
+ .then(() => setIsTrustedAdmin(true))
39
+ .catch(() => setIsTrustedAdmin(false));
40
+ }, []);
41
+ useEffect(() => {
42
+ setLoading(true);
43
+ setError(null);
44
+ listAuditLogEntries({ page, limit: PAGE_SIZE })
45
+ .then(setEntries)
46
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load the audit log."))
47
+ .finally(() => setLoading(false));
48
+ }, [page]);
49
+ async function handleVerify() {
50
+ setVerifying(true);
51
+ setVerifyError(null);
52
+ setVerifyResult(null);
53
+ try {
54
+ setVerifyResult(await verifyAuditChain());
55
+ }
56
+ catch (err) {
57
+ setVerifyError(err instanceof ApiRequestError ? err.message : "Could not verify the audit chain.");
58
+ }
59
+ finally {
60
+ setVerifying(false);
61
+ }
62
+ }
63
+ const hasNextPage = entries.length === PAGE_SIZE;
64
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between mb-5", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Escrow audit log" }), isTrustedAdmin && (_jsx(Button, { type: "button", className: "!w-auto", loading: verifying, disabled: verifying, onClick: handleVerify, children: "Verify chain integrity" }))] }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Every escrow access lifecycle event visible to you \u2014 matters under a scope you hold. A trusted administrator sees every entry across every scope instead." }), verifyError && _jsx(Alert, { children: verifyError }), verifyResult &&
65
+ (verifyResult.valid ? (_jsx("div", { className: "mb-5 py-3 px-3.5 rounded-sm text-sm bg-success/10 text-success font-medium", children: "Chain verified \u2014 no tampering detected." })) : (_jsxs(Alert, { children: ["Chain integrity broken at sequence ", verifyResult.brokenAtSequence, "."] }))), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : entries.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No audit log entries visible to you yet." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Sequence", "Action", "Holder", "Mailbox", "Occurred"].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: entries.map((entry) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: entry.sequence }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: ACTION_LABELS[entry.action] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: entry.holderUserUid }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: entry.mailboxUid }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: new Date(entry.occurredAt).toLocaleString() })] }, entry.uid))) })] }) })), _jsxs("div", { className: "flex gap-3 items-center mt-4", children: [_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: page === 0 || loading, onClick: () => setPage((p) => Math.max(0, p - 1)), children: "Previous" }), _jsxs("span", { className: "text-sm text-text-muted", children: ["Page ", page + 1] }), _jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: !hasNextPage || loading, onClick: () => setPage((p) => p + 1), children: "Next" })] })] }));
66
+ }
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } 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 { listMatters } from "@rapidmx/react-shared/admin/mattersApi.js";
9
+ import EscrowShell from "../shared/components/escrow/layout/EscrowShell.js";
10
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
12
+ const PAGE_SIZE = 25;
13
+ export default function MattersPage(props) {
14
+ return (_jsx(EscrowShell, { ...props, active: "matters", children: _jsx(MattersContent, {}) }));
15
+ }
16
+ function MattersContent() {
17
+ const [page, setPage] = useState(0);
18
+ const [matters, setMatters] = useState([]);
19
+ const [loading, setLoading] = useState(true);
20
+ const [error, setError] = useState(null);
21
+ useEffect(() => {
22
+ setLoading(true);
23
+ setError(null);
24
+ listMatters({ page, limit: PAGE_SIZE })
25
+ .then(setMatters)
26
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load matters."))
27
+ .finally(() => setLoading(false));
28
+ }, [page]);
29
+ const hasNextPage = matters.length === PAGE_SIZE;
30
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between mb-5", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Matters" }), _jsx("a", { href: "/escrow/matters/new", children: _jsx(Button, { type: "button", className: "!w-auto", children: "+ New matter" }) })] }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Only matters under an escrow scope you hold are ever shown here \u2014 this list is already scoped to you server-side." }), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : matters.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No matters yet \u2014 either none exist under a scope you hold, or you don't currently hold any escrow scope." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Name", "Custodians", "Date range", "Status", ""].map((h) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: h }, h))) }) }), _jsx("tbody", { children: matters.map((matter) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: matter.name }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: matter.custodianMailboxUids.length }), _jsxs("td", { className: "py-2.5 px-2.5 border-b border-border", children: [new Date(matter.dateRangeStart).toLocaleDateString(), " \u2013", " ", new Date(matter.dateRangeEnd).toLocaleDateString()] }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: matter.closedAt ? (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Closed" })) : (_jsx("span", { className: "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white", children: "Open" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-right", children: _jsx("a", { href: `/escrow/matters/${encodeURIComponent(matter.uid)}`, className: "text-primary-dark hover:underline font-medium", children: "View" }) })] }, matter.uid))) })] }) })), _jsxs("div", { className: "flex gap-3 items-center mt-4", children: [_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: page === 0 || loading, onClick: () => setPage((p) => Math.max(0, p - 1)), children: "Previous" }), _jsxs("span", { className: "text-sm text-text-muted", children: ["Page ", page + 1] }), _jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", disabled: !hasNextPage || loading, onClick: () => setPage((p) => p + 1), children: "Next" })] })] }));
31
+ }
@@ -0,0 +1,234 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } 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 { closeMatter, getMatter } from "@rapidmx/react-shared/admin/mattersApi.js";
9
+ import { approveAccessRequest, createAccessRequest, denyAccessRequest, getAccessRequestMaterial, listAccessRequests, } from "@rapidmx/react-shared/admin/escrowAccessRequestsApi.js";
10
+ import { createMatterExportRequest, listMatterExportRequests, matterExportRequestDownloadUrl, } from "@rapidmx/react-shared/admin/matterExportApi.js";
11
+ import { searchMatter } from "@rapidmx/react-shared/admin/matterSearchApi.js";
12
+ import { parseSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
13
+ import EscrowShell from "../../shared/components/escrow/layout/EscrowShell.js";
14
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
15
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
16
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
17
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
18
+ const INPUT_CLASS = "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
19
+ // `BaseEscrowAccessRequestRoute.find()` always returns every request under every scope the caller holds
20
+ // (it unconditionally overwrites any `matterId` filter with its own held-scopes-derived one — see
21
+ // `escrowAccessRequestsApi.ts`'s own doc comment) — there is no server-side way to ask for just this one
22
+ // matter's requests. This page fetches a generously-sized single page and filters client-side instead of
23
+ // paginating a second, matter-scoped list UI on top of an already-scoped one; a holder with more than this
24
+ // many *total* in-flight requests across every matter they hold is not the common case this v1 targets.
25
+ const REQUESTS_FETCH_LIMIT = 200;
26
+ function statusBadgeClass(status) {
27
+ if (status === "denied" || status === "failed")
28
+ return "bg-danger-bg text-danger";
29
+ if (status === "pending")
30
+ return "bg-surface-alt text-text-muted";
31
+ return "bg-success text-white";
32
+ }
33
+ export default function MatterDetailPage(props) {
34
+ return (_jsx(EscrowShell, { ...props, active: "matters", children: _jsx(MatterDetailContent, { uid: props.params.uid }) }));
35
+ }
36
+ function MatterDetailContent({ uid }) {
37
+ const [matter, setMatter] = useState(null);
38
+ const [requests, setRequests] = useState([]);
39
+ const [loading, setLoading] = useState(true);
40
+ const [error, setError] = useState(null);
41
+ const [closing, setClosing] = useState(false);
42
+ const [closeError, setCloseError] = useState(null);
43
+ const [showNewRequest, setShowNewRequest] = useState(false);
44
+ const [newRequestMailboxUid, setNewRequestMailboxUid] = useState("");
45
+ const [creatingRequest, setCreatingRequest] = useState(false);
46
+ const [newRequestError, setNewRequestError] = useState(null);
47
+ // Keyed by request uid — several rows can be independently mid-action at once.
48
+ const [actionErrors, setActionErrors] = useState({});
49
+ const [actingOn, setActingOn] = useState({});
50
+ const [materialFor, setMaterialFor] = useState(null);
51
+ const [materialText, setMaterialText] = useState("");
52
+ const [materialError, setMaterialError] = useState(null);
53
+ const [loadingMaterial, setLoadingMaterial] = useState(false);
54
+ const [exportRequests, setExportRequests] = useState([]);
55
+ const [creatingExport, setCreatingExport] = useState(false);
56
+ const [exportError, setExportError] = useState(null);
57
+ const [searchText, setSearchText] = useState("");
58
+ const [searching, setSearching] = useState(false);
59
+ const [searchError, setSearchError] = useState(null);
60
+ const [searchResults, setSearchResults] = useState(null);
61
+ // Same "no server-side single-matter filter, fetch a generous page and filter client-side" shape
62
+ // REQUESTS_FETCH_LIMIT's own doc comment already establishes for access requests -
63
+ // listMatterExportRequests() has the identical held-scopes-derived scoping, not a per-matter one.
64
+ function reload() {
65
+ setLoading(true);
66
+ setError(null);
67
+ Promise.all([getMatter(uid), listAccessRequests({ limit: REQUESTS_FETCH_LIMIT }), listMatterExportRequests()])
68
+ .then(([loadedMatter, allRequests, allExportRequests]) => {
69
+ setMatter(loadedMatter);
70
+ setRequests(allRequests.filter((r) => r.matterId === uid));
71
+ setExportRequests(allExportRequests.filter((r) => r.matterId === uid));
72
+ })
73
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this matter."))
74
+ .finally(() => setLoading(false));
75
+ }
76
+ useEffect(() => {
77
+ reload();
78
+ }, [uid]);
79
+ // Only ever invoked from the "Close matter" button below, which itself only renders once `matter` is
80
+ // resolved (children only render once loaded — see the early returns further down) — the non-null
81
+ // assertion reflects that real invariant, not an unchecked assumption. Matches `DomainDetailContent.
82
+ // handleVerify`'s identical pattern.
83
+ async function handleClose() {
84
+ setClosing(true);
85
+ setCloseError(null);
86
+ try {
87
+ const updated = await closeMatter(matter.uid);
88
+ setMatter(updated);
89
+ }
90
+ catch (err) {
91
+ setCloseError(err instanceof ApiRequestError ? err.message : "Could not close this matter.");
92
+ }
93
+ finally {
94
+ setClosing(false);
95
+ }
96
+ }
97
+ async function handleCreateRequest(e) {
98
+ e.preventDefault();
99
+ setNewRequestError(null);
100
+ if (!newRequestMailboxUid.trim()) {
101
+ setNewRequestError("A mailbox uid is required.");
102
+ return;
103
+ }
104
+ setCreatingRequest(true);
105
+ try {
106
+ await createAccessRequest({ matterId: uid, mailboxUid: newRequestMailboxUid.trim() });
107
+ setShowNewRequest(false);
108
+ setNewRequestMailboxUid("");
109
+ reload();
110
+ }
111
+ catch (err) {
112
+ setNewRequestError(err instanceof ApiRequestError ? err.message : "Could not create this access request.");
113
+ }
114
+ finally {
115
+ setCreatingRequest(false);
116
+ }
117
+ }
118
+ async function handleApprove(request) {
119
+ setActingOn((prev) => ({ ...prev, [request.uid]: true }));
120
+ setActionErrors((prev) => ({ ...prev, [request.uid]: "" }));
121
+ try {
122
+ const updated = await approveAccessRequest(request.uid);
123
+ setRequests((prev) => prev.map((r) => (r.uid === updated.uid ? updated : r)));
124
+ }
125
+ catch (err) {
126
+ setActionErrors((prev) => ({
127
+ ...prev,
128
+ [request.uid]: err instanceof ApiRequestError ? err.message : "Could not approve this request.",
129
+ }));
130
+ }
131
+ finally {
132
+ setActingOn((prev) => ({ ...prev, [request.uid]: false }));
133
+ }
134
+ }
135
+ async function handleDeny(request) {
136
+ setActingOn((prev) => ({ ...prev, [request.uid]: true }));
137
+ setActionErrors((prev) => ({ ...prev, [request.uid]: "" }));
138
+ try {
139
+ const updated = await denyAccessRequest(request.uid);
140
+ setRequests((prev) => prev.map((r) => (r.uid === updated.uid ? updated : r)));
141
+ }
142
+ catch (err) {
143
+ setActionErrors((prev) => ({
144
+ ...prev,
145
+ [request.uid]: err instanceof ApiRequestError ? err.message : "Could not deny this request.",
146
+ }));
147
+ }
148
+ finally {
149
+ setActingOn((prev) => ({ ...prev, [request.uid]: false }));
150
+ }
151
+ }
152
+ async function handleGetMaterial(request) {
153
+ setMaterialFor(request);
154
+ setMaterialText("");
155
+ setMaterialError(null);
156
+ setLoadingMaterial(true);
157
+ try {
158
+ const material = await getAccessRequestMaterial(request.uid);
159
+ setMaterialText(JSON.stringify(material.masterKeyWraps, null, 2));
160
+ }
161
+ catch (err) {
162
+ setMaterialError(err instanceof ApiRequestError ? err.message : "Could not read this request's material.");
163
+ }
164
+ finally {
165
+ setLoadingMaterial(false);
166
+ }
167
+ }
168
+ function closeMaterialModal() {
169
+ setMaterialFor(null);
170
+ }
171
+ async function handleCreateExport() {
172
+ setExportError(null);
173
+ setCreatingExport(true);
174
+ try {
175
+ await createMatterExportRequest(uid);
176
+ reload();
177
+ }
178
+ catch (err) {
179
+ setExportError(err instanceof ApiRequestError ? err.message : "Could not start this export.");
180
+ }
181
+ finally {
182
+ setCreatingExport(false);
183
+ }
184
+ }
185
+ async function handleSearch(e) {
186
+ e.preventDefault();
187
+ setSearchError(null);
188
+ setSearching(true);
189
+ try {
190
+ // Same "parse the operator grammar once, client-side, before hitting the server" convention
191
+ // apps/www/index.tsx's own inbox search already establishes - searchMatter()/buildSearchParams()
192
+ // expect the caller to have already extracted operators into discrete params, not embedded in
193
+ // the raw `q` text (see queryGrammar.ts's own doc comment). Passing the unparsed box text
194
+ // straight through, as this previously did, silently treated e.g. "from:alice@example.com" as
195
+ // four literal free-text words instead of a sender filter.
196
+ const parsed = parseSearchQuery(searchText);
197
+ const results = await searchMatter(uid, parsed.text, {
198
+ types: parsed.entityTypes,
199
+ from: parsed.from,
200
+ to: parsed.to,
201
+ cc: parsed.cc,
202
+ subject: parsed.subject,
203
+ hasAttachment: parsed.hasAttachment,
204
+ before: parsed.before,
205
+ after: parsed.after,
206
+ folderUid: parsed.folderUid,
207
+ flags: parsed.flags,
208
+ labels: parsed.labels,
209
+ });
210
+ setSearchResults(results);
211
+ }
212
+ catch (err) {
213
+ setSearchError(err instanceof ApiRequestError ? err.message : "Could not search this matter.");
214
+ }
215
+ finally {
216
+ setSearching(false);
217
+ }
218
+ }
219
+ if (loading) {
220
+ return _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" });
221
+ }
222
+ if (error || !matter) {
223
+ return _jsx(Alert, { children: error ?? "Matter not found." });
224
+ }
225
+ return (_jsxs("div", { className: "max-w-4xl flex flex-col gap-5", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/escrow", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All matters" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: matter.name })] }), !matter.closedAt && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", loading: closing, disabled: closing, onClick: handleClose, children: "Close matter" }))] }), closeError && _jsx(Alert, { children: closeError }), _jsx("div", { className: "bg-surface border border-border rounded-md p-6", children: _jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Description" }), _jsx("dd", { children: matter.description ?? "None" }), _jsx("dt", { className: "text-text-muted", children: "Escrow scope" }), _jsx("dd", { className: "font-mono text-xs", children: matter.escrowScopeId }), _jsx("dt", { className: "text-text-muted", children: "Custodian mailboxes" }), _jsx("dd", { children: matter.custodianMailboxUids.join(", ") }), _jsx("dt", { className: "text-text-muted", children: "Date range" }), _jsxs("dd", { children: [new Date(matter.dateRangeStart).toLocaleDateString(), " \u2013", " ", new Date(matter.dateRangeEnd).toLocaleDateString()] }), _jsx("dt", { className: "text-text-muted", children: "Status" }), _jsx("dd", { children: matter.closedAt ? `Closed ${new Date(matter.closedAt).toLocaleString()}` : "Open" })] }) }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("div", { className: "flex items-center justify-between mb-3", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide", children: "Access requests" }), !matter.closedAt && (_jsx(Button, { type: "button", className: "!w-auto", onClick: () => setShowNewRequest(true), children: "+ New access request" }))] }), requests.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No access requests yet." })) : (_jsx("ul", { className: "flex flex-col gap-3", children: requests.map((request) => (_jsxs("li", { className: "border border-border rounded-sm p-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-3 flex-wrap", children: [_jsxs("div", { children: [_jsx("div", { className: "text-sm font-semibold", children: request.mailboxUid }), _jsxs("div", { className: "text-xs text-text-muted", children: [request.approvals.length, " of ", request.requiredHoldersAtCreation, " approvals \u00B7 requested by ", request.requestedByUserUid] })] }), _jsx("span", { className: [
226
+ "text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill",
227
+ request.status === "denied"
228
+ ? "bg-danger-bg text-danger"
229
+ : request.status === "pending"
230
+ ? "bg-surface-alt text-text-muted"
231
+ : "bg-success text-white",
232
+ ].join(" "), children: request.status })] }), actionErrors[request.uid] && _jsx(Alert, { children: actionErrors[request.uid] }), _jsxs("div", { className: "flex gap-3 mt-3", children: [request.status === "pending" && (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", className: "!w-auto", loading: actingOn[request.uid], disabled: actingOn[request.uid], onClick: () => handleApprove(request), children: "Approve" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: actingOn[request.uid], disabled: actingOn[request.uid], onClick: () => handleDeny(request), children: "Deny" })] })), (request.status === "approved" || request.status === "fulfilled") && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => handleGetMaterial(request), children: "Get material" }))] })] }, request.uid))) }))] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("div", { className: "flex items-center justify-between mb-3", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide", children: "Export this matter" }), _jsx(Button, { type: "button", className: "!w-auto", loading: creatingExport, disabled: creatingExport, onClick: handleCreateExport, children: "+ New export" })] }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Exports messages (narrowed to this matter\u2019s own date range), plus full contacts, calendar events, tasks, and notes, from every custodian mailbox actually assigned to this matter\u2019s escrow scope, as one combined NDJSON file." }), exportError && _jsx(Alert, { children: exportError }), exportRequests.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No export requests yet." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: exportRequests.map((request) => (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsxs("span", { children: [new Date(request.dateCreated).toLocaleString(), request.status === "failed" && request.errorMessage && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.errorMessage] }))] }), _jsxs("span", { className: "flex items-center gap-3", children: [_jsx("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${statusBadgeClass(request.status)}`, children: request.status }), request.status === "ready" && (_jsx("a", { href: matterExportRequestDownloadUrl(request.uid), className: "text-primary-dark hover:underline font-medium", children: "Download" }))] })] }, request.uid))) }))] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-3", children: "Search this matter\u2019s custodians" }), _jsxs("form", { onSubmit: handleSearch, className: "flex gap-3 mb-4", children: [_jsx("input", { "aria-label": "Search this matter", className: INPUT_CLASS, value: searchText, onChange: (e) => setSearchText(e.target.value), placeholder: "Free text or an operator filter, e.g. from:alice@example.com" }), _jsx(Button, { type: "submit", className: "!w-auto shrink-0", loading: searching, disabled: searching || !searchText.trim(), children: "Search" })] }), searchError && _jsx(Alert, { children: searchError }), searchResults &&
233
+ (Object.keys(searchResults).length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No custodian mailboxes could be searched." })) : (_jsx("div", { className: "flex flex-col gap-4", children: Object.entries(searchResults).map(([mailboxUid, page]) => (_jsxs("div", { children: [_jsx("h3", { className: "text-sm font-semibold mb-2", children: mailboxUid }), page.results.length === 0 ? (_jsx("p", { className: "text-xs text-text-muted", children: "No matches." })) : (_jsx("ul", { className: "flex flex-col gap-1", children: page.results.map((result) => (_jsxs("li", { className: "text-sm py-1 px-2 bg-surface-alt rounded-sm", children: [result.entityType, " \u00B7 ", result.entityUid, result.snippet && _jsxs("span", { className: "text-text-muted", children: [" \u2014 ", result.snippet] })] }, `${result.entityType}-${result.entityUid}`))) }))] }, mailboxUid))) })))] }), _jsx(Modal, { open: showNewRequest, onClose: () => setShowNewRequest(false), title: "New access request", children: _jsxs("form", { onSubmit: handleCreateRequest, className: "flex flex-col gap-4", children: [newRequestError && _jsx(Alert, { children: newRequestError }), _jsx(FormField, { label: "Mailbox uid", htmlFor: "newRequestMailboxUid", children: _jsx("input", { id: "newRequestMailboxUid", type: "text", className: INPUT_CLASS, value: newRequestMailboxUid, onChange: (e) => setNewRequestMailboxUid(e.target.value) }) }), _jsx("p", { className: "text-xs text-text-muted", children: "Must be one of this matter's own custodian mailboxes, currently assigned to this matter's escrow scope. Your own creation counts as the first approval." }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: creatingRequest, onClick: () => setShowNewRequest(false), children: "Cancel" }), _jsx(Button, { type: "submit", className: "!w-auto", loading: creatingRequest, disabled: creatingRequest, children: "Create request" })] })] }) }), _jsx(Modal, { open: materialFor !== null, onClose: closeMaterialModal, title: "Escrow key material", children: loadingMaterial ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : materialError ? (_jsx(Alert, { children: materialError })) : (_jsxs(_Fragment, { children: [_jsxs("p", { className: "text-sm text-text-muted mb-3", children: ["Still-encrypted master key wraps for ", _jsx("strong", { children: materialFor?.mailboxUid }), " \u2014 this server cannot decrypt these either. Copy the JSON below into your own offline tooling."] }), _jsx("pre", { className: "text-xs font-mono bg-surface-alt border border-border rounded-sm p-3 overflow-x-auto whitespace-pre-wrap break-all", children: materialText })] })) })] }));
234
+ }
@@ -0,0 +1,75 @@
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 { useState } from "react";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
9
+ import { createMatter } from "@rapidmx/react-shared/admin/mattersApi.js";
10
+ import EscrowShell from "../../../shared/components/escrow/layout/EscrowShell.js";
11
+ import StringListField from "../../../shared/components/forms/StringListField.js";
12
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
13
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
14
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
15
+ const INPUT_CLASS = "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
16
+ export default function NewMatterPage(props) {
17
+ return (_jsx(EscrowShell, { ...props, active: "matters", children: _jsx(NewMatterForm, {}) }));
18
+ }
19
+ function NewMatterForm() {
20
+ const [name, setName] = useState("");
21
+ const [description, setDescription] = useState("");
22
+ // No dropdown here: `EscrowScope` (BaseEscrowScopeRoute) is trusted-admin-only end to end, so this
23
+ // app has no holder-readable "list the scopes I hold" endpoint to populate one from — a holder is
24
+ // told their own escrowScopeId out of band by whichever admin granted them the role. Same
25
+ // "server fills a gap, or here, deliberately doesn't paper over one restapi itself doesn't expose"
26
+ // posture as `BaseEscrowInfoRoute`'s own doc comment on the mailbox-owner side of this feature.
27
+ const [escrowScopeId, setEscrowScopeId] = useState("");
28
+ const [custodianMailboxUids, setCustodianMailboxUids] = useState([]);
29
+ // Defaults to the last 24 hours rather than "now" for both ends - a `dateRangeStart === dateRangeEnd`
30
+ // default would always fail this form's own "start must be before end" check the instant it's opened,
31
+ // forcing every holder to touch both fields even when they'd otherwise leave one at its default.
32
+ const [dateRangeStart, setDateRangeStart] = useState(toDatetimeLocal(new Date(Date.now() - 24 * 60 * 60000).toISOString()));
33
+ const [dateRangeEnd, setDateRangeEnd] = useState(toDatetimeLocal(new Date().toISOString()));
34
+ const [error, setError] = useState(null);
35
+ const [saving, setSaving] = useState(false);
36
+ async function handleSubmit(e) {
37
+ e.preventDefault();
38
+ setError(null);
39
+ if (!name.trim()) {
40
+ setError("A name is required.");
41
+ return;
42
+ }
43
+ if (!escrowScopeId.trim()) {
44
+ setError("An escrow scope uid is required.");
45
+ return;
46
+ }
47
+ if (custodianMailboxUids.length === 0) {
48
+ setError("At least one custodian mailbox is required.");
49
+ return;
50
+ }
51
+ if (new Date(dateRangeStart).getTime() >= new Date(dateRangeEnd).getTime()) {
52
+ setError("The date range start must be before its end.");
53
+ return;
54
+ }
55
+ setSaving(true);
56
+ try {
57
+ const created = await createMatter({
58
+ name: name.trim(),
59
+ description: description.trim() || undefined,
60
+ escrowScopeId: escrowScopeId.trim(),
61
+ custodianMailboxUids,
62
+ dateRangeStart: new Date(dateRangeStart).toISOString(),
63
+ dateRangeEnd: new Date(dateRangeEnd).toISOString(),
64
+ });
65
+ window.location.href = `/escrow/matters/${encodeURIComponent(created.uid)}`;
66
+ }
67
+ catch (err) {
68
+ setError(err instanceof ApiRequestError ? err.message : "Could not create the matter.");
69
+ }
70
+ finally {
71
+ setSaving(false);
72
+ }
73
+ }
74
+ return (_jsxs("div", { className: "max-w-3xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "New matter" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "You must already be a holder of the escrow scope you name below \u2014 this server checks that at creation time, the same as every other action in this console." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-5", children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx(FormField, { label: "Name", htmlFor: "name", children: _jsx("input", { id: "name", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value), placeholder: "Smith v. Acme" }) }), _jsx(FormField, { label: "Description (optional)", htmlFor: "description", children: _jsx("input", { id: "description", type: "text", className: INPUT_CLASS, value: description, onChange: (e) => setDescription(e.target.value) }) }), _jsx(FormField, { label: "Escrow scope uid", htmlFor: "escrowScopeId", children: _jsx("input", { id: "escrowScopeId", type: "text", className: INPUT_CLASS, value: escrowScopeId, onChange: (e) => setEscrowScopeId(e.target.value) }) }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-4", children: [_jsx(FormField, { label: "Date range start", htmlFor: "dateRangeStart", children: _jsx("input", { id: "dateRangeStart", type: "datetime-local", className: INPUT_CLASS, value: dateRangeStart, onChange: (e) => setDateRangeStart(e.target.value) }) }), _jsx(FormField, { label: "Date range end", htmlFor: "dateRangeEnd", children: _jsx("input", { id: "dateRangeEnd", type: "datetime-local", className: INPUT_CLASS, value: dateRangeEnd, onChange: (e) => setDateRangeEnd(e.target.value) }) })] })] }), _jsx("div", { className: "bg-surface border border-border rounded-md p-6", children: _jsx(StringListField, { label: "Custodian mailbox uids", id: "custodianMailboxUids", values: custodianMailboxUids, onChange: setCustodianMailboxUids, placeholder: "Mailbox uid to add", emptyMessage: "No custodian mailboxes added yet." }) }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Create matter" }), _jsx("a", { href: "/escrow", children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", children: "Cancel" }) })] })] })] }));
75
+ }
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
3
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
4
+ import StringListField from "../../forms/StringListField.js";
5
+ 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";
6
+ /** Builds an all-zeroes starting point for the "new" page — `notBefore`/`notAfter` default to "now" so an
7
+ * admin pasting in a cert's own dates only has to touch what actually differs. */
8
+ export function emptyEscrowScopeKeyAndHoldersValue() {
9
+ const now = toDatetimeLocal(new Date().toISOString());
10
+ return {
11
+ publicKey: "",
12
+ keyType: "x509",
13
+ fingerprint: "",
14
+ notBefore: now,
15
+ notAfter: now,
16
+ holderUserUids: [],
17
+ requiredHolders: 1,
18
+ notifySubjectOnAccess: false,
19
+ };
20
+ }
21
+ export default function EscrowScopeKeyAndHoldersFields({ value, onChange, disabled }) {
22
+ function set(key, next) {
23
+ onChange({ ...value, [key]: next });
24
+ }
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"] })] })] }));
26
+ }
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import "../../../styles/app.css";
7
7
  import { useEffect, useState } from "react";
8
- import { HiOutlineClipboardDocumentList, HiOutlineGlobeAlt, HiOutlineInboxStack, HiOutlinePaintBrush, HiOutlineQueueList, HiOutlineShieldCheck, HiOutlineShieldExclamation, HiOutlineUserGroup, } from "react-icons/hi2";
8
+ import { HiOutlineClipboardDocumentList, HiOutlineClock, HiOutlineDocumentArrowDown, HiOutlineGlobeAlt, HiOutlineInboxStack, HiOutlineKey, HiOutlinePaintBrush, HiOutlineQueueList, HiOutlineShieldCheck, HiOutlineShieldExclamation, HiOutlineUserGroup, } from "react-icons/hi2";
9
9
  import { apiFetch, ApiRequestError } from "@rapidmx/react-shared/util/api.js";
10
10
  import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
11
11
  import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
@@ -33,6 +33,24 @@ const NAV_ITEMS = [
33
33
  label: "Transport Rules",
34
34
  icon: HiOutlineShieldCheck,
35
35
  },
36
+ {
37
+ id: "escrowScopes",
38
+ href: "/admin/escrow-scopes",
39
+ label: "Escrow Scopes",
40
+ icon: HiOutlineKey,
41
+ },
42
+ {
43
+ id: "retentionPolicy",
44
+ href: "/admin/retention-policy",
45
+ label: "Retention Policy",
46
+ icon: HiOutlineClock,
47
+ },
48
+ {
49
+ id: "dataRequests",
50
+ href: "/admin/data-requests",
51
+ label: "Data Requests",
52
+ icon: HiOutlineDocumentArrowDown,
53
+ },
36
54
  { id: "branding", href: "/admin/branding", label: "Branding", icon: HiOutlinePaintBrush },
37
55
  ];
38
56
  /** Mailbox-scoped sections — not part of the global nav rail/tab bar (see `NAV_ITEMS` above), but
@@ -1,11 +1,20 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
3
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
3
4
  import ContactAvatar from "@rapidmx/react-shared/components/avatar/ContactAvatar.js";
5
+ /** Groups a hex fingerprint into 4-character blocks (`ab12 cd34 ...`) for out-of-band verification -
6
+ * the spec's own use case ("compare this over the phone") is materially easier with a grouped string
7
+ * than one 64-character run. */
8
+ function formatFingerprint(fingerprint) {
9
+ return fingerprint.match(/.{1,4}/g)?.join(" ") ?? fingerprint;
10
+ }
4
11
  /**
5
12
  * A contact's read-only detail view. Shared by the desktop inline pane (`apps/www/contacts/index.tsx`,
6
13
  * always visible alongside the contact list) and the mobile detail route
7
14
  * (`apps/www/contacts/[uid].tsx`, a full page on its own reached by tapping a contact row).
8
15
  */
9
16
  export default function ContactDetailPane({ contact, onEdit, onDelete, backHref }) {
10
- return (_jsxs("div", { role: "region", "aria-label": "Contact details", className: "max-w-xl flex flex-col gap-5", children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline", children: "\u2190 Back to contacts" })), _jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 48 }), _jsxs("div", { children: [_jsx("h1", { className: "text-xl font-bold tracking-tight", children: contact.displayName }), contact.jobTitle && contact.company && (_jsxs("p", { className: "text-sm text-text-muted mt-0.5", children: [contact.jobTitle, " at ", contact.company] }))] })] }), _jsxs("div", { className: "flex gap-2 shrink-0", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onEdit, children: "Edit" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", onClick: onDelete, children: "Delete" })] })] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6 flex flex-col gap-4 text-sm", children: [contact.emails.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Email" }), contact.emails.map((e, i) => (_jsxs("div", { children: [e.address, " ", _jsxs("span", { className: "text-text-muted", children: ["(", e.type, ")"] })] }, i)))] })), contact.phones.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Phone" }), contact.phones.map((p, i) => (_jsxs("div", { children: [p.phoneNumber, " ", _jsxs("span", { className: "text-text-muted", children: ["(", p.type, ")"] })] }, i)))] })), contact.addresses.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Address" }), contact.addresses.map((a, i) => (_jsx("div", { children: [a.street, a.city, a.state, a.postalCode, a.country].filter(Boolean).join(", ") }, i)))] })), contact.categories && contact.categories.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Categories" }), _jsx("div", { children: contact.categories.join(", ") })] })), contact.notes && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Notes" }), _jsx("div", { children: contact.notes })] }))] })] }));
17
+ return (_jsxs("div", { role: "region", "aria-label": "Contact details", className: "max-w-xl flex flex-col gap-5", children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline", children: "\u2190 Back to contacts" })), _jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 48 }), _jsxs("div", { children: [_jsx("h1", { className: "text-xl font-bold tracking-tight", children: contact.displayName }), contact.jobTitle && contact.company && (_jsxs("p", { className: "text-sm text-text-muted mt-0.5", children: [contact.jobTitle, " at ", contact.company] }))] })] }), _jsxs("div", { className: "flex gap-2 shrink-0", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onEdit, children: "Edit" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", onClick: onDelete, children: "Delete" })] })] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6 flex flex-col gap-4 text-sm", children: [contact.emails.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Email" }), contact.emails.map((e, i) => (_jsxs("div", { children: [e.address, " ", _jsxs("span", { className: "text-text-muted", children: ["(", e.type, ")"] })] }, i)))] })), contact.phones.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Phone" }), contact.phones.map((p, i) => (_jsxs("div", { children: [p.phoneNumber, " ", _jsxs("span", { className: "text-text-muted", children: ["(", p.type, ")"] })] }, i)))] })), contact.addresses.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Address" }), contact.addresses.map((a, i) => (_jsx("div", { children: [a.street, a.city, a.state, a.postalCode, a.country].filter(Boolean).join(", ") }, i)))] })), contact.categories && contact.categories.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Categories" }), _jsx("div", { children: contact.categories.join(", ") })] })), contact.notes && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Notes" }), _jsx("div", { children: contact.notes })] })), (contact.keys && contact.keys.length > 0) || contact.encryptPreference || contact.keyConflict ? (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Encryption" }), contact.keyConflict && (_jsx("div", { className: "mb-2", children: _jsxs(Alert, { children: [_jsxs("p", { className: "mb-2", children: ["A different encryption key was observed for this contact on", " ", new Date(contact.keyConflict.observedAt).toLocaleDateString(), ". The previously verified key below is still the one in use \u2014 this is routine after a device change or reinstall, but it\u2019s also what a real attack looks like, so verify the new fingerprint with ", contact.displayName, " ", "directly (e.g. by phone) before trusting it. There is no automatic way to accept or reject this yet."] }), _jsxs("p", { className: "font-mono text-xs", children: ["Newly observed: ", formatFingerprint(contact.keyConflict.observedFingerprint)] })] }) })), contact.encryptPreference && (_jsx("div", { className: "mb-1", children: contact.encryptPreference.preferEncrypt === "mutual"
18
+ ? "This contact also encrypts to you — messages to them can be encrypted."
19
+ : "This contact has not indicated a mutual encryption preference." })), contact.keys?.map((key) => (_jsxs("div", { className: "font-mono text-xs py-0.5", children: [key.useType === "sign" ? "Signing" : "Encryption", " key: ", formatFingerprint(key.fingerprint), key.revokedAt && _jsx("span", { className: "text-danger", children: " (revoked)" })] }, key.fingerprint)))] })) : null] })] }));
11
20
  }