@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,197 @@
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, useRef, useState } from "react";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { createExportRequest, exportRequestDownloadUrl, listExportRequests, } from "@rapidmx/react-shared/mail/dataExportApi.js";
9
+ import { listImportRequests, uploadMailboxImport, } from "@rapidmx/react-shared/mail/mailboxImportApi.js";
10
+ import { approveErasureRequest, denyErasureRequest, listErasureRequests, } from "@rapidmx/react-shared/mail/erasureRequestApi.js";
11
+ import { listFolders } from "@rapidmx/react-shared/mail/mailApi.js";
12
+ import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
13
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
14
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
15
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
16
+ const INPUT_CLASS = "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface";
17
+ const NON_MAIL_FOLDER_TYPES = new Set(["calendar", "contacts", "tasks", "notes"]);
18
+ function importFormatFromFilename(filename) {
19
+ return filename.toLowerCase().endsWith(".pst") ? "pst" : "mbox";
20
+ }
21
+ function statusBadgeClass(status) {
22
+ if (status === "failed" || status === "denied")
23
+ return "bg-danger-bg text-danger";
24
+ if (status === "pending" || status === "processing")
25
+ return "bg-surface-alt text-text-muted";
26
+ return "bg-success text-white";
27
+ }
28
+ export default function DataRequestsPage(props) {
29
+ return (_jsx(AdminShell, { ...props, active: "dataRequests", children: _jsxs("div", { className: "flex flex-col gap-8", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "Data Requests" }), _jsx("p", { className: "text-sm text-text-muted", children: "Every GDPR export, mailbox-import, and account-erasure request across every mailbox." })] }), _jsx(ExportRequestsSection, {}), _jsx(ImportRequestsSection, {}), _jsx(ErasureRequestsSection, {})] }) }));
30
+ }
31
+ function ExportRequestsSection() {
32
+ const [requests, setRequests] = useState([]);
33
+ const [loading, setLoading] = useState(true);
34
+ const [loadError, setLoadError] = useState(null);
35
+ const [mailboxUid, setMailboxUid] = useState("");
36
+ const [format, setFormat] = useState("json");
37
+ const [creating, setCreating] = useState(false);
38
+ const [createError, setCreateError] = useState(null);
39
+ // The create form below isn't gated behind `loading`, so a fast create-then-reload can resolve before
40
+ // the initial mount fetch does; without this guard the mount fetch's now-stale response would land
41
+ // last and silently revert the list. Only the most-recently-issued load's response is ever applied.
42
+ const loadSeq = useRef(0);
43
+ function loadRequests() {
44
+ const seq = ++loadSeq.current;
45
+ return listExportRequests()
46
+ .then((data) => {
47
+ if (seq === loadSeq.current)
48
+ setRequests(data);
49
+ })
50
+ .catch((err) => {
51
+ if (seq === loadSeq.current)
52
+ setLoadError(err instanceof ApiRequestError ? err.message : "Could not load export requests.");
53
+ });
54
+ }
55
+ useEffect(() => {
56
+ loadRequests().finally(() => setLoading(false));
57
+ // eslint-disable-next-line react-hooks/exhaustive-deps
58
+ }, []);
59
+ async function handleCreate(e) {
60
+ e.preventDefault();
61
+ setCreateError(null);
62
+ setCreating(true);
63
+ try {
64
+ await createExportRequest({ format, mailboxUid: mailboxUid.trim() });
65
+ setMailboxUid("");
66
+ await loadRequests();
67
+ }
68
+ catch (err) {
69
+ setCreateError(err instanceof ApiRequestError ? err.message : "Could not start this export.");
70
+ }
71
+ finally {
72
+ setCreating(false);
73
+ }
74
+ }
75
+ return (_jsxs("section", { children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted mb-3", children: "Export requests" }), _jsxs("form", { onSubmit: handleCreate, className: "flex items-end gap-3 mb-4 flex-wrap", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Mailbox UID" }), _jsx("input", { "aria-label": "Export mailbox UID", className: INPUT_CLASS, value: mailboxUid, onChange: (e) => setMailboxUid(e.target.value) })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Format" }), _jsxs("select", { "aria-label": "Export format", className: INPUT_CLASS, value: format, onChange: (e) => setFormat(e.target.value), children: [_jsx("option", { value: "json", children: "JSON" }), _jsx("option", { value: "mbox", children: "Mbox" })] })] }), _jsx(Button, { type: "submit", variant: "secondary", className: "!w-auto", loading: creating, disabled: creating || !mailboxUid.trim(), children: "Create export" })] }), createError && _jsx(Alert, { children: createError }), loadError && _jsx(Alert, { children: loadError }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : requests.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No export requests." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: requests.map((request) => (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsxs("span", { children: [request.mailboxUid, " \u00B7 ", request.format.toUpperCase(), " \u00B7", " ", new Date(request.dateCreated).toLocaleString(), request.status === "failed" && request.errorMessage && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.errorMessage] }))] }), _jsxs("span", { className: "flex items-center gap-3", children: [_jsx("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${statusBadgeClass(request.status)}`, children: request.status }), request.status === "ready" && (_jsx("a", { href: exportRequestDownloadUrl(request.uid), className: "text-primary-dark hover:underline font-medium", children: "Download" }))] })] }, request.uid))) }))] }));
76
+ }
77
+ function ImportRequestsSection() {
78
+ const [requests, setRequests] = useState([]);
79
+ const [loading, setLoading] = useState(true);
80
+ const [loadError, setLoadError] = useState(null);
81
+ const [mailboxUid, setMailboxUid] = useState("");
82
+ const [folders, setFolders] = useState([]);
83
+ const [targetFolderUid, setTargetFolderUid] = useState("");
84
+ const [folderError, setFolderError] = useState(null);
85
+ const [uploading, setUploading] = useState(false);
86
+ const [uploadError, setUploadError] = useState(null);
87
+ const fileInputRef = useRef(null);
88
+ // Same stale-response guard as ExportRequestsSection's loadRequests() - the Upload button is enabled
89
+ // independently of this section's own `loading`, so a fast upload-then-reload can resolve before the
90
+ // initial mount fetch does.
91
+ const loadSeq = useRef(0);
92
+ function loadRequests() {
93
+ const seq = ++loadSeq.current;
94
+ return listImportRequests()
95
+ .then((data) => {
96
+ if (seq === loadSeq.current)
97
+ setRequests(data);
98
+ })
99
+ .catch((err) => {
100
+ if (seq === loadSeq.current)
101
+ setLoadError(err instanceof ApiRequestError ? err.message : "Could not load import requests.");
102
+ });
103
+ }
104
+ useEffect(() => {
105
+ loadRequests().finally(() => setLoading(false));
106
+ // eslint-disable-next-line react-hooks/exhaustive-deps
107
+ }, []);
108
+ function handleLoadFolders() {
109
+ const uid = mailboxUid.trim();
110
+ setTargetFolderUid("");
111
+ setFolders([]);
112
+ setFolderError(null);
113
+ if (!uid) {
114
+ return;
115
+ }
116
+ listFolders(uid)
117
+ .then((all) => {
118
+ const mailFolders = all.filter((f) => !NON_MAIL_FOLDER_TYPES.has(f.type));
119
+ setFolders(mailFolders);
120
+ setTargetFolderUid(mailFolders[0]?.uid ?? "");
121
+ })
122
+ .catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load this mailbox's folders."));
123
+ }
124
+ function handleFileChange(e) {
125
+ const file = e.target.files?.[0];
126
+ e.target.value = "";
127
+ if (!file || !targetFolderUid || !mailboxUid.trim()) {
128
+ return;
129
+ }
130
+ setUploadError(null);
131
+ setUploading(true);
132
+ uploadMailboxImport(file, { format: importFormatFromFilename(file.name), targetFolderUid, mailboxUid: mailboxUid.trim() })
133
+ .then(() => loadRequests())
134
+ .catch((err) => setUploadError(err instanceof ApiRequestError ? err.message : "Could not upload this file."))
135
+ .finally(() => setUploading(false));
136
+ }
137
+ return (_jsxs("section", { children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted mb-3", children: "Import requests" }), _jsxs("div", { className: "flex items-end gap-3 mb-2 flex-wrap", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Mailbox UID" }), _jsx("input", { "aria-label": "Import mailbox UID", className: INPUT_CLASS, value: mailboxUid, onChange: (e) => setMailboxUid(e.target.value), onBlur: handleLoadFolders })] }), folders.length > 0 && (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Destination folder" }), _jsx("select", { "aria-label": "Import destination folder", className: INPUT_CLASS, value: targetFolderUid, onChange: (e) => setTargetFolderUid(e.target.value), children: folders.map((folder) => (_jsx("option", { value: folder.uid, children: folder.name }, folder.uid))) })] })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: uploading, disabled: uploading || !targetFolderUid, onClick: () => fileInputRef.current?.click(), children: "Upload Mbox or PST file" }), _jsx("input", { ref: fileInputRef, type: "file", accept: ".mbox,.pst", "aria-label": "Upload mail archive", className: "hidden", onChange: handleFileChange })] }), folderError && _jsx(Alert, { children: folderError }), uploadError && _jsx(Alert, { children: uploadError }), loadError && _jsx(Alert, { children: loadError }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : requests.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No import requests." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: requests.map((request) => (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsxs("span", { children: [request.mailboxUid, " \u00B7 ", request.format.toUpperCase(), " \u00B7", " ", new Date(request.dateCreated).toLocaleString(), request.status === "completed" && (_jsxs("span", { className: "text-text-muted", children: [" ", "\u2014 ", request.importedCount ?? 0, " imported", request.failedCount ? `, ${request.failedCount} failed` : ""] })), request.status === "failed" && request.errorMessage && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.errorMessage] }))] }), _jsx("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${statusBadgeClass(request.status)}`, children: request.status })] }, request.uid))) }))] }));
138
+ }
139
+ function ErasureRequestsSection() {
140
+ const [requests, setRequests] = useState([]);
141
+ const [loading, setLoading] = useState(true);
142
+ const [loadError, setLoadError] = useState(null);
143
+ const [actionErrors, setActionErrors] = useState({});
144
+ const [actingOn, setActingOn] = useState({});
145
+ const [denyTarget, setDenyTarget] = useState(null);
146
+ const [denyReason, setDenyReason] = useState("");
147
+ const [denying, setDenying] = useState(false);
148
+ const [denyError, setDenyError] = useState(null);
149
+ function loadRequests() {
150
+ return listErasureRequests()
151
+ .then(setRequests)
152
+ .catch((err) => setLoadError(err instanceof ApiRequestError ? err.message : "Could not load erasure requests."));
153
+ }
154
+ useEffect(() => {
155
+ loadRequests().finally(() => setLoading(false));
156
+ // eslint-disable-next-line react-hooks/exhaustive-deps
157
+ }, []);
158
+ async function handleApprove(request) {
159
+ setActingOn((prev) => ({ ...prev, [request.uid]: true }));
160
+ setActionErrors((prev) => ({ ...prev, [request.uid]: "" }));
161
+ try {
162
+ await approveErasureRequest(request.uid);
163
+ await loadRequests();
164
+ }
165
+ catch (err) {
166
+ setActionErrors((prev) => ({
167
+ ...prev,
168
+ [request.uid]: err instanceof ApiRequestError ? err.message : "Could not approve this request.",
169
+ }));
170
+ }
171
+ finally {
172
+ setActingOn((prev) => ({ ...prev, [request.uid]: false }));
173
+ }
174
+ }
175
+ function closeDenyModal() {
176
+ setDenyTarget(null);
177
+ setDenyReason("");
178
+ setDenyError(null);
179
+ }
180
+ async function handleDeny() {
181
+ setDenyError(null);
182
+ setDenying(true);
183
+ try {
184
+ // Only invoked from the deny modal below, which only renders once denyTarget is set.
185
+ await denyErasureRequest(denyTarget.uid, denyReason);
186
+ closeDenyModal();
187
+ await loadRequests();
188
+ }
189
+ catch (err) {
190
+ setDenyError(err instanceof ApiRequestError ? err.message : "Could not deny this request.");
191
+ }
192
+ finally {
193
+ setDenying(false);
194
+ }
195
+ }
196
+ return (_jsxs("section", { children: [_jsx("h2", { className: "text-sm font-bold uppercase tracking-wide text-text-muted mb-3", children: "Erasure requests" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Self-service only \u2014 a mailbox owner requests their own account's erasure; there is no admin-initiated path here, only review." }), loadError && _jsx(Alert, { children: loadError }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : requests.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No erasure requests." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: requests.map((request) => (_jsxs("li", { className: "border border-border rounded-sm p-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsxs("span", { className: "text-sm", children: [request.mailboxUid, " \u00B7 ", new Date(request.dateCreated).toLocaleString(), request.status === "denied" && request.reason && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.reason] })), request.status === "completed" && request.purgedCount !== undefined && (_jsxs("span", { className: "text-text-muted", children: [" \u2014 ", request.purgedCount, " records purged"] }))] }), _jsx("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${statusBadgeClass(request.status)}`, children: request.status })] }), actionErrors[request.uid] && _jsx(Alert, { children: actionErrors[request.uid] }), request.status === "pending" && (_jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: actingOn[request.uid], disabled: actingOn[request.uid], onClick: () => handleApprove(request), children: "Approve" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: actingOn[request.uid], onClick: () => setDenyTarget(request), children: "Deny" })] }))] }, request.uid))) })), _jsxs(Modal, { open: denyTarget !== null, onClose: closeDenyModal, title: "Deny erasure request", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm mb-4", children: [_jsx("span", { className: "font-semibold", children: "Reason" }), _jsx("input", { "aria-label": "Denial reason", className: INPUT_CLASS, value: denyReason, onChange: (e) => setDenyReason(e.target.value) })] }), denyError && _jsx(Alert, { children: denyError }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: denying, onClick: closeDenyModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: denying, disabled: denying || !denyReason.trim(), onClick: handleDeny, children: "Deny" })] })] })] }));
197
+ }
@@ -0,0 +1,141 @@
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 { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
9
+ import { deleteEscrowScope, getEscrowScope, updateEscrowScope, } from "@rapidmx/react-shared/admin/escrowScopesApi.js";
10
+ import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
11
+ import EscrowScopeKeyAndHoldersFields from "../../shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.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
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
16
+ 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";
17
+ function toFieldsValue(scope) {
18
+ return {
19
+ publicKey: scope.publicKey.publicKey,
20
+ keyType: scope.publicKey.type,
21
+ fingerprint: scope.publicKey.fingerprint,
22
+ notBefore: toDatetimeLocal(new Date(scope.publicKey.notBefore).toISOString()),
23
+ notAfter: toDatetimeLocal(new Date(scope.publicKey.notAfter).toISOString()),
24
+ holderUserUids: scope.holderUserUids,
25
+ requiredHolders: scope.requiredHolders,
26
+ notifySubjectOnAccess: scope.notifySubjectOnAccess,
27
+ };
28
+ }
29
+ export default function EscrowScopeDetailPage(props) {
30
+ return (_jsx(AdminShell, { ...props, active: "escrowScopes", children: _jsx(EscrowScopeDetailContent, { uid: props.params.uid }) }));
31
+ }
32
+ function EscrowScopeDetailContent({ uid }) {
33
+ const [original, setOriginal] = useState(null);
34
+ const [name, setName] = useState("");
35
+ const [description, setDescription] = useState("");
36
+ const [fields, setFields] = useState(null);
37
+ const [loading, setLoading] = useState(true);
38
+ const [error, setError] = useState(null);
39
+ const [saving, setSaving] = useState(false);
40
+ const [saved, setSaved] = useState(false);
41
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
42
+ const [deleting, setDeleting] = useState(false);
43
+ const [deleteError, setDeleteError] = useState(null);
44
+ useEffect(() => {
45
+ setLoading(true);
46
+ setError(null);
47
+ getEscrowScope(uid)
48
+ .then((loaded) => {
49
+ setOriginal(loaded);
50
+ if (loaded) {
51
+ setName(loaded.name);
52
+ setDescription(loaded.description ?? "");
53
+ setFields(toFieldsValue(loaded));
54
+ }
55
+ })
56
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this escrow scope."))
57
+ .finally(() => setLoading(false));
58
+ }, [uid]);
59
+ // Only ever invoked from the form below, which itself only renders once `original`/`fields` are
60
+ // loaded (the early returns further down cover every other state) — the non-null assertions reflect
61
+ // that real invariant, not an unchecked assumption. Matches `TransportRuleDetailContent.handleSubmit`'s
62
+ // identical pattern.
63
+ async function handleSubmit(e) {
64
+ e.preventDefault();
65
+ setError(null);
66
+ if (!name.trim()) {
67
+ setError("A name is required.");
68
+ return;
69
+ }
70
+ if (!fields.publicKey.trim() || !fields.keyType.trim() || !fields.fingerprint.trim()) {
71
+ setError("The public key, its type, and its fingerprint are all required.");
72
+ return;
73
+ }
74
+ if (fields.holderUserUids.length === 0) {
75
+ setError("At least one holder is required.");
76
+ return;
77
+ }
78
+ if (fields.requiredHolders < 1 || fields.requiredHolders > fields.holderUserUids.length) {
79
+ setError("Required holders must be between 1 and the number of holders.");
80
+ return;
81
+ }
82
+ setSaving(true);
83
+ setSaved(false);
84
+ try {
85
+ const updated = await updateEscrowScope({
86
+ uid: original.uid,
87
+ version: original.version,
88
+ name: name.trim(),
89
+ description: description.trim() || undefined,
90
+ publicKey: {
91
+ publicKey: fields.publicKey.trim(),
92
+ type: fields.keyType.trim(),
93
+ fingerprint: fields.fingerprint.trim(),
94
+ notBefore: new Date(fields.notBefore).getTime(),
95
+ notAfter: new Date(fields.notAfter).getTime(),
96
+ },
97
+ holderUserUids: fields.holderUserUids,
98
+ requiredHolders: fields.requiredHolders,
99
+ notifySubjectOnAccess: fields.notifySubjectOnAccess,
100
+ });
101
+ setOriginal(updated);
102
+ setFields(toFieldsValue(updated));
103
+ setSaved(true);
104
+ }
105
+ catch (err) {
106
+ setError(err instanceof ApiRequestError ? err.message : "Could not save this escrow scope.");
107
+ }
108
+ finally {
109
+ setSaving(false);
110
+ }
111
+ }
112
+ function closeDeleteModal() {
113
+ setConfirmingDelete(false);
114
+ }
115
+ // Only ever invoked from the delete-confirmation modal below, which itself only renders once
116
+ // `original` is resolved — same non-null-assertion precedent as `handleSubmit` above.
117
+ async function handleDelete() {
118
+ setDeleting(true);
119
+ setDeleteError(null);
120
+ try {
121
+ await deleteEscrowScope(original.uid, original.version);
122
+ window.location.href = "/admin/escrow-scopes";
123
+ }
124
+ catch (err) {
125
+ // Most commonly a 409 ("referenced by an existing Matter") — see `deleteEscrowScope()`'s own
126
+ // doc comment - surfaced as-is rather than special-cased.
127
+ setDeleteError(err instanceof ApiRequestError ? err.message : "Could not delete this escrow scope.");
128
+ setDeleting(false);
129
+ }
130
+ }
131
+ if (loading) {
132
+ return _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" });
133
+ }
134
+ if (error && !original) {
135
+ return _jsx(Alert, { children: error });
136
+ }
137
+ if (!original || !fields) {
138
+ return _jsx(Alert, { children: "Escrow scope not found." });
139
+ }
140
+ return (_jsxs("div", { className: "max-w-3xl", children: [_jsxs("div", { className: "flex items-start justify-between gap-4 mb-1", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin/escrow-scopes", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All escrow scopes" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: original.name })] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0 !border-danger !text-danger hover:!border-danger hover:!text-danger", onClick: () => setConfirmingDelete(true), children: "Delete scope" })] }), error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-5 mt-4", children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx(FormField, { label: "Name", htmlFor: "name", children: _jsx("input", { id: "name", type: "text", className: INPUT_CLASS, value: name, onChange: (e) => setName(e.target.value) }) }), _jsx(FormField, { label: "Description (optional)", htmlFor: "description", children: _jsx("input", { id: "description", type: "text", className: INPUT_CLASS, value: description, onChange: (e) => setDescription(e.target.value) }) })] }), _jsx(EscrowScopeKeyAndHoldersFields, { value: fields, onChange: setFields }), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save changes" }) })] }), _jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete escrow scope", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Are you sure you want to delete ", _jsx("strong", { children: original.name }), "? This cannot be undone, and fails if any Matter still references this scope."] }), deleteError && _jsx(Alert, { children: deleteError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: deleting, onClick: closeDeleteModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: deleting, disabled: deleting, onClick: handleDelete, children: "Delete" })] })] })] }));
141
+ }
@@ -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 { listEscrowScopes } from "@rapidmx/react-shared/admin/escrowScopesApi.js";
9
+ import AdminShell from "../../shared/components/admin/layout/AdminShell.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 EscrowScopesPage(props) {
14
+ return (_jsx(AdminShell, { ...props, active: "escrowScopes", children: _jsx(EscrowScopesContent, {}) }));
15
+ }
16
+ function EscrowScopesContent() {
17
+ const [page, setPage] = useState(0);
18
+ const [scopes, setScopes] = useState([]);
19
+ const [loading, setLoading] = useState(true);
20
+ const [error, setError] = useState(null);
21
+ useEffect(() => {
22
+ setLoading(true);
23
+ setError(null);
24
+ listEscrowScopes({ page, limit: PAGE_SIZE })
25
+ .then(setScopes)
26
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load escrow scopes."))
27
+ .finally(() => setLoading(false));
28
+ }, [page]);
29
+ const hasNextPage = scopes.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: "Escrow scopes" }), _jsx("a", { href: "/admin/escrow-scopes/new", children: _jsx(Button, { type: "button", className: "!w-auto", children: "+ New escrow scope" }) })] }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Configuring who counts as a holder, the dual-control threshold, and a scope's own public key is an administrative act, separate from actually holding the eDiscovery/compliance role \u2014 this page never grants you access to any mailbox's escrowed key material." }), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : scopes.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No escrow scopes yet." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Name", "Holders", "Required holders", "Notify subject", ""].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: scopes.map((scope) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: scope.name }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: scope.holderUserUids.length }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: scope.requiredHolders }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border", children: scope.notifySubjectOnAccess ? "Yes" : "No" }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border text-right", children: _jsx("a", { href: `/admin/escrow-scopes/${encodeURIComponent(scope.uid)}`, className: "text-primary-dark hover:underline font-medium", children: "View" }) })] }, scope.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,69 @@
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 { createEscrowScope } from "@rapidmx/react-shared/admin/escrowScopesApi.js";
9
+ import AdminShell from "../../../shared/components/admin/layout/AdminShell.js";
10
+ import EscrowScopeKeyAndHoldersFields, { emptyEscrowScopeKeyAndHoldersValue, } from "../../../shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js";
11
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
12
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
13
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
14
+ const INPUT_CLASS = "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
15
+ export default function NewEscrowScopePage(props) {
16
+ return (_jsx(AdminShell, { ...props, active: "escrowScopes", children: _jsx(NewEscrowScopeForm, {}) }));
17
+ }
18
+ function NewEscrowScopeForm() {
19
+ const [name, setName] = useState("");
20
+ const [description, setDescription] = useState("");
21
+ const [keyAndHolders, setKeyAndHolders] = useState(emptyEscrowScopeKeyAndHoldersValue());
22
+ const [error, setError] = useState(null);
23
+ const [saving, setSaving] = useState(false);
24
+ async function handleSubmit(e) {
25
+ e.preventDefault();
26
+ setError(null);
27
+ if (!name.trim()) {
28
+ setError("A name is required.");
29
+ return;
30
+ }
31
+ if (!keyAndHolders.publicKey.trim() || !keyAndHolders.keyType.trim() || !keyAndHolders.fingerprint.trim()) {
32
+ setError("The public key, its type, and its fingerprint are all required.");
33
+ return;
34
+ }
35
+ if (keyAndHolders.holderUserUids.length === 0) {
36
+ setError("At least one holder is required.");
37
+ return;
38
+ }
39
+ if (keyAndHolders.requiredHolders < 1 || keyAndHolders.requiredHolders > keyAndHolders.holderUserUids.length) {
40
+ setError("Required holders must be between 1 and the number of holders.");
41
+ return;
42
+ }
43
+ setSaving(true);
44
+ try {
45
+ const created = await createEscrowScope({
46
+ name: name.trim(),
47
+ description: description.trim() || undefined,
48
+ publicKey: {
49
+ publicKey: keyAndHolders.publicKey.trim(),
50
+ type: keyAndHolders.keyType.trim(),
51
+ fingerprint: keyAndHolders.fingerprint.trim(),
52
+ notBefore: new Date(keyAndHolders.notBefore).getTime(),
53
+ notAfter: new Date(keyAndHolders.notAfter).getTime(),
54
+ },
55
+ holderUserUids: keyAndHolders.holderUserUids,
56
+ requiredHolders: keyAndHolders.requiredHolders,
57
+ notifySubjectOnAccess: keyAndHolders.notifySubjectOnAccess,
58
+ });
59
+ window.location.href = `/admin/escrow-scopes/${encodeURIComponent(created.uid)}`;
60
+ }
61
+ catch (err) {
62
+ setError(err instanceof ApiRequestError ? err.message : "Could not create the escrow scope.");
63
+ }
64
+ finally {
65
+ setSaving(false);
66
+ }
67
+ }
68
+ return (_jsxs("div", { className: "max-w-3xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "New escrow scope" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Configuring who counts as a holder is an administrative act, deliberately separate from actually holding the eDiscovery/compliance role." }), 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: "Legal Hold Q1" }) }), _jsx(FormField, { label: "Description (optional)", htmlFor: "description", children: _jsx("input", { id: "description", type: "text", className: INPUT_CLASS, value: description, onChange: (e) => setDescription(e.target.value) }) })] }), _jsx(EscrowScopeKeyAndHoldersFields, { value: keyAndHolders, onChange: setKeyAndHolders }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Create escrow scope" }), _jsx("a", { href: "/admin/escrow-scopes", children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", children: "Cancel" }) })] })] })] }));
69
+ }
@@ -5,12 +5,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useEffect, useState } from "react";
7
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import { getMailbox, impersonateUser } from "@rapidmx/react-shared/mail/mailApi.js";
8
+ import { deleteMailbox, getMailbox, impersonateUser } from "@rapidmx/react-shared/mail/mailApi.js";
9
9
  import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
