@rapidmx/web-client 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/admin/audit-log/index.tsx +35 -8
- package/apps/admin/data-requests/index.tsx +480 -485
- package/apps/admin/distribution-lists/[uid].tsx +19 -1
- package/apps/admin/escrow-scopes/[uid].tsx +348 -237
- package/apps/admin/escrow-scopes/new/index.tsx +8 -2
- package/apps/admin/index.tsx +19 -1
- package/apps/admin/ingest-queue/index.tsx +44 -5
- package/apps/admin/mailboxes/[uid].tsx +211 -184
- package/apps/admin/quarantine/index.tsx +46 -14
- package/apps/admin/transport-rules/[uid].tsx +30 -1
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
- package/apps/admin/transport-rules/index.tsx +24 -6
- package/apps/admin/transport-rules/new/index.tsx +30 -1
- package/apps/escrow/_layout.tsx +2 -3
- package/apps/escrow/audit-log/index.tsx +196 -168
- package/apps/escrow/matters/[uid].tsx +621 -521
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
- package/apps/shared/components/admin/layout/AdminShell.tsx +3 -1
- package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
- package/apps/shared/components/admin/settings/BrandingForm.tsx +15 -4
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +18 -7
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +10 -2
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +6 -0
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +45 -12
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -595
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -98
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +49 -13
- package/apps/shared/components/admin/setup/SetupWizard.tsx +150 -34
- package/apps/shared/components/admin/signOut.ts +79 -0
- package/apps/shared/components/admin/usePagedList.tsx +129 -0
- package/apps/shared/components/calendar/EventModal.tsx +717 -549
- package/apps/shared/components/calendar/MonthView.tsx +173 -139
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
- package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
- package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
- package/apps/shared/components/calendar/allDay.ts +124 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +1 -1
- package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
- package/apps/shared/components/contacts/ContactForm.tsx +377 -348
- package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
- package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
- package/apps/shared/components/contacts/contactKeys.ts +78 -0
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
- package/apps/shared/components/layout/AppShell.tsx +87 -15
- package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +389 -298
- package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
- package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -128
- package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
- package/apps/shared/components/mail/MessageDetailPane.tsx +1318 -630
- package/apps/shared/components/mail/compose/ComposeContext.tsx +20 -15
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +854 -132
- package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +12 -2
- package/apps/shared/components/mail/pinnedSigners.ts +124 -0
- package/apps/shared/components/mail/verificationSeals.ts +125 -0
- package/apps/shared/components/mail/writableMailboxes.ts +101 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
- package/apps/shared/mail/listAllPages.ts +39 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +64 -55
- package/apps/shared/search/localIndexBuilder.ts +481 -179
- package/apps/shared/search/localIndexRpcClient.ts +228 -28
- package/apps/shared/search/localIndexSchema.ts +15 -6
- package/apps/shared/search/localIndexStorage.ts +98 -0
- package/apps/shared/search/localIndexVFS.ts +32 -1
- package/apps/shared/search/localIndexWorker.ts +648 -186
- package/apps/www/calendar/index.tsx +438 -417
- package/apps/www/contacts/[uid].tsx +21 -2
- package/apps/www/contacts/index.tsx +116 -12
- package/apps/www/index.tsx +1314 -1041
- package/apps/www/messages/[uid].tsx +18 -6
- package/apps/www/settings/auto-reply/index.tsx +134 -129
- package/apps/www/settings/booking-types/[uid].tsx +306 -300
- package/apps/www/settings/encryption/index.tsx +739 -216
- package/apps/www/settings/filters/[uid].tsx +171 -155
- package/apps/www/settings/filters/new/index.tsx +8 -1
- package/apps/www/settings/focused-inbox/index.tsx +150 -148
- package/apps/www/settings/privacy/index.tsx +494 -436
- package/apps/www/settings/read-receipts/index.tsx +148 -125
- package/apps/www/settings/sharing/index.tsx +17 -14
- package/apps/www/tasks/index.tsx +638 -579
- package/dist/apps/admin/audit-log/index.js +38 -8
- package/dist/apps/admin/data-requests/index.js +52 -72
- package/dist/apps/admin/distribution-lists/[uid].js +1 -1
- package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
- package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
- package/dist/apps/admin/index.js +4 -1
- package/dist/apps/admin/ingest-queue/index.js +24 -6
- package/dist/apps/admin/mailboxes/[uid].js +20 -4
- package/dist/apps/admin/quarantine/index.js +20 -11
- package/dist/apps/admin/transport-rules/[uid].js +18 -2
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
- package/dist/apps/admin/transport-rules/index.js +22 -6
- package/dist/apps/admin/transport-rules/new/index.js +18 -2
- package/dist/apps/escrow/_layout.js +3 -3
- package/dist/apps/escrow/audit-log/index.js +26 -1
- package/dist/apps/escrow/matters/[uid].js +77 -42
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
- package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -1
- package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +15 -4
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +13 -7
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +7 -2
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +6 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +34 -12
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +390 -78
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +40 -11
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +29 -13
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +87 -21
- package/dist/apps/shared/components/admin/signOut.js +75 -0
- package/dist/apps/shared/components/admin/usePagedList.js +99 -0
- package/dist/apps/shared/components/calendar/EventModal.js +142 -21
- package/dist/apps/shared/components/calendar/MonthView.js +12 -5
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
- package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
- package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
- package/dist/apps/shared/components/calendar/allDay.js +111 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +1 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
- package/dist/apps/shared/components/contacts/ContactForm.js +32 -10
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
- package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
- package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
- package/dist/apps/shared/components/layout/AppShell.js +83 -14
- package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +49 -12
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
- package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +73 -14
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
- package/dist/apps/shared/components/mail/MessageDetailPane.js +441 -36
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +699 -128
- package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +12 -3
- package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
- package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
- package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
- package/dist/apps/shared/mail/listAllPages.js +25 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +58 -54
- package/dist/apps/shared/search/localIndexBuilder.js +292 -37
- package/dist/apps/shared/search/localIndexRpcClient.js +188 -25
- package/dist/apps/shared/search/localIndexSchema.js +12 -6
- package/dist/apps/shared/search/localIndexStorage.js +86 -0
- package/dist/apps/shared/search/localIndexVFS.js +68 -40
- package/dist/apps/shared/search/localIndexWorker.js +519 -173
- package/dist/apps/www/calendar/index.js +28 -10
- package/dist/apps/www/contacts/[uid].js +13 -2
- package/dist/apps/www/contacts/index.js +80 -10
- package/dist/apps/www/index.js +339 -108
- package/dist/apps/www/messages/[uid].js +18 -6
- package/dist/apps/www/settings/auto-reply/index.js +9 -4
- package/dist/apps/www/settings/booking-types/[uid].js +17 -12
- package/dist/apps/www/settings/encryption/index.js +514 -97
- package/dist/apps/www/settings/filters/[uid].js +22 -7
- package/dist/apps/www/settings/filters/new/index.js +8 -2
- package/dist/apps/www/settings/focused-inbox/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.js +57 -22
- package/dist/apps/www/settings/read-receipts/index.js +10 -3
- package/dist/apps/www/settings/sharing/index.js +7 -11
- package/dist/apps/www/tasks/index.js +50 -10
- package/package.json +2 -2
|
@@ -3,13 +3,15 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
-
import { useEffect, useState } from "react";
|
|
6
|
+
import { useEffect, useRef, useState } from "react";
|
|
7
7
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
8
|
import { listAuditLog } from "@rapidmx/react-shared/admin/auditLogApi.js";
|
|
9
9
|
import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
|
|
10
10
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
11
11
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
12
12
|
const PAGE_SIZE = 25;
|
|
13
|
+
/** How long typing in a filter box has to pause before the log is re-fetched. */
|
|
14
|
+
export const FILTER_DEBOUNCE_MS = 300;
|
|
13
15
|
const FILTER_KEYS = ["mailboxUid", "actorUserUid", "action", "targetType"];
|
|
14
16
|
/** Seeds the filter form from the query string on first render — lets a link (e.g. a future "View audit
|
|
15
17
|
* log" deep link from a mailbox's detail page) land here pre-filtered, same convention as `quarantine`'s
|
|
@@ -32,26 +34,54 @@ export default function AuditLogPage(props) {
|
|
|
32
34
|
return (_jsx(AdminShell, { ...props, active: "auditLog", children: _jsx(AuditLogContent, {}) }));
|
|
33
35
|
}
|
|
34
36
|
function AuditLogContent() {
|
|
37
|
+
// `filters` is what the boxes show; `queryFilters` is what was last fetched - it follows `filters` once typing
|
|
38
|
+
// pauses, and stays `null` until the URL's filters are read so no unfiltered fetch goes out first.
|
|
35
39
|
const [filters, setFilters] = useState({});
|
|
40
|
+
const [queryFilters, setQueryFilters] = useState(null);
|
|
36
41
|
const [page, setPage] = useState(0);
|
|
37
42
|
const [entries, setEntries] = useState([]);
|
|
38
43
|
const [loading, setLoading] = useState(true);
|
|
39
44
|
const [error, setError] = useState(null);
|
|
45
|
+
const debounceTimer = useRef(undefined);
|
|
40
46
|
useEffect(() => {
|
|
41
|
-
|
|
47
|
+
const initial = readFiltersFromUrl();
|
|
48
|
+
setFilters(initial);
|
|
49
|
+
setQueryFilters(initial);
|
|
50
|
+
return () => clearTimeout(debounceTimer.current);
|
|
42
51
|
}, []);
|
|
43
52
|
useEffect(() => {
|
|
53
|
+
if (!queryFilters) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// Only the latest filters/page's response is applied, so a slow earlier fetch can't overwrite it.
|
|
57
|
+
let cancelled = false;
|
|
44
58
|
setLoading(true);
|
|
45
59
|
setError(null);
|
|
46
|
-
listAuditLog(
|
|
47
|
-
.then(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
60
|
+
listAuditLog(queryFilters, { page, limit: PAGE_SIZE })
|
|
61
|
+
.then((data) => {
|
|
62
|
+
if (!cancelled)
|
|
63
|
+
setEntries(data);
|
|
64
|
+
})
|
|
65
|
+
.catch((err) => {
|
|
66
|
+
if (!cancelled)
|
|
67
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not load the audit log.");
|
|
68
|
+
})
|
|
69
|
+
.finally(() => {
|
|
70
|
+
if (!cancelled)
|
|
71
|
+
setLoading(false);
|
|
72
|
+
});
|
|
73
|
+
return () => {
|
|
74
|
+
cancelled = true;
|
|
75
|
+
};
|
|
76
|
+
}, [queryFilters, page]);
|
|
51
77
|
function updateFilter(key, value) {
|
|
52
78
|
const next = { ...filters, [key]: value || undefined };
|
|
53
79
|
setFilters(next);
|
|
54
|
-
|
|
80
|
+
clearTimeout(debounceTimer.current);
|
|
81
|
+
debounceTimer.current = setTimeout(() => {
|
|
82
|
+
setQueryFilters(next);
|
|
83
|
+
setPage(0);
|
|
84
|
+
}, FILTER_DEBOUNCE_MS);
|
|
55
85
|
const params = new URLSearchParams();
|
|
56
86
|
for (const k of FILTER_KEYS) {
|
|
57
87
|
const v = next[k];
|
|
@@ -10,6 +10,7 @@ import { listImportRequests, uploadMailboxImport, } from "@rapidmx/react-shared/
|
|
|
10
10
|
import { approveErasureRequest, denyErasureRequest, listErasureRequests, } from "@rapidmx/react-shared/mail/erasureRequestApi.js";
|
|
11
11
|
import { listFolders } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
12
12
|
import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
|
|
13
|
+
import { LoadMoreButton, usePagedList } from "../../shared/components/admin/usePagedList.js";
|
|
13
14
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
14
15
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
15
16
|
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
@@ -18,43 +19,33 @@ const NON_MAIL_FOLDER_TYPES = new Set(["calendar", "contacts", "tasks", "notes"]
|
|
|
18
19
|
function importFormatFromFilename(filename) {
|
|
19
20
|
return filename.toLowerCase().endsWith(".pst") ? "pst" : "mbox";
|
|
20
21
|
}
|
|
22
|
+
/** Request statuses still waiting or running - shown as a neutral in-progress badge, never as success. Includes
|
|
23
|
+
* restapi's `processing` (exports/imports) and `in_progress` (an erasure that is running). */
|
|
24
|
+
const IN_PROGRESS_STATUSES = new Set(["pending", "processing", "in_progress"]);
|
|
21
25
|
function statusBadgeClass(status) {
|
|
22
26
|
if (status === "failed" || status === "denied")
|
|
23
27
|
return "bg-danger-bg text-danger";
|
|
24
|
-
if (status
|
|
28
|
+
if (IN_PROGRESS_STATUSES.has(status))
|
|
25
29
|
return "bg-surface-alt text-text-muted";
|
|
26
30
|
return "bg-success text-white";
|
|
27
31
|
}
|
|
32
|
+
/** A status as shown on its badge (`in_progress` -> `in progress`). */
|
|
33
|
+
function statusLabel(status) {
|
|
34
|
+
return status.replace(/_/g, " ");
|
|
35
|
+
}
|
|
28
36
|
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, {})] }) }));
|
|
37
|
+
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, newest first." })] }), _jsx(ExportRequestsSection, {}), _jsx(ImportRequestsSection, {}), _jsx(ErasureRequestsSection, {})] }) }));
|
|
30
38
|
}
|
|
31
39
|
function ExportRequestsSection() {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
40
|
+
// The create form below isn't gated behind `loading`, so a fast create-then-reload can resolve before the
|
|
41
|
+
// initial mount fetch does - `usePagedList()` only ever applies the most recently started load.
|
|
42
|
+
const list = usePagedList(listExportRequests, "Could not load export requests.");
|
|
35
43
|
const [mailboxUid, setMailboxUid] = useState("");
|
|
36
44
|
const [format, setFormat] = useState("json");
|
|
37
45
|
const [creating, setCreating] = useState(false);
|
|
38
46
|
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
47
|
useEffect(() => {
|
|
56
|
-
|
|
57
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
48
|
+
void list.reload();
|
|
58
49
|
}, []);
|
|
59
50
|
async function handleCreate(e) {
|
|
60
51
|
e.preventDefault();
|
|
@@ -63,7 +54,7 @@ function ExportRequestsSection() {
|
|
|
63
54
|
try {
|
|
64
55
|
await createExportRequest({ format, mailboxUid: mailboxUid.trim() });
|
|
65
56
|
setMailboxUid("");
|
|
66
|
-
await
|
|
57
|
+
await list.reload();
|
|
67
58
|
}
|
|
68
59
|
catch (err) {
|
|
69
60
|
setCreateError(err instanceof ApiRequestError ? err.message : "Could not start this export.");
|
|
@@ -72,12 +63,12 @@ function ExportRequestsSection() {
|
|
|
72
63
|
setCreating(false);
|
|
73
64
|
}
|
|
74
65
|
}
|
|
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" })) :
|
|
66
|
+
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 }), list.loadError && _jsx(Alert, { children: list.loadError }), list.loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : list.items.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No export requests." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: list.items.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: statusLabel(request.status) }), request.status === "ready" && (_jsx("a", { href: exportRequestDownloadUrl(request.uid), className: "text-primary-dark hover:underline font-medium", children: "Download" }))] })] }, request.uid))) })), _jsx(LoadMoreButton, { list: list, label: "Load more export requests" })] }));
|
|
76
67
|
}
|
|
77
68
|
function ImportRequestsSection() {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
69
|
+
// Same stale-response guard as ExportRequestsSection - the Upload button is enabled independently of this
|
|
70
|
+
// section's own `loading`.
|
|
71
|
+
const list = usePagedList(listImportRequests, "Could not load import requests.");
|
|
81
72
|
const [mailboxUid, setMailboxUid] = useState("");
|
|
82
73
|
const [folders, setFolders] = useState([]);
|
|
83
74
|
const [targetFolderUid, setTargetFolderUid] = useState("");
|
|
@@ -85,25 +76,8 @@ function ImportRequestsSection() {
|
|
|
85
76
|
const [uploading, setUploading] = useState(false);
|
|
86
77
|
const [uploadError, setUploadError] = useState(null);
|
|
87
78
|
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
79
|
useEffect(() => {
|
|
105
|
-
|
|
106
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
80
|
+
void list.reload();
|
|
107
81
|
}, []);
|
|
108
82
|
function handleLoadFolders() {
|
|
109
83
|
const uid = mailboxUid.trim();
|
|
@@ -130,46 +104,48 @@ function ImportRequestsSection() {
|
|
|
130
104
|
setUploadError(null);
|
|
131
105
|
setUploading(true);
|
|
132
106
|
uploadMailboxImport(file, { format: importFormatFromFilename(file.name), targetFolderUid, mailboxUid: mailboxUid.trim() })
|
|
133
|
-
.then(() =>
|
|
107
|
+
.then(() => list.reload())
|
|
134
108
|
.catch((err) => setUploadError(err instanceof ApiRequestError ? err.message : "Could not upload this file."))
|
|
135
109
|
.finally(() => setUploading(false));
|
|
136
110
|
}
|
|
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" })) :
|
|
111
|
+
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 }), list.loadError && _jsx(Alert, { children: list.loadError }), list.loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : list.items.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No import requests." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: list.items.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: statusLabel(request.status) })] }, request.uid))) })), _jsx(LoadMoreButton, { list: list, label: "Load more import requests" })] }));
|
|
138
112
|
}
|
|
139
113
|
function ErasureRequestsSection() {
|
|
140
|
-
const
|
|
141
|
-
const [
|
|
142
|
-
const [
|
|
143
|
-
const [
|
|
144
|
-
const [actingOn, setActingOn] = useState({});
|
|
114
|
+
const list = usePagedList(listErasureRequests, "Could not load erasure requests.");
|
|
115
|
+
const [approveTarget, setApproveTarget] = useState(null);
|
|
116
|
+
const [approving, setApproving] = useState(false);
|
|
117
|
+
const [approveError, setApproveError] = useState(null);
|
|
145
118
|
const [denyTarget, setDenyTarget] = useState(null);
|
|
146
119
|
const [denyReason, setDenyReason] = useState("");
|
|
147
120
|
const [denying, setDenying] = useState(false);
|
|
148
121
|
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
122
|
useEffect(() => {
|
|
155
|
-
|
|
156
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
123
|
+
void list.reload();
|
|
157
124
|
}, []);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
125
|
+
function closeApproveModal() {
|
|
126
|
+
setApproveTarget(null);
|
|
127
|
+
setApproveError(null);
|
|
128
|
+
}
|
|
129
|
+
// Escape/Close are ignored while a decision is being sent, so its result (or error) can't land on a modal
|
|
130
|
+
// reopened for a different request.
|
|
131
|
+
function dismissApproveModal() {
|
|
132
|
+
if (!approving)
|
|
133
|
+
closeApproveModal();
|
|
134
|
+
}
|
|
135
|
+
async function handleApprove() {
|
|
136
|
+
setApproveError(null);
|
|
137
|
+
setApproving(true);
|
|
161
138
|
try {
|
|
162
|
-
|
|
163
|
-
await
|
|
139
|
+
// Only invoked from the approve modal below, which only renders once approveTarget is set.
|
|
140
|
+
await approveErasureRequest(approveTarget.uid);
|
|
141
|
+
closeApproveModal();
|
|
142
|
+
await list.reload();
|
|
164
143
|
}
|
|
165
144
|
catch (err) {
|
|
166
|
-
|
|
167
|
-
...prev,
|
|
168
|
-
[request.uid]: err instanceof ApiRequestError ? err.message : "Could not approve this request.",
|
|
169
|
-
}));
|
|
145
|
+
setApproveError(err instanceof ApiRequestError ? err.message : "Could not approve this request.");
|
|
170
146
|
}
|
|
171
147
|
finally {
|
|
172
|
-
|
|
148
|
+
setApproving(false);
|
|
173
149
|
}
|
|
174
150
|
}
|
|
175
151
|
function closeDenyModal() {
|
|
@@ -177,6 +153,10 @@ function ErasureRequestsSection() {
|
|
|
177
153
|
setDenyReason("");
|
|
178
154
|
setDenyError(null);
|
|
179
155
|
}
|
|
156
|
+
function dismissDenyModal() {
|
|
157
|
+
if (!denying)
|
|
158
|
+
closeDenyModal();
|
|
159
|
+
}
|
|
180
160
|
async function handleDeny() {
|
|
181
161
|
setDenyError(null);
|
|
182
162
|
setDenying(true);
|
|
@@ -184,7 +164,7 @@ function ErasureRequestsSection() {
|
|
|
184
164
|
// Only invoked from the deny modal below, which only renders once denyTarget is set.
|
|
185
165
|
await denyErasureRequest(denyTarget.uid, denyReason);
|
|
186
166
|
closeDenyModal();
|
|
187
|
-
await
|
|
167
|
+
await list.reload();
|
|
188
168
|
}
|
|
189
169
|
catch (err) {
|
|
190
170
|
setDenyError(err instanceof ApiRequestError ? err.message : "Could not deny this request.");
|
|
@@ -193,5 +173,5 @@ function ErasureRequestsSection() {
|
|
|
193
173
|
setDenying(false);
|
|
194
174
|
}
|
|
195
175
|
}
|
|
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" })) :
|
|
176
|
+
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." }), list.loadError && _jsx(Alert, { children: list.loadError }), list.loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : list.items.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No erasure requests." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: list.items.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: statusLabel(request.status) })] }), request.status === "pending" && (_jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "button", className: "!w-auto", onClick: () => setApproveTarget(request), children: "Approve" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setDenyTarget(request), children: "Deny" })] }))] }, request.uid))) })), _jsx(LoadMoreButton, { list: list, label: "Load more erasure requests" }), _jsxs(Modal, { open: approveTarget !== null, onClose: dismissApproveModal, title: "Approve erasure request", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Approving permanently erases the mailbox ", _jsx("strong", { className: "break-all", children: approveTarget?.mailboxUid }), " ", "and everything in it - mail, contacts, calendars, tasks, and notes."] }), _jsx("p", { className: "text-sm font-semibold text-danger mb-4", children: "This is irreversible and cannot be undone." }), approveError && _jsx(Alert, { children: approveError }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: approving, onClick: closeApproveModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: approving, disabled: approving, onClick: handleApprove, children: "Erase mailbox" })] })] }), _jsxs(Modal, { open: denyTarget !== null, onClose: dismissDenyModal, 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
177
|
}
|
|
@@ -30,5 +30,5 @@ function DistributionListDetailContent({ uid }) {
|
|
|
30
30
|
if (error || !list) {
|
|
31
31
|
return _jsx(Alert, { children: error ?? "Distribution list not found." });
|
|
32
32
|
}
|
|
33
|
-
return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin/distribution-lists", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All distribution lists" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: list.primarySmtpAddress })] }), _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: "Name" }), _jsx("dd", { children: list.name }), _jsx("dt", { className: "text-text-muted", children: "Description" }), _jsx("dd", { children: list.description ?? "None" }), _jsx("dt", { className: "text-text-muted", children: "Owner" }), _jsx("dd", { children: list.ownerUserUid ?? "None" }), _jsx("dt", { className: "text-text-muted", children: "Alias addresses" }), _jsx("dd", { children: list.aliasAddresses && list.aliasAddresses.length > 0 ? list.aliasAddresses.join(", ") : "None" }), _jsx("dt", { className: "text-text-muted", children: "Restrict senders" }),
|
|
33
|
+
return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin/distribution-lists", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All distribution lists" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: list.primarySmtpAddress })] }), _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: "Name" }), _jsx("dd", { children: list.name }), _jsx("dt", { className: "text-text-muted", children: "Description" }), _jsx("dd", { children: list.description ?? "None" }), _jsx("dt", { className: "text-text-muted", children: "Owner" }), _jsx("dd", { children: list.ownerUserUid ?? "None" }), _jsx("dt", { className: "text-text-muted", children: "Alias addresses" }), _jsx("dd", { children: list.aliasAddresses && list.aliasAddresses.length > 0 ? list.aliasAddresses.join(", ") : "None" }), _jsx("dt", { className: "text-text-muted", children: "Restrict senders" }), _jsxs("dd", { children: [list.restrictSenders ? "Only members may send" : "No", list.restrictSenders ? (_jsxs("p", { role: "status", className: "mt-1.5 py-2 px-3 rounded-sm text-xs bg-warning/15 text-text", children: ["Members are recognized only by a verified (DKIM) signature, which requires the mail server\u2019s Authentication-Results configuration (the trusted authserv id,", " ", _jsx("code", { children: "mail:security:trusted_authserv_id" }), "). If that isn\u2019t configured, all mail sent to this list is dropped."] })) : (_jsx("p", { className: "mt-1 text-xs text-text-muted", children: "Restricting senders to members requires the mail server\u2019s Authentication-Results configuration (the trusted authserv id); without it, all mail to the list is dropped." }))] }), _jsx("dt", { className: "text-text-muted", children: "Created" }), _jsx("dd", { children: new Date(list.dateCreated).toLocaleString() })] }) }), _jsx(MemberListCard, { list: list, onUpdate: setList })] }));
|
|
34
34
|
}
|
|
@@ -8,7 +8,7 @@ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
|
8
8
|
import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
|
|
9
9
|
import { deleteEscrowScope, getEscrowScope, updateEscrowScope, } from "@rapidmx/react-shared/admin/escrowScopesApi.js";
|
|
10
10
|
import AdminShell from "../../shared/components/admin/layout/AdminShell.js";
|
|
11
|
-
import EscrowScopeKeyAndHoldersFields from "../../shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js";
|
|
11
|
+
import EscrowScopeKeyAndHoldersFields, { selfAsHolderError, } from "../../shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.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
14
|
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
@@ -26,10 +26,75 @@ function toFieldsValue(scope) {
|
|
|
26
26
|
notifySubjectOnAccess: scope.notifySubjectOnAccess,
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Describes every change to the security-relevant parts of a scope - who holds it, how many holders must approve,
|
|
31
|
+
* and its public key - so the administrator confirms exactly what they're changing before it's saved.
|
|
32
|
+
*/
|
|
33
|
+
export function describeSecurityChanges(original, next) {
|
|
34
|
+
const changes = [];
|
|
35
|
+
const added = next.holderUserUids.filter((holder) => !original.holderUserUids.includes(holder));
|
|
36
|
+
const removed = original.holderUserUids.filter((holder) => !next.holderUserUids.includes(holder));
|
|
37
|
+
if (added.length > 0) {
|
|
38
|
+
changes.push(`Add holders: ${added.join(", ")}`);
|
|
39
|
+
}
|
|
40
|
+
if (removed.length > 0) {
|
|
41
|
+
changes.push(`Remove holders: ${removed.join(", ")}`);
|
|
42
|
+
}
|
|
43
|
+
if (next.requiredHolders !== original.requiredHolders) {
|
|
44
|
+
changes.push(`Required approvals: ${original.requiredHolders} → ${next.requiredHolders}`);
|
|
45
|
+
}
|
|
46
|
+
if (next.notifySubjectOnAccess !== original.notifySubjectOnAccess) {
|
|
47
|
+
const onOff = (value) => (value ? "on" : "off");
|
|
48
|
+
changes.push(`Notify subject on access: ${onOff(original.notifySubjectOnAccess)} → ${onOff(next.notifySubjectOnAccess)}`);
|
|
49
|
+
}
|
|
50
|
+
const originalKey = toFieldsValue(original);
|
|
51
|
+
if (next.publicKey.trim() !== originalKey.publicKey || next.keyType.trim() !== originalKey.keyType) {
|
|
52
|
+
changes.push("Replace the public key");
|
|
53
|
+
}
|
|
54
|
+
if (next.fingerprint.trim() !== originalKey.fingerprint) {
|
|
55
|
+
changes.push(`Fingerprint: ${originalKey.fingerprint} → ${next.fingerprint.trim()}`);
|
|
56
|
+
}
|
|
57
|
+
if (validityChanged(originalKey, next)) {
|
|
58
|
+
changes.push(`Key validity: ${originalKey.notBefore} – ${originalKey.notAfter} → ${next.notBefore} – ${next.notAfter}`);
|
|
59
|
+
}
|
|
60
|
+
return changes;
|
|
61
|
+
}
|
|
62
|
+
function keyMaterialChanged(originalKey, next) {
|
|
63
|
+
return (next.publicKey.trim() !== originalKey.publicKey ||
|
|
64
|
+
next.keyType.trim() !== originalKey.keyType ||
|
|
65
|
+
next.fingerprint.trim() !== originalKey.fingerprint);
|
|
66
|
+
}
|
|
67
|
+
function validityChanged(originalKey, next) {
|
|
68
|
+
return next.notBefore !== originalKey.notBefore || next.notAfter !== originalKey.notAfter;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The `publicKey` to send with an update, or `undefined` when the admin didn't touch any key field - the form only
|
|
72
|
+
* shows dates to the minute, so re-sending a rebuilt key would silently truncate the stored validity window. A date
|
|
73
|
+
* field left as loaded keeps its original epoch-ms value. `revokedAt` isn't editable here: it's carried over when
|
|
74
|
+
* only the validity window changed, and dropped when the key material itself is replaced (a new key isn't revoked).
|
|
75
|
+
*/
|
|
76
|
+
export function buildPublicKeyUpdate(original, next) {
|
|
77
|
+
const originalKey = toFieldsValue(original);
|
|
78
|
+
const materialChanged = keyMaterialChanged(originalKey, next);
|
|
79
|
+
if (!materialChanged && !validityChanged(originalKey, next)) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const key = {
|
|
83
|
+
publicKey: next.publicKey.trim(),
|
|
84
|
+
type: next.keyType.trim(),
|
|
85
|
+
fingerprint: next.fingerprint.trim(),
|
|
86
|
+
notBefore: next.notBefore === originalKey.notBefore ? original.publicKey.notBefore : new Date(next.notBefore).getTime(),
|
|
87
|
+
notAfter: next.notAfter === originalKey.notAfter ? original.publicKey.notAfter : new Date(next.notAfter).getTime(),
|
|
88
|
+
};
|
|
89
|
+
if (!materialChanged && original.publicKey.revokedAt !== undefined) {
|
|
90
|
+
key.revokedAt = original.publicKey.revokedAt;
|
|
91
|
+
}
|
|
92
|
+
return key;
|
|
93
|
+
}
|
|
29
94
|
export default function EscrowScopeDetailPage(props) {
|
|
30
|
-
return (_jsx(AdminShell, { ...props, active: "escrowScopes", children: _jsx(EscrowScopeDetailContent, { uid: props.params.uid }) }));
|
|
95
|
+
return (_jsx(AdminShell, { ...props, active: "escrowScopes", children: _jsx(EscrowScopeDetailContent, { uid: props.params.uid, adminUid: props.userUid }) }));
|
|
31
96
|
}
|
|
32
|
-
function EscrowScopeDetailContent({ uid }) {
|
|
97
|
+
function EscrowScopeDetailContent({ uid, adminUid }) {
|
|
33
98
|
const [original, setOriginal] = useState(null);
|
|
34
99
|
const [name, setName] = useState("");
|
|
35
100
|
const [description, setDescription] = useState("");
|
|
@@ -41,6 +106,7 @@ function EscrowScopeDetailContent({ uid }) {
|
|
|
41
106
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
|
42
107
|
const [deleting, setDeleting] = useState(false);
|
|
43
108
|
const [deleteError, setDeleteError] = useState(null);
|
|
109
|
+
const [pendingChanges, setPendingChanges] = useState(null);
|
|
44
110
|
useEffect(() => {
|
|
45
111
|
setLoading(true);
|
|
46
112
|
setError(null);
|
|
@@ -79,6 +145,20 @@ function EscrowScopeDetailContent({ uid }) {
|
|
|
79
145
|
setError("Required holders must be between 1 and the number of holders.");
|
|
80
146
|
return;
|
|
81
147
|
}
|
|
148
|
+
const selfError = selfAsHolderError(fields.holderUserUids, adminUid, original.holderUserUids);
|
|
149
|
+
if (selfError) {
|
|
150
|
+
setError(selfError);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const changes = describeSecurityChanges(original, fields);
|
|
154
|
+
if (changes.length > 0) {
|
|
155
|
+
setPendingChanges(changes);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
await save();
|
|
159
|
+
}
|
|
160
|
+
async function save() {
|
|
161
|
+
setPendingChanges(null);
|
|
82
162
|
setSaving(true);
|
|
83
163
|
setSaved(false);
|
|
84
164
|
try {
|
|
@@ -86,14 +166,10 @@ function EscrowScopeDetailContent({ uid }) {
|
|
|
86
166
|
uid: original.uid,
|
|
87
167
|
version: original.version,
|
|
88
168
|
name: name.trim(),
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
fingerprint: fields.fingerprint.trim(),
|
|
94
|
-
notBefore: new Date(fields.notBefore).getTime(),
|
|
95
|
-
notAfter: new Date(fields.notAfter).getTime(),
|
|
96
|
-
},
|
|
169
|
+
// `null` (not omitted) so clearing the field actually clears the stored description - an update is
|
|
170
|
+
// a merge, and an absent field is left as it was.
|
|
171
|
+
description: (description.trim() || null),
|
|
172
|
+
publicKey: buildPublicKeyUpdate(original, fields),
|
|
97
173
|
holderUserUids: fields.holderUserUids,
|
|
98
174
|
requiredHolders: fields.requiredHolders,
|
|
99
175
|
notifySubjectOnAccess: fields.notifySubjectOnAccess,
|
|
@@ -109,7 +185,10 @@ function EscrowScopeDetailContent({ uid }) {
|
|
|
109
185
|
setSaving(false);
|
|
110
186
|
}
|
|
111
187
|
}
|
|
188
|
+
// Ignored while deleting (Escape/Close; Cancel is disabled then too).
|
|
112
189
|
function closeDeleteModal() {
|
|
190
|
+
if (deleting)
|
|
191
|
+
return;
|
|
113
192
|
setConfirmingDelete(false);
|
|
114
193
|
}
|
|
115
194
|
// Only ever invoked from the delete-confirmation modal below, which itself only renders once
|
|
@@ -137,5 +216,5 @@ function EscrowScopeDetailContent({ uid }) {
|
|
|
137
216
|
if (!original || !fields) {
|
|
138
217
|
return _jsx(Alert, { children: "Escrow scope not found." });
|
|
139
218
|
}
|
|
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" })] })] })] }));
|
|
219
|
+
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: pendingChanges !== null, onClose: () => setPendingChanges(null), title: "Confirm escrow scope changes", children: [_jsxs("p", { className: "text-sm mb-3", children: ["These changes decide who can recover escrowed mail under ", _jsx("strong", { children: original.name }), ":"] }), _jsx("ul", { className: "list-disc pl-5 text-sm flex flex-col gap-1 mb-3", children: pendingChanges?.map((change) => (_jsx("li", { className: "break-all", children: change }, change))) }), _jsx("p", { className: "text-xs text-text-muted", children: "The server refuses changes while an access request under this scope is still awaiting approval." }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setPendingChanges(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => void save(), children: "Confirm and save" })] })] }), _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
220
|
}
|
|
@@ -7,15 +7,15 @@ import { useState } from "react";
|
|
|
7
7
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
8
|
import { createEscrowScope } from "@rapidmx/react-shared/admin/escrowScopesApi.js";
|
|
9
9
|
import AdminShell from "../../../shared/components/admin/layout/AdminShell.js";
|
|
10
|
-
import EscrowScopeKeyAndHoldersFields, { emptyEscrowScopeKeyAndHoldersValue, } from "../../../shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js";
|
|
10
|
+
import EscrowScopeKeyAndHoldersFields, { emptyEscrowScopeKeyAndHoldersValue, selfAsHolderError, } from "../../../shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js";
|
|
11
11
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
12
12
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
13
13
|
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
14
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
15
|
export default function NewEscrowScopePage(props) {
|
|
16
|
-
return (_jsx(AdminShell, { ...props, active: "escrowScopes", children: _jsx(NewEscrowScopeForm, {}) }));
|
|
16
|
+
return (_jsx(AdminShell, { ...props, active: "escrowScopes", children: _jsx(NewEscrowScopeForm, { adminUid: props.userUid }) }));
|
|
17
17
|
}
|
|
18
|
-
function NewEscrowScopeForm() {
|
|
18
|
+
function NewEscrowScopeForm({ adminUid }) {
|
|
19
19
|
const [name, setName] = useState("");
|
|
20
20
|
const [description, setDescription] = useState("");
|
|
21
21
|
const [keyAndHolders, setKeyAndHolders] = useState(emptyEscrowScopeKeyAndHoldersValue());
|
|
@@ -40,6 +40,11 @@ function NewEscrowScopeForm() {
|
|
|
40
40
|
setError("Required holders must be between 1 and the number of holders.");
|
|
41
41
|
return;
|
|
42
42
|
}
|
|
43
|
+
const selfError = selfAsHolderError(keyAndHolders.holderUserUids, adminUid);
|
|
44
|
+
if (selfError) {
|
|
45
|
+
setError(selfError);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
43
48
|
setSaving(true);
|
|
44
49
|
try {
|
|
45
50
|
const created = await createEscrowScope({
|
package/dist/apps/admin/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import AdminShell from "../shared/components/admin/layout/AdminShell.js";
|
|
|
11
11
|
import MailboxTable from "../shared/components/admin/mailboxes/MailboxTable.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
|
const PAGE_SIZE = 25;
|
|
15
16
|
export default function MailboxesListPage(props) {
|
|
16
17
|
return (_jsx(AdminShell, { ...props, active: "mailboxes", children: _jsx(MailboxesListContent, {}) }));
|
|
@@ -30,6 +31,7 @@ function MailboxesListContent() {
|
|
|
30
31
|
}, [page]);
|
|
31
32
|
const hasNextPage = mailboxes.length === PAGE_SIZE;
|
|
32
33
|
const [reopening, setReopening] = useState(false);
|
|
34
|
+
const [confirmingSetup, setConfirmingSetup] = useState(false);
|
|
33
35
|
async function handleRunSetup() {
|
|
34
36
|
setReopening(true);
|
|
35
37
|
try {
|
|
@@ -39,7 +41,8 @@ function MailboxesListContent() {
|
|
|
39
41
|
catch (err) {
|
|
40
42
|
setError(err instanceof ApiRequestError ? err.message : "Could not reopen setup.");
|
|
41
43
|
setReopening(false);
|
|
44
|
+
setConfirmingSetup(false);
|
|
42
45
|
}
|
|
43
46
|
}
|
|
44
|
-
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: "Mailboxes" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto",
|
|
47
|
+
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: "Mailboxes" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: reopening, onClick: () => setConfirmingSetup(true), children: "Run setup again" }), _jsx("a", { href: "/admin/mailboxes/new", children: _jsx(Button, { type: "button", className: "!w-auto", children: "+ New mailbox" }) })] })] }), error && _jsx(Alert, { children: error }), _jsxs(Modal, { open: confirmingSetup, onClose: () => setConfirmingSetup(false), title: "Run setup again?", children: [_jsx("p", { className: "text-sm mb-4", children: "Setup starts again from its first step, and the admin console sends administrators to it until it's finished. Your existing settings, domains and mailboxes are kept." }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingSetup(false), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: reopening, disabled: reopening, onClick: () => void handleRunSetup(), children: "Run setup" })] })] }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : (_jsx(MailboxTable, { mailboxes: mailboxes })), _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" })] })] }));
|
|
45
48
|
}
|