@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.
Files changed (59) hide show
  1. package/apps/admin/data-requests/index.tsx +485 -0
  2. package/apps/admin/escrow-scopes/[uid].tsx +237 -0
  3. package/apps/admin/escrow-scopes/index.tsx +124 -0
  4. package/apps/admin/escrow-scopes/new/index.tsx +128 -0
  5. package/apps/admin/mailboxes/[uid].tsx +66 -8
  6. package/apps/admin/retention-policy/index.tsx +132 -0
  7. package/apps/escrow/_layout.tsx +41 -0
  8. package/apps/escrow/audit-log/index.tsx +168 -0
  9. package/apps/escrow/index.tsx +139 -0
  10. package/apps/escrow/matters/[uid].tsx +521 -0
  11. package/apps/escrow/matters/new/index.tsx +173 -0
  12. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +171 -0
  13. package/apps/shared/components/admin/layout/AdminShell.tsx +24 -0
  14. package/apps/shared/components/contacts/ContactDetailPane.tsx +44 -0
  15. package/apps/shared/components/escrow/layout/EscrowShell.tsx +134 -0
  16. package/apps/shared/components/forms/StringListField.tsx +104 -0
  17. package/apps/shared/components/layout/AppShell.tsx +4 -0
  18. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +265 -0
  19. package/apps/shared/components/mail/ConversationThreadPane.tsx +8 -1
  20. package/apps/shared/components/mail/MessageDetailPane.tsx +214 -9
  21. package/apps/shared/components/mail/compose/ComposeContext.tsx +10 -1
  22. package/apps/shared/components/mail/compose/ComposeWindow.tsx +271 -24
  23. package/apps/shared/components/mail/layout/MailShell.tsx +302 -287
  24. package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -0
  25. package/apps/www/index.tsx +122 -11
  26. package/apps/www/messages/[uid].tsx +15 -1
  27. package/apps/www/settings/encryption/index.tsx +664 -0
  28. package/apps/www/settings/labels/index.tsx +201 -0
  29. package/apps/www/settings/privacy/index.tsx +436 -0
  30. package/dist/apps/admin/data-requests/index.js +197 -0
  31. package/dist/apps/admin/escrow-scopes/[uid].js +141 -0
  32. package/dist/apps/admin/escrow-scopes/index.js +31 -0
  33. package/dist/apps/admin/escrow-scopes/new/index.js +69 -0
  34. package/dist/apps/admin/mailboxes/[uid].js +26 -2
  35. package/dist/apps/admin/retention-policy/index.js +65 -0
  36. package/dist/apps/escrow/_layout.js +10 -0
  37. package/dist/apps/escrow/audit-log/index.js +66 -0
  38. package/dist/apps/escrow/index.js +31 -0
  39. package/dist/apps/escrow/matters/[uid].js +234 -0
  40. package/dist/apps/escrow/matters/new/index.js +75 -0
  41. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +26 -0
  42. package/dist/apps/shared/components/admin/layout/AdminShell.js +19 -1
  43. package/dist/apps/shared/components/contacts/ContactDetailPane.js +10 -1
  44. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +75 -0
  45. package/dist/apps/shared/components/forms/StringListField.js +39 -0
  46. package/dist/apps/shared/components/layout/AppShell.js +4 -0
  47. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +142 -0
  48. package/dist/apps/shared/components/mail/ConversationThreadPane.js +2 -2
  49. package/dist/apps/shared/components/mail/MessageDetailPane.js +105 -6
  50. package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -1
  51. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +184 -21
  52. package/dist/apps/shared/components/mail/layout/MailShell.js +11 -2
  53. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -0
  54. package/dist/apps/www/index.js +110 -10
  55. package/dist/apps/www/messages/[uid].js +12 -2
  56. package/dist/apps/www/settings/encryption/index.js +330 -0
  57. package/dist/apps/www/settings/labels/index.js +95 -0
  58. package/dist/apps/www/settings/privacy/index.js +167 -0
  59. package/package.json +9 -3
@@ -0,0 +1,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&hellip;</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
+ &larr; 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&hellip;</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
- {mailbox.ownerUserUid && (
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 shrink-0"
79
- loading={impersonating}
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
- Access this mailbox
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
  }