10
10
  import ShareAccessCard from "../../shared/components/admin/mailboxes/ShareAccessCard.js";
11
11
  import ResourceSettingsCard from "../../shared/components/admin/mailboxes/ResourceSettingsCard.js";
12
12
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
13
13
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
14
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
14
15
  function formatBytes(bytes) {
15
16
  if (bytes >= 1000000000)
16
17
  return `${(bytes / 1000000000).toFixed(2)} GB`;
@@ -28,6 +29,9 @@ function MailboxDetailContent({ uid, impersonationBaseUrl }) {
28
29
  const [loading, setLoading] = useState(true);
29
30
  const [error, setError] = useState(null);
30
31
  const [impersonating, setImpersonating] = useState(false);
32
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
33
+ const [deleting, setDeleting] = useState(false);
34
+ const [deleteError, setDeleteError] = useState(null);
31
35
  async function handleAccessMailbox(ownerUserUid) {
32
36
  setImpersonating(true);
33
37
  try {
@@ -39,6 +43,26 @@ function MailboxDetailContent({ uid, impersonationBaseUrl }) {
39
43
  setImpersonating(false);
40
44
  }
41
45
  }
46
+ function closeDeleteModal() {
47
+ setConfirmingDelete(false);
48
+ }
49
+ // Only ever invoked from the delete-confirmation modal below, which itself only renders once
50
+ // `mailbox` is resolved (the `error || !mailbox` branch below returns before this content mounts).
51
+ async function handleDelete() {
52
+ setDeleting(true);
53
+ setDeleteError(null);
54
+ try {
55
+ await deleteMailbox(mailbox.uid, mailbox.version);
56
+ window.location.href = "/admin";
57
+ }
58
+ catch (err) {
59
+ // Most commonly a 409 if this mailbox is a custodian on an open legal hold (restapi's own
60
+ // `assertNotOnLegalHold()`, naming the blocking Matter uid(s)) - surfaced as-is, same as
61
+ // every other destructive-action error in this codebase, rather than special-cased here.
62
+ setDeleteError(err instanceof ApiRequestError ? err.message : "Could not delete this mailbox.");
63
+ setDeleting(false);
64
+ }
65
+ }
42
66
  useEffect(() => {
43
67
  setLoading(true);
44
68
  setError(null);
@@ -55,5 +79,5 @@ function MailboxDetailContent({ uid, impersonationBaseUrl }) {
55
79
  }
56
80
  const quarantineHref = `/admin/quarantine?mailboxUid=${encodeURIComponent(mailbox.uid)}`;
57
81
  const ingestQueueHref = `/admin/ingest-queue?mailboxUid=${encodeURIComponent(mailbox.uid)}`;
58
- return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All mailboxes" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: mailbox.primarySmtpAddress })] }), mailbox.ownerUserUid && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", loading: impersonating, disabled: impersonating, onClick: () => handleAccessMailbox(mailbox.ownerUserUid), children: "Access this mailbox" }))] }), _jsxs("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: "Display name" }), _jsx("dd", { children: mailbox.displayName }), _jsx("dt", { className: "text-text-muted", children: "Owner" }), _jsx("dd", { children: mailbox.ownerUserUid ?? "None (shared mailbox)" }), _jsx("dt", { className: "text-text-muted", children: "Timezone" }), _jsx("dd", { children: mailbox.timezone }), _jsx("dt", { className: "text-text-muted", children: "Quota" }), _jsxs("dd", { children: [formatBytes(mailbox.usedBytes), " / ", formatBytes(mailbox.quotaBytes)] }), _jsx("dt", { className: "text-text-muted", children: "Alias addresses" }), _jsx("dd", { children: mailbox.aliasAddresses.length > 0 ? mailbox.aliasAddresses.join(", ") : "None" }), mailbox.isResource && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Resource type" }), _jsx("dd", { className: "capitalize", children: mailbox.resourceType ?? "room" })] })), _jsx("dt", { className: "text-text-muted", children: "Created" }), _jsx("dd", { children: new Date(mailbox.dateCreated).toLocaleString() })] }), _jsxs("div", { className: "flex gap-4 mt-5 pt-5 border-t border-border text-sm font-medium", children: [_jsx("a", { href: quarantineHref, className: "text-primary-dark hover:underline", children: "View quarantine" }), _jsx("a", { href: ingestQueueHref, className: "text-primary-dark hover:underline", children: "View ingest queue" })] })] }), _jsx(ShareAccessCard, { mailboxUid: mailbox.uid }), mailbox.isResource && _jsx(ResourceSettingsCard, { mailbox: mailbox, onUpdate: setMailbox })] }));
82
+ return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All mailboxes" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: mailbox.primarySmtpAddress })] }), _jsxs("div", { className: "flex gap-3 shrink-0", children: [mailbox.ownerUserUid && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: impersonating, disabled: impersonating, onClick: () => handleAccessMailbox(mailbox.ownerUserUid), children: "Access this mailbox" })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto !border-danger !text-danger hover:!border-danger hover:!text-danger", onClick: () => setConfirmingDelete(true), children: "Delete mailbox" })] })] }), _jsxs("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: "Display name" }), _jsx("dd", { children: mailbox.displayName }), _jsx("dt", { className: "text-text-muted", children: "Owner" }), _jsx("dd", { children: mailbox.ownerUserUid ?? "None (shared mailbox)" }), _jsx("dt", { className: "text-text-muted", children: "Timezone" }), _jsx("dd", { children: mailbox.timezone }), _jsx("dt", { className: "text-text-muted", children: "Quota" }), _jsxs("dd", { children: [formatBytes(mailbox.usedBytes), " / ", formatBytes(mailbox.quotaBytes)] }), _jsx("dt", { className: "text-text-muted", children: "Alias addresses" }), _jsx("dd", { children: mailbox.aliasAddresses.length > 0 ? mailbox.aliasAddresses.join(", ") : "None" }), mailbox.isResource && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Resource type" }), _jsx("dd", { className: "capitalize", children: mailbox.resourceType ?? "room" })] })), _jsx("dt", { className: "text-text-muted", children: "Created" }), _jsx("dd", { children: new Date(mailbox.dateCreated).toLocaleString() })] }), _jsxs("div", { className: "flex gap-4 mt-5 pt-5 border-t border-border text-sm font-medium", children: [_jsx("a", { href: quarantineHref, className: "text-primary-dark hover:underline", children: "View quarantine" }), _jsx("a", { href: ingestQueueHref, className: "text-primary-dark hover:underline", children: "View ingest queue" })] })] }), _jsx(ShareAccessCard, { mailboxUid: mailbox.uid }), mailbox.isResource && _jsx(ResourceSettingsCard, { mailbox: mailbox, onUpdate: setMailbox }), _jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete mailbox", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Are you sure you want to delete ", _jsx("strong", { children: mailbox.primarySmtpAddress }), "? This permanently deletes the mailbox and everything in it, and cannot be undone. It fails if this mailbox is a custodian on an active legal hold."] }), deleteError && _jsx(Alert, { children: deleteError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: deleting, onClick: closeDeleteModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: deleting, disabled: deleting, onClick: handleDelete, children: "Delete" })] })] })] }));
59
83
  }
