@rapidmx/web-client 0.2.0 → 0.3.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/data-requests/index.tsx +485 -0
- package/apps/admin/escrow-scopes/[uid].tsx +237 -0
- package/apps/admin/escrow-scopes/index.tsx +124 -0
- package/apps/admin/escrow-scopes/new/index.tsx +128 -0
- package/apps/admin/mailboxes/[uid].tsx +66 -8
- package/apps/admin/retention-policy/index.tsx +132 -0
- package/apps/escrow/_layout.tsx +41 -0
- package/apps/escrow/audit-log/index.tsx +168 -0
- package/apps/escrow/index.tsx +139 -0
- package/apps/escrow/matters/[uid].tsx +521 -0
- package/apps/escrow/matters/new/index.tsx +173 -0
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +171 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +24 -0
- package/apps/shared/components/contacts/ContactDetailPane.tsx +44 -0
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +134 -0
- package/apps/shared/components/forms/StringListField.tsx +104 -0
- package/apps/shared/components/layout/AppShell.tsx +4 -0
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +265 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +8 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +214 -9
- package/apps/shared/components/mail/compose/ComposeContext.tsx +10 -1
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +271 -24
- package/apps/shared/components/mail/layout/MailShell.tsx +302 -287
- package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -0
- package/apps/www/index.tsx +122 -11
- package/apps/www/messages/[uid].tsx +15 -1
- package/apps/www/settings/encryption/index.tsx +664 -0
- package/apps/www/settings/labels/index.tsx +201 -0
- package/apps/www/settings/privacy/index.tsx +436 -0
- package/dist/apps/admin/data-requests/index.js +197 -0
- package/dist/apps/admin/escrow-scopes/[uid].js +141 -0
- package/dist/apps/admin/escrow-scopes/index.js +31 -0
- package/dist/apps/admin/escrow-scopes/new/index.js +69 -0
- package/dist/apps/admin/mailboxes/[uid].js +26 -2
- package/dist/apps/admin/retention-policy/index.js +65 -0
- package/dist/apps/escrow/_layout.js +10 -0
- package/dist/apps/escrow/audit-log/index.js +66 -0
- package/dist/apps/escrow/index.js +31 -0
- package/dist/apps/escrow/matters/[uid].js +234 -0
- package/dist/apps/escrow/matters/new/index.js +75 -0
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +26 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +19 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +10 -1
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +75 -0
- package/dist/apps/shared/components/forms/StringListField.js +39 -0
- package/dist/apps/shared/components/layout/AppShell.js +4 -0
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +142 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +2 -2
- package/dist/apps/shared/components/mail/MessageDetailPane.js +105 -6
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +184 -21
- package/dist/apps/shared/components/mail/layout/MailShell.js +11 -2
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -0
- package/dist/apps/www/index.js +110 -10
- package/dist/apps/www/messages/[uid].js +12 -2
- package/dist/apps/www/settings/encryption/index.js +330 -0
- package/dist/apps/www/settings/labels/index.js +95 -0
- package/dist/apps/www/settings/privacy/index.js +167 -0
- package/package.json +9 -3
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { FormEvent, useEffect, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
|
|
8
|
+
import {
|
|
9
|
+
deleteEscrowScope,
|
|
10
|
+
EscrowScope,
|
|
11
|
+
getEscrowScope,
|
|
12
|
+
updateEscrowScope,
|
|
13
|
+
} from "@rapidmx/react-shared/admin/escrowScopesApi.js";
|
|
14
|
+
import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
|
|
15
|
+
import EscrowScopeKeyAndHoldersFields, {
|
|
16
|
+
EscrowScopeKeyAndHoldersValue,
|
|
17
|
+
} from "../../shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js";
|
|
18
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
19
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
20
|
+
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
21
|
+
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
22
|
+
|
|
23
|
+
const INPUT_CLASS =
|
|
24
|
+
"w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
25
|
+
|
|
26
|
+
function toFieldsValue(scope: EscrowScope): EscrowScopeKeyAndHoldersValue {
|
|
27
|
+
return {
|
|
28
|
+
publicKey: scope.publicKey.publicKey,
|
|
29
|
+
keyType: scope.publicKey.type,
|
|
30
|
+
fingerprint: scope.publicKey.fingerprint,
|
|
31
|
+
notBefore: toDatetimeLocal(new Date(scope.publicKey.notBefore).toISOString()),
|
|
32
|
+
notAfter: toDatetimeLocal(new Date(scope.publicKey.notAfter).toISOString()),
|
|
33
|
+
holderUserUids: scope.holderUserUids,
|
|
34
|
+
requiredHolders: scope.requiredHolders,
|
|
35
|
+
notifySubjectOnAccess: scope.notifySubjectOnAccess,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function EscrowScopeDetailPage(props: Omit<AdminShellProps, "active"> & { params: { uid: string } }) {
|
|
40
|
+
return (
|
|
41
|
+
<AdminShell {...props} active="escrowScopes">
|
|
42
|
+
<EscrowScopeDetailContent uid={props.params.uid} />
|
|
43
|
+
</AdminShell>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function EscrowScopeDetailContent({ uid }: { uid: string }) {
|
|
48
|
+
const [original, setOriginal] = useState<EscrowScope | null>(null);
|
|
49
|
+
const [name, setName] = useState("");
|
|
50
|
+
const [description, setDescription] = useState("");
|
|
51
|
+
const [fields, setFields] = useState<EscrowScopeKeyAndHoldersValue | null>(null);
|
|
52
|
+
const [loading, setLoading] = useState(true);
|
|
53
|
+
const [error, setError] = useState<string | null>(null);
|
|
54
|
+
const [saving, setSaving] = useState(false);
|
|
55
|
+
const [saved, setSaved] = useState(false);
|
|
56
|
+
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
|
57
|
+
const [deleting, setDeleting] = useState(false);
|
|
58
|
+
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
setLoading(true);
|
|
62
|
+
setError(null);
|
|
63
|
+
getEscrowScope(uid)
|
|
64
|
+
.then((loaded) => {
|
|
65
|
+
setOriginal(loaded);
|
|
66
|
+
if (loaded) {
|
|
67
|
+
setName(loaded.name);
|
|
68
|
+
setDescription(loaded.description ?? "");
|
|
69
|
+
setFields(toFieldsValue(loaded));
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this escrow scope."))
|
|
73
|
+
.finally(() => setLoading(false));
|
|
74
|
+
}, [uid]);
|
|
75
|
+
|
|
76
|
+
// Only ever invoked from the form below, which itself only renders once `original`/`fields` are
|
|
77
|
+
// loaded (the early returns further down cover every other state) — the non-null assertions reflect
|
|
78
|
+
// that real invariant, not an unchecked assumption. Matches `TransportRuleDetailContent.handleSubmit`'s
|
|
79
|
+
// identical pattern.
|
|
80
|
+
async function handleSubmit(e: FormEvent) {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
setError(null);
|
|
83
|
+
|
|
84
|
+
if (!name.trim()) {
|
|
85
|
+
setError("A name is required.");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!fields!.publicKey.trim() || !fields!.keyType.trim() || !fields!.fingerprint.trim()) {
|
|
89
|
+
setError("The public key, its type, and its fingerprint are all required.");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (fields!.holderUserUids.length === 0) {
|
|
93
|
+
setError("At least one holder is required.");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (fields!.requiredHolders < 1 || fields!.requiredHolders > fields!.holderUserUids.length) {
|
|
97
|
+
setError("Required holders must be between 1 and the number of holders.");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
setSaving(true);
|
|
102
|
+
setSaved(false);
|
|
103
|
+
try {
|
|
104
|
+
const updated = await updateEscrowScope({
|
|
105
|
+
uid: original!.uid,
|
|
106
|
+
version: original!.version,
|
|
107
|
+
name: name.trim(),
|
|
108
|
+
description: description.trim() || undefined,
|
|
109
|
+
publicKey: {
|
|
110
|
+
publicKey: fields!.publicKey.trim(),
|
|
111
|
+
type: fields!.keyType.trim(),
|
|
112
|
+
fingerprint: fields!.fingerprint.trim(),
|
|
113
|
+
notBefore: new Date(fields!.notBefore).getTime(),
|
|
114
|
+
notAfter: new Date(fields!.notAfter).getTime(),
|
|
115
|
+
},
|
|
116
|
+
holderUserUids: fields!.holderUserUids,
|
|
117
|
+
requiredHolders: fields!.requiredHolders,
|
|
118
|
+
notifySubjectOnAccess: fields!.notifySubjectOnAccess,
|
|
119
|
+
});
|
|
120
|
+
setOriginal(updated);
|
|
121
|
+
setFields(toFieldsValue(updated));
|
|
122
|
+
setSaved(true);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not save this escrow scope.");
|
|
125
|
+
} finally {
|
|
126
|
+
setSaving(false);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function closeDeleteModal() {
|
|
131
|
+
setConfirmingDelete(false);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Only ever invoked from the delete-confirmation modal below, which itself only renders once
|
|
135
|
+
// `original` is resolved — same non-null-assertion precedent as `handleSubmit` above.
|
|
136
|
+
async function handleDelete() {
|
|
137
|
+
setDeleting(true);
|
|
138
|
+
setDeleteError(null);
|
|
139
|
+
try {
|
|
140
|
+
await deleteEscrowScope(original!.uid, original!.version);
|
|
141
|
+
window.location.href = "/admin/escrow-scopes";
|
|
142
|
+
} catch (err) {
|
|
143
|
+
// Most commonly a 409 ("referenced by an existing Matter") — see `deleteEscrowScope()`'s own
|
|
144
|
+
// doc comment - surfaced as-is rather than special-cased.
|
|
145
|
+
setDeleteError(err instanceof ApiRequestError ? err.message : "Could not delete this escrow scope.");
|
|
146
|
+
setDeleting(false);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (loading) {
|
|
151
|
+
return <p className="text-sm text-text-muted">Loading…</p>;
|
|
152
|
+
}
|
|
153
|
+
if (error && !original) {
|
|
154
|
+
return <Alert>{error}</Alert>;
|
|
155
|
+
}
|
|
156
|
+
if (!original || !fields) {
|
|
157
|
+
return <Alert>Escrow scope not found.</Alert>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div className="max-w-3xl">
|
|
162
|
+
<div className="flex items-start justify-between gap-4 mb-1">
|
|
163
|
+
<div>
|
|
164
|
+
<a href="/admin/escrow-scopes" className="text-sm text-primary-dark hover:underline">
|
|
165
|
+
← All escrow scopes
|
|
166
|
+
</a>
|
|
167
|
+
<h1 className="text-xl font-bold tracking-tight mt-1">{original.name}</h1>
|
|
168
|
+
</div>
|
|
169
|
+
<Button
|
|
170
|
+
type="button"
|
|
171
|
+
variant="secondary"
|
|
172
|
+
className="!w-auto shrink-0 !border-danger !text-danger hover:!border-danger hover:!text-danger"
|
|
173
|
+
onClick={() => setConfirmingDelete(true)}
|
|
174
|
+
>
|
|
175
|
+
Delete scope
|
|
176
|
+
</Button>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{error && <Alert>{error}</Alert>}
|
|
180
|
+
{saved && !error && <div className="mb-4 text-sm text-success font-medium">Saved.</div>}
|
|
181
|
+
|
|
182
|
+
<form onSubmit={handleSubmit} className="flex flex-col gap-5 mt-4">
|
|
183
|
+
<div className="bg-surface border border-border rounded-md p-6">
|
|
184
|
+
<FormField label="Name" htmlFor="name">
|
|
185
|
+
<input
|
|
186
|
+
id="name"
|
|
187
|
+
type="text"
|
|
188
|
+
className={INPUT_CLASS}
|
|
189
|
+
value={name}
|
|
190
|
+
onChange={(e) => setName(e.target.value)}
|
|
191
|
+
/>
|
|
192
|
+
</FormField>
|
|
193
|
+
|
|
194
|
+
<FormField label="Description (optional)" htmlFor="description">
|
|
195
|
+
<input
|
|
196
|
+
id="description"
|
|
197
|
+
type="text"
|
|
198
|
+
className={INPUT_CLASS}
|
|
199
|
+
value={description}
|
|
200
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
201
|
+
/>
|
|
202
|
+
</FormField>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<EscrowScopeKeyAndHoldersFields value={fields} onChange={setFields} />
|
|
206
|
+
|
|
207
|
+
<div>
|
|
208
|
+
<Button type="submit" loading={saving} disabled={saving} className="!w-auto">
|
|
209
|
+
Save changes
|
|
210
|
+
</Button>
|
|
211
|
+
</div>
|
|
212
|
+
</form>
|
|
213
|
+
|
|
214
|
+
<Modal open={confirmingDelete} onClose={closeDeleteModal} title="Delete escrow scope">
|
|
215
|
+
<p className="text-sm mb-5">
|
|
216
|
+
Are you sure you want to delete <strong>{original.name}</strong>? This cannot be undone, and fails
|
|
217
|
+
if any Matter still references this scope.
|
|
218
|
+
</p>
|
|
219
|
+
{deleteError && <Alert>{deleteError}</Alert>}
|
|
220
|
+
<div className="flex gap-3 justify-end mt-5">
|
|
221
|
+
<Button type="button" variant="secondary" className="!w-auto" disabled={deleting} onClick={closeDeleteModal}>
|
|
222
|
+
Cancel
|
|
223
|
+
</Button>
|
|
224
|
+
<Button
|
|
225
|
+
type="button"
|
|
226
|
+
className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger"
|
|
227
|
+
loading={deleting}
|
|
228
|
+
disabled={deleting}
|
|
229
|
+
onClick={handleDelete}
|
|
230
|
+
>
|
|
231
|
+
Delete
|
|
232
|
+
</Button>
|
|
233
|
+
</div>
|
|
234
|
+
</Modal>
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useEffect, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import { EscrowScope, listEscrowScopes } from "@rapidmx/react-shared/admin/escrowScopesApi.js";
|
|
8
|
+
import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
|
|
9
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
10
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
11
|
+
|
|
12
|
+
const PAGE_SIZE = 25;
|
|
13
|
+
|
|
14
|
+
export default function EscrowScopesPage(props: Omit<AdminShellProps, "active">) {
|
|
15
|
+
return (
|
|
16
|
+
<AdminShell {...props} active="escrowScopes">
|
|
17
|
+
<EscrowScopesContent />
|
|
18
|
+
</AdminShell>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function EscrowScopesContent() {
|
|
23
|
+
const [page, setPage] = useState(0);
|
|
24
|
+
const [scopes, setScopes] = useState<EscrowScope[]>([]);
|
|
25
|
+
const [loading, setLoading] = useState(true);
|
|
26
|
+
const [error, setError] = useState<string | null>(null);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
setLoading(true);
|
|
30
|
+
setError(null);
|
|
31
|
+
listEscrowScopes({ page, limit: PAGE_SIZE })
|
|
32
|
+
.then(setScopes)
|
|
33
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load escrow scopes."))
|
|
34
|
+
.finally(() => setLoading(false));
|
|
35
|
+
}, [page]);
|
|
36
|
+
|
|
37
|
+
const hasNextPage = scopes.length === PAGE_SIZE;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<div className="flex items-center justify-between mb-5">
|
|
42
|
+
<h1 className="text-xl font-bold uppercase tracking-wide">Escrow scopes</h1>
|
|
43
|
+
<a href="/admin/escrow-scopes/new">
|
|
44
|
+
<Button type="button" className="!w-auto">
|
|
45
|
+
+ New escrow scope
|
|
46
|
+
</Button>
|
|
47
|
+
</a>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<p className="text-sm text-text-muted mb-5">
|
|
51
|
+
Configuring who counts as a holder, the dual-control threshold, and a scope's own public key is an
|
|
52
|
+
administrative act, separate from actually holding the eDiscovery/compliance role — this page never
|
|
53
|
+
grants you access to any mailbox's escrowed key material.
|
|
54
|
+
</p>
|
|
55
|
+
|
|
56
|
+
{error && <Alert>{error}</Alert>}
|
|
57
|
+
|
|
58
|
+
{loading ? (
|
|
59
|
+
<p className="text-sm text-text-muted">Loading…</p>
|
|
60
|
+
) : scopes.length === 0 ? (
|
|
61
|
+
<p className="text-sm text-text-muted">No escrow scopes yet.</p>
|
|
62
|
+
) : (
|
|
63
|
+
<div className="overflow-x-auto">
|
|
64
|
+
<table className="w-full text-sm border-collapse">
|
|
65
|
+
<thead>
|
|
66
|
+
<tr>
|
|
67
|
+
{["Name", "Holders", "Required holders", "Notify subject", ""].map((h) => (
|
|
68
|
+
<th
|
|
69
|
+
key={h}
|
|
70
|
+
className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
|
|
71
|
+
>
|
|
72
|
+
{h}
|
|
73
|
+
</th>
|
|
74
|
+
))}
|
|
75
|
+
</tr>
|
|
76
|
+
</thead>
|
|
77
|
+
<tbody>
|
|
78
|
+
{scopes.map((scope) => (
|
|
79
|
+
<tr key={scope.uid}>
|
|
80
|
+
<td className="py-2.5 px-2.5 border-b border-border">{scope.name}</td>
|
|
81
|
+
<td className="py-2.5 px-2.5 border-b border-border">{scope.holderUserUids.length}</td>
|
|
82
|
+
<td className="py-2.5 px-2.5 border-b border-border">{scope.requiredHolders}</td>
|
|
83
|
+
<td className="py-2.5 px-2.5 border-b border-border">
|
|
84
|
+
{scope.notifySubjectOnAccess ? "Yes" : "No"}
|
|
85
|
+
</td>
|
|
86
|
+
<td className="py-2.5 px-2.5 border-b border-border text-right">
|
|
87
|
+
<a
|
|
88
|
+
href={`/admin/escrow-scopes/${encodeURIComponent(scope.uid)}`}
|
|
89
|
+
className="text-primary-dark hover:underline font-medium"
|
|
90
|
+
>
|
|
91
|
+
View
|
|
92
|
+
</a>
|
|
93
|
+
</td>
|
|
94
|
+
</tr>
|
|
95
|
+
))}
|
|
96
|
+
</tbody>
|
|
97
|
+
</table>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
|
|
101
|
+
<div className="flex gap-3 items-center mt-4">
|
|
102
|
+
<Button
|
|
103
|
+
variant="secondary"
|
|
104
|
+
type="button"
|
|
105
|
+
className="!w-auto"
|
|
106
|
+
disabled={page === 0 || loading}
|
|
107
|
+
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
108
|
+
>
|
|
109
|
+
Previous
|
|
110
|
+
</Button>
|
|
111
|
+
<span className="text-sm text-text-muted">Page {page + 1}</span>
|
|
112
|
+
<Button
|
|
113
|
+
variant="secondary"
|
|
114
|
+
type="button"
|
|
115
|
+
className="!w-auto"
|
|
116
|
+
disabled={!hasNextPage || loading}
|
|
117
|
+
onClick={() => setPage((p) => p + 1)}
|
|
118
|
+
>
|
|
119
|
+
Next
|
|
120
|
+
</Button>
|
|
121
|
+
</div>
|
|
122
|
+
</>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { FormEvent, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import { createEscrowScope } from "@rapidmx/react-shared/admin/escrowScopesApi.js";
|
|
8
|
+
import AdminShell, { AdminShellProps } from "../../../shared/components/admin/layout/AdminShell.js";
|
|
9
|
+
import EscrowScopeKeyAndHoldersFields, {
|
|
10
|
+
emptyEscrowScopeKeyAndHoldersValue,
|
|
11
|
+
} 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
|
+
|
|
16
|
+
const INPUT_CLASS =
|
|
17
|
+
"w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
18
|
+
|
|
19
|
+
export default function NewEscrowScopePage(props: Omit<AdminShellProps, "active">) {
|
|
20
|
+
return (
|
|
21
|
+
<AdminShell {...props} active="escrowScopes">
|
|
22
|
+
<NewEscrowScopeForm />
|
|
23
|
+
</AdminShell>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function NewEscrowScopeForm() {
|
|
28
|
+
const [name, setName] = useState("");
|
|
29
|
+
const [description, setDescription] = useState("");
|
|
30
|
+
const [keyAndHolders, setKeyAndHolders] = useState(emptyEscrowScopeKeyAndHoldersValue());
|
|
31
|
+
const [error, setError] = useState<string | null>(null);
|
|
32
|
+
const [saving, setSaving] = useState(false);
|
|
33
|
+
|
|
34
|
+
async function handleSubmit(e: FormEvent) {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
setError(null);
|
|
37
|
+
|
|
38
|
+
if (!name.trim()) {
|
|
39
|
+
setError("A name is required.");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (!keyAndHolders.publicKey.trim() || !keyAndHolders.keyType.trim() || !keyAndHolders.fingerprint.trim()) {
|
|
43
|
+
setError("The public key, its type, and its fingerprint are all required.");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (keyAndHolders.holderUserUids.length === 0) {
|
|
47
|
+
setError("At least one holder is required.");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (keyAndHolders.requiredHolders < 1 || keyAndHolders.requiredHolders > keyAndHolders.holderUserUids.length) {
|
|
51
|
+
setError("Required holders must be between 1 and the number of holders.");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
setSaving(true);
|
|
56
|
+
try {
|
|
57
|
+
const created = await createEscrowScope({
|
|
58
|
+
name: name.trim(),
|
|
59
|
+
description: description.trim() || undefined,
|
|
60
|
+
publicKey: {
|
|
61
|
+
publicKey: keyAndHolders.publicKey.trim(),
|
|
62
|
+
type: keyAndHolders.keyType.trim(),
|
|
63
|
+
fingerprint: keyAndHolders.fingerprint.trim(),
|
|
64
|
+
notBefore: new Date(keyAndHolders.notBefore).getTime(),
|
|
65
|
+
notAfter: new Date(keyAndHolders.notAfter).getTime(),
|
|
66
|
+
},
|
|
67
|
+
holderUserUids: keyAndHolders.holderUserUids,
|
|
68
|
+
requiredHolders: keyAndHolders.requiredHolders,
|
|
69
|
+
notifySubjectOnAccess: keyAndHolders.notifySubjectOnAccess,
|
|
70
|
+
});
|
|
71
|
+
window.location.href = `/admin/escrow-scopes/${encodeURIComponent(created.uid)}`;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not create the escrow scope.");
|
|
74
|
+
} finally {
|
|
75
|
+
setSaving(false);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="max-w-3xl">
|
|
81
|
+
<h1 className="text-xl font-bold uppercase tracking-wide mb-1">New escrow scope</h1>
|
|
82
|
+
<p className="text-sm text-text-muted mb-5">
|
|
83
|
+
Configuring who counts as a holder is an administrative act, deliberately separate from actually
|
|
84
|
+
holding the eDiscovery/compliance role.
|
|
85
|
+
</p>
|
|
86
|
+
|
|
87
|
+
{error && <Alert>{error}</Alert>}
|
|
88
|
+
|
|
89
|
+
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
|
90
|
+
<div className="bg-surface border border-border rounded-md p-6">
|
|
91
|
+
<FormField label="Name" htmlFor="name">
|
|
92
|
+
<input
|
|
93
|
+
id="name"
|
|
94
|
+
type="text"
|
|
95
|
+
className={INPUT_CLASS}
|
|
96
|
+
value={name}
|
|
97
|
+
onChange={(e) => setName(e.target.value)}
|
|
98
|
+
placeholder="Legal Hold Q1"
|
|
99
|
+
/>
|
|
100
|
+
</FormField>
|
|
101
|
+
|
|
102
|
+
<FormField label="Description (optional)" htmlFor="description">
|
|
103
|
+
<input
|
|
104
|
+
id="description"
|
|
105
|
+
type="text"
|
|
106
|
+
className={INPUT_CLASS}
|
|
107
|
+
value={description}
|
|
108
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
109
|
+
/>
|
|
110
|
+
</FormField>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<EscrowScopeKeyAndHoldersFields value={keyAndHolders} onChange={setKeyAndHolders} />
|
|
114
|
+
|
|
115
|
+
<div className="flex gap-3">
|
|
116
|
+
<Button type="submit" loading={saving} disabled={saving} className="!w-auto">
|
|
117
|
+
Create escrow scope
|
|
118
|
+
</Button>
|
|
119
|
+
<a href="/admin/escrow-scopes">
|
|
120
|
+
<Button type="button" variant="secondary" className="!w-auto">
|
|
121
|
+
Cancel
|
|
122
|
+
</Button>
|
|
123
|
+
</a>
|
|
124
|
+
</div>
|
|
125
|
+
</form>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import React, { useEffect, useState } from "react";
|
|
6
6
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
-
import { getMailbox, impersonateUser, Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
7
|
+
import { deleteMailbox, getMailbox, impersonateUser, Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
8
8
|
import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
|
|
9
9
|
import ShareAccessCard from "../../shared/components/admin/mailboxes/ShareAccessCard.js";
|
|
10
10
|
import ResourceSettingsCard from "../../shared/components/admin/mailboxes/ResourceSettingsCard.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
|
+
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
13
14
|
|
|
14
15
|
function formatBytes(bytes: number): string {
|
|
15
16
|
if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(2)} GB`;
|
|
@@ -31,6 +32,9 @@ function MailboxDetailContent({ uid, impersonationBaseUrl }: { uid: string } & P
|
|
|
31
32
|
const [loading, setLoading] = useState(true);
|
|
32
33
|
const [error, setError] = useState<string | null>(null);
|
|
33
34
|
const [impersonating, setImpersonating] = useState(false);
|
|
35
|
+
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
|
36
|
+
const [deleting, setDeleting] = useState(false);
|
|
37
|
+
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
34
38
|
|
|
35
39
|
async function handleAccessMailbox(ownerUserUid: string) {
|
|
36
40
|
setImpersonating(true);
|
|
@@ -43,6 +47,27 @@ function MailboxDetailContent({ uid, impersonationBaseUrl }: { uid: string } & P
|
|
|
43
47
|
}
|
|
44
48
|
}
|
|
45
49
|
|
|
50
|
+
function closeDeleteModal() {
|
|
51
|
+
setConfirmingDelete(false);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Only ever invoked from the delete-confirmation modal below, which itself only renders once
|
|
55
|
+
// `mailbox` is resolved (the `error || !mailbox` branch below returns before this content mounts).
|
|
56
|
+
async function handleDelete() {
|
|
57
|
+
setDeleting(true);
|
|
58
|
+
setDeleteError(null);
|
|
59
|
+
try {
|
|
60
|
+
await deleteMailbox(mailbox!.uid, mailbox!.version);
|
|
61
|
+
window.location.href = "/admin";
|
|
62
|
+
} catch (err) {
|
|
63
|
+
// Most commonly a 409 if this mailbox is a custodian on an open legal hold (restapi's own
|
|
64
|
+
// `assertNotOnLegalHold()`, naming the blocking Matter uid(s)) - surfaced as-is, same as
|
|
65
|
+
// every other destructive-action error in this codebase, rather than special-cased here.
|
|
66
|
+
setDeleteError(err instanceof ApiRequestError ? err.message : "Could not delete this mailbox.");
|
|
67
|
+
setDeleting(false);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
46
71
|
useEffect(() => {
|
|
47
72
|
setLoading(true);
|
|
48
73
|
setError(null);
|
|
@@ -71,18 +96,28 @@ function MailboxDetailContent({ uid, impersonationBaseUrl }: { uid: string } & P
|
|
|
71
96
|
</a>
|
|
72
97
|
<h1 className="text-xl font-bold tracking-tight mt-1">{mailbox.primarySmtpAddress}</h1>
|
|
73
98
|
</div>
|
|
74
|
-
|
|
99
|
+
<div className="flex gap-3 shrink-0">
|
|
100
|
+
{mailbox.ownerUserUid && (
|
|
101
|
+
<Button
|
|
102
|
+
type="button"
|
|
103
|
+
variant="secondary"
|
|
104
|
+
className="!w-auto"
|
|
105
|
+
loading={impersonating}
|
|
106
|
+
disabled={impersonating}
|
|
107
|
+
onClick={() => handleAccessMailbox(mailbox.ownerUserUid as string)}
|
|
108
|
+
>
|
|
109
|
+
Access this mailbox
|
|
110
|
+
</Button>
|
|
111
|
+
)}
|
|
75
112
|
<Button
|
|
76
113
|
type="button"
|
|
77
114
|
variant="secondary"
|
|
78
|
-
className="!w-auto
|
|
79
|
-
|
|
80
|
-
disabled={impersonating}
|
|
81
|
-
onClick={() => handleAccessMailbox(mailbox.ownerUserUid as string)}
|
|
115
|
+
className="!w-auto !border-danger !text-danger hover:!border-danger hover:!text-danger"
|
|
116
|
+
onClick={() => setConfirmingDelete(true)}
|
|
82
117
|
>
|
|
83
|
-
|
|
118
|
+
Delete mailbox
|
|
84
119
|
</Button>
|
|
85
|
-
|
|
120
|
+
</div>
|
|
86
121
|
</div>
|
|
87
122
|
|
|
88
123
|
<div className="bg-surface border border-border rounded-md p-6">
|
|
@@ -121,6 +156,29 @@ function MailboxDetailContent({ uid, impersonationBaseUrl }: { uid: string } & P
|
|
|
121
156
|
<ShareAccessCard mailboxUid={mailbox.uid} />
|
|
122
157
|
|
|
123
158
|
{mailbox.isResource && <ResourceSettingsCard mailbox={mailbox} onUpdate={setMailbox} />}
|
|
159
|
+
|
|
160
|
+
<Modal open={confirmingDelete} onClose={closeDeleteModal} title="Delete mailbox">
|
|
161
|
+
<p className="text-sm mb-5">
|
|
162
|
+
Are you sure you want to delete <strong>{mailbox.primarySmtpAddress}</strong>? This permanently
|
|
163
|
+
deletes the mailbox and everything in it, and cannot be undone. It fails if this mailbox is a
|
|
164
|
+
custodian on an active legal hold.
|
|
165
|
+
</p>
|
|
166
|
+
{deleteError && <Alert>{deleteError}</Alert>}
|
|
167
|
+
<div className="flex gap-3 justify-end mt-5">
|
|
168
|
+
<Button type="button" variant="secondary" className="!w-auto" disabled={deleting} onClick={closeDeleteModal}>
|
|
169
|
+
Cancel
|
|
170
|
+
</Button>
|
|
171
|
+
<Button
|
|
172
|
+
type="button"
|
|
173
|
+
className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger"
|
|
174
|
+
loading={deleting}
|
|
175
|
+
disabled={deleting}
|
|
176
|
+
onClick={handleDelete}
|
|
177
|
+
>
|
|
178
|
+
Delete
|
|
179
|
+
</Button>
|
|
180
|
+
</div>
|
|
181
|
+
</Modal>
|
|
124
182
|
</div>
|
|
125
183
|
);
|
|
126
184
|
}
|