@@ -0,0 +1,65 @@
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 { MIN_AUDIT_LOG_RETENTION_DAYS, getRetentionPolicy, updateRetentionPolicy, } from "@rapidmx/react-shared/admin/retentionPolicyApi.js";
9
+ import AdminShell from "../../shared/components/admin/layout/AdminShell.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 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";
13
+ export default function RetentionPolicyPage(props) {
14
+ return (_jsx(AdminShell, { ...props, active: "retentionPolicy", children: _jsx(RetentionPolicyContent, {}) }));
15
+ }
16
+ function RetentionPolicyContent() {
17
+ const [policy, setPolicy] = useState(null);
18
+ const [loading, setLoading] = useState(true);
19
+ const [loadError, setLoadError] = useState(null);
20
+ useEffect(() => {
21
+ getRetentionPolicy()
22
+ .then(setPolicy)
23
+ .catch((err) => setLoadError(err instanceof ApiRequestError ? err.message : "Could not load the retention policy."))
24
+ .finally(() => setLoading(false));
25
+ }, []);
26
+ if (loading) {
27
+ return _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" });
28
+ }
29
+ if (loadError || !policy) {
30
+ return _jsx(Alert, { children: loadError });
31
+ }
32
+ return _jsx(RetentionPolicyForm, { policy: policy, onChange: setPolicy });
33
+ }
34
+ function RetentionPolicyForm({ policy, onChange }) {
35
+ const [messageRetentionDays, setMessageRetentionDays] = useState(policy.messageRetentionDays?.toString() ?? "");
36
+ const [auditLogRetentionDays, setAuditLogRetentionDays] = useState(policy.auditLogRetentionDays?.toString() ?? "");
37
+ const [error, setError] = useState(null);
38
+ const [saving, setSaving] = useState(false);
39
+ const [saved, setSaved] = useState(false);
40
+ async function handleSubmit(e) {
41
+ e.preventDefault();
42
+ setError(null);
43
+ setSaved(false);
44
+ setSaving(true);
45
+ try {
46
+ const patch = {};
47
+ if (messageRetentionDays.trim() !== "") {
48
+ patch.messageRetentionDays = Number(messageRetentionDays);
49
+ }
50
+ if (auditLogRetentionDays.trim() !== "") {
51
+ patch.auditLogRetentionDays = Number(auditLogRetentionDays);
52
+ }
53
+ const updated = await updateRetentionPolicy(patch);
54
+ onChange(updated);
55
+ setSaved(true);
56
+ }
57
+ catch (err) {
58
+ setError(err instanceof ApiRequestError ? err.message : "Could not save the retention policy.");
59
+ }
60
+ finally {
61
+ setSaving(false);
62
+ }
63
+ }
64
+ return (_jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-1", children: "Retention Policy" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Automatically and permanently deletes mail/audit-log entries older than the configured age. Leave a field blank for no automatic purge. This does not apply to a mailbox that is a custodian on an active legal hold \u2014 a held message is skipped and retried on a later run once the hold is lifted." }), error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Message retention (days)" }), _jsx("input", { "aria-label": "Message retention (days)", type: "number", min: 1, className: INPUT_CLASS, value: messageRetentionDays, onChange: (e) => setMessageRetentionDays(e.target.value), placeholder: "No automatic purge" }), _jsx("span", { className: "text-xs text-text-muted", children: "Applies to any message, in any folder, regardless of age." })] }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Audit log retention (days)" }), _jsx("input", { "aria-label": "Audit log retention (days)", type: "number", min: MIN_AUDIT_LOG_RETENTION_DAYS, className: INPUT_CLASS, value: auditLogRetentionDays, onChange: (e) => setAuditLogRetentionDays(e.target.value), placeholder: "Keep forever" }), _jsxs("span", { className: "text-xs text-text-muted", children: ["Cannot be set below ", MIN_AUDIT_LOG_RETENTION_DAYS, " days. Never applies to the tamper-evident escrow audit chain."] })] }), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }) })] })] }));
65
+ }
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { CUSTOM_STYLESHEET_LINK_ID } from "@rapidmx/react-shared/branding/useBranding.js";
3
+ export default function Layout({ children, branding }) {
4
+ const title = branding?.title || branding?.companyName
5
+ ? `${branding?.title || branding?.companyName}: Escrow Console`
6
+ : "RapidMX: Escrow Console";
7
+ const iconHref = branding?.iconUrl || branding?.logoUrl || "/images/logo.svg";
8
+ const stylesheetHref = branding?.stylesheetUrl;
9
+ return (_jsxs("html", { lang: "en", children: [_jsxs("head", { children: [_jsx("meta", { charSet: "utf-8" }), _jsx("meta", { name: "viewport", content: "width=device-width, initial-scale=1" }), _jsx("title", { children: title }), _jsx("link", { rel: "icon", type: "image/svg+xml", href: iconHref }), _jsx("link", { rel: "alternate icon", href: "/favicon.ico" }), _jsx("link", { rel: "preconnect", href: "https://fonts.googleapis.com" }), _jsx("link", { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" }), _jsx("link", { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" }), stylesheetHref && _jsx("link", { rel: "stylesheet", href: stylesheetHref, id: CUSTOM_STYLESHEET_LINK_ID })] }), _jsx("body", { children: children })] }));
10
+ }