@rapidmx/web-client 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,330 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
+
import { addMasterKeyWrap, checkSignEnrollmentStatus, findActivePublicKey, getEscrowInfo, getKeyVault, rekey, removeMasterKeyWrap, startSignEnrollment, } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
9
|
+
import { SIGNING_PRIVATE_KEY_AAD_PURPOSE, destroyUnlockedKeys, getUnlockedKeys, unlockWithPassword, } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
10
|
+
import { IDLE_TIMEOUT_OPTIONS_MINUTES, getIdleTimeoutMinutes, setIdleTimeoutMinutes } from "@rapidmx/react-shared/crypto/idleTimeout.js";
|
|
11
|
+
import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
|
|
12
|
+
import { buildAad, sealWithKey } from "@rapidmx/react-shared/crypto/masterKey.js";
|
|
13
|
+
import { buildEscrowWrap, buildPasswordWrap, buildRecoveryWraps } from "@rapidmx/react-shared/crypto/masterKeyWraps.js";
|
|
14
|
+
import { rewrapPrivateKeysUnderNewMasterKey } from "@rapidmx/react-shared/crypto/keyRotation.js";
|
|
15
|
+
import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "@rapidmx/react-shared/crypto/keys.js";
|
|
16
|
+
import { getMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
17
|
+
import SettingsShell, { useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
|
|
18
|
+
import KeyEnrollmentGate from "../../../shared/components/layout/KeyEnrollmentGate.js";
|
|
19
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
20
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
21
|
+
const MIN_PASSWORD_LENGTH = 8;
|
|
22
|
+
// RFC 8823 ACME issuance is a real email round-trip with a public CA - "likely minutes," not seconds -
|
|
23
|
+
// so this polls infrequently rather than hammering the endpoint.
|
|
24
|
+
const SIGNING_ENROLLMENT_POLL_INTERVAL_MS = 15000;
|
|
25
|
+
const METHOD_LABELS = {
|
|
26
|
+
password: "Password",
|
|
27
|
+
passkey: "Passkey",
|
|
28
|
+
recovery: "Recovery code",
|
|
29
|
+
escrow: "Escrow (managed by your organization)",
|
|
30
|
+
};
|
|
31
|
+
function idleTimeoutLabel(minutes) {
|
|
32
|
+
if (minutes === 0) {
|
|
33
|
+
return "Never";
|
|
34
|
+
}
|
|
35
|
+
if (minutes === 60) {
|
|
36
|
+
return "1 hour";
|
|
37
|
+
}
|
|
38
|
+
return `${minutes} minutes`;
|
|
39
|
+
}
|
|
40
|
+
export default function SettingsEncryptionPage(props) {
|
|
41
|
+
return (_jsx(SettingsShell, { ...props, active: "encryption", children: _jsx(EncryptionGate, {}) }));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Unlike `MailShell`, `SettingsShell` doesn't already gate its children behind `KeyEnrollmentGate` (a
|
|
45
|
+
* user can reach Settings without ever having opened Mail this session) - wrapped here instead, so
|
|
46
|
+
* `EncryptionContent` below can always assume `getUnlockedKeys()` has something for this mailbox before
|
|
47
|
+
* it renders. See `KeyEnrollmentGate`'s own doc comment for why it's safe to mount more than once (it
|
|
48
|
+
* short-circuits to `children` immediately for a mailbox already unlocked elsewhere this session).
|
|
49
|
+
*/
|
|
50
|
+
function EncryptionGate() {
|
|
51
|
+
const { mailboxUid, mailboxes } = useSettingsShell();
|
|
52
|
+
const mailbox = mailboxes.find((mb) => mb.uid === mailboxUid);
|
|
53
|
+
return (_jsx(KeyEnrollmentGate, { mailboxUid: mailboxUid, mailboxAddress: mailbox.primarySmtpAddress, mailboxKeys: mailbox.keys, children: _jsx(EncryptionContent, {}) }));
|
|
54
|
+
}
|
|
55
|
+
function EncryptionContent() {
|
|
56
|
+
const { mailboxUid, mailboxes } = useSettingsShell();
|
|
57
|
+
const mailbox = mailboxes.find((mb) => mb.uid === mailboxUid);
|
|
58
|
+
const [vault, setVault] = useState(null);
|
|
59
|
+
const [loadError, setLoadError] = useState(null);
|
|
60
|
+
const [removingMethod, setRemovingMethod] = useState(null);
|
|
61
|
+
const [actionError, setActionError] = useState(null);
|
|
62
|
+
const [newPassword, setNewPassword] = useState("");
|
|
63
|
+
const [confirmNewPassword, setConfirmNewPassword] = useState("");
|
|
64
|
+
const [addingPassword, setAddingPassword] = useState(false);
|
|
65
|
+
const [regenerating, setRegenerating] = useState(false);
|
|
66
|
+
const [newRecoveryCodes, setNewRecoveryCodes] = useState(null);
|
|
67
|
+
// Distinguishes the "Save your new recovery codes" screen's copy for the two different actions that
|
|
68
|
+
// land on it - a plain regeneration only invalidates old recovery codes, while a rotation also
|
|
69
|
+
// invalidates every other unlock method, which the copy needs to say plainly.
|
|
70
|
+
const [recoveryCodesReason, setRecoveryCodesReason] = useState("regenerate");
|
|
71
|
+
const [codesSaved, setCodesSaved] = useState(false);
|
|
72
|
+
const [rotationPassword, setRotationPassword] = useState("");
|
|
73
|
+
const [rotationConfirmPassword, setRotationConfirmPassword] = useState("");
|
|
74
|
+
const [rotating, setRotating] = useState(false);
|
|
75
|
+
const [destroyed, setDestroyed] = useState(false);
|
|
76
|
+
const [idleTimeoutMinutes, setIdleTimeoutMinutesState] = useState(() => getIdleTimeoutMinutes());
|
|
77
|
+
// `mailbox.keys` comes from `SettingsShell`'s one-time `listMailboxes()` fetch - once an ACME
|
|
78
|
+
// enrollment issues, the server has installed a new signing key that fetch never saw. Only this
|
|
79
|
+
// page refetches (via `getMailbox()`) to notice; `null` means "no fresher data yet, use mailbox.keys".
|
|
80
|
+
const [refreshedKeys, setRefreshedKeys] = useState(null);
|
|
81
|
+
const displayedKeys = refreshedKeys ?? mailbox.keys ?? [];
|
|
82
|
+
const activeSigningKey = findActivePublicKey(displayedKeys, "sign");
|
|
83
|
+
const [signingStatus, setSigningStatus] = useState("idle");
|
|
84
|
+
const [signingEnrollmentId, setSigningEnrollmentId] = useState(null);
|
|
85
|
+
const [signingError, setSigningError] = useState(null);
|
|
86
|
+
const hasEscrowWrap = vault?.masterKeyWraps.some((w) => w.method === "escrow") ?? false;
|
|
87
|
+
const [wrappingEscrow, setWrappingEscrow] = useState(false);
|
|
88
|
+
const [escrowError, setEscrowError] = useState(null);
|
|
89
|
+
async function handleWrapEscrow() {
|
|
90
|
+
setEscrowError(null);
|
|
91
|
+
setWrappingEscrow(true);
|
|
92
|
+
try {
|
|
93
|
+
// Only reachable when mailbox.escrowScopeId is set (see the render guard below) and
|
|
94
|
+
// `unlocked` is defined - see `handleAddPassword`'s identical note on the latter.
|
|
95
|
+
const escrowInfo = await getEscrowInfo(mailboxUid);
|
|
96
|
+
const wrap = await buildEscrowWrap(unlocked.masterKey, escrowInfo.escrowScopeId, fromBase64(escrowInfo.publicKey.publicKey));
|
|
97
|
+
await addMasterKeyWrap(mailboxUid, wrap);
|
|
98
|
+
await loadVault();
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
setEscrowError(err instanceof ApiRequestError ? err.message : "Could not add escrow protection for this mailbox.");
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
setWrappingEscrow(false);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (signingStatus !== "pending" || !signingEnrollmentId) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
let cancelled = false;
|
|
112
|
+
const interval = setInterval(async () => {
|
|
113
|
+
try {
|
|
114
|
+
const result = await checkSignEnrollmentStatus(mailboxUid, signingEnrollmentId);
|
|
115
|
+
if (cancelled) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (result.status === "issued") {
|
|
119
|
+
setSigningStatus("idle");
|
|
120
|
+
setSigningEnrollmentId(null);
|
|
121
|
+
const refreshed = await getMailbox(mailboxUid);
|
|
122
|
+
if (!cancelled) {
|
|
123
|
+
setRefreshedKeys(refreshed.keys ?? []);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else if (result.status === "failed") {
|
|
127
|
+
setSigningStatus("idle");
|
|
128
|
+
setSigningEnrollmentId(null);
|
|
129
|
+
setSigningError(result.error ?? "Signing certificate enrollment failed.");
|
|
130
|
+
}
|
|
131
|
+
// "pending" leaves state as-is - the interval below just tries again.
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Transient network error - keep polling rather than surfacing a one-off failure.
|
|
135
|
+
}
|
|
136
|
+
}, SIGNING_ENROLLMENT_POLL_INTERVAL_MS);
|
|
137
|
+
return () => {
|
|
138
|
+
cancelled = true;
|
|
139
|
+
clearInterval(interval);
|
|
140
|
+
};
|
|
141
|
+
}, [signingStatus, signingEnrollmentId, mailboxUid]);
|
|
142
|
+
async function handleEnrollSigning() {
|
|
143
|
+
setSigningError(null);
|
|
144
|
+
setSigningStatus("enrolling");
|
|
145
|
+
try {
|
|
146
|
+
// Only reachable once `unlocked` is defined - see `handleAddPassword`'s identical note.
|
|
147
|
+
const { keyPair, csrPem } = await generateKeyPairWithCsr(mailbox.primarySmtpAddress, "sign");
|
|
148
|
+
const privateKeyRaw = await exportPrivateKeyPkcs8(keyPair.privateKey);
|
|
149
|
+
const wrappedKeySealed = await sealWithKey(unlocked.masterKey, privateKeyRaw, buildAad(mailboxUid, SIGNING_PRIVATE_KEY_AAD_PURPOSE));
|
|
150
|
+
const { enrollmentId } = await startSignEnrollment(mailboxUid, {
|
|
151
|
+
csr: csrPem,
|
|
152
|
+
wrappedKey: { ciphertext: wrappedKeySealed.ciphertext, nonce: wrappedKeySealed.nonce, algorithm: "AES-256-GCM" },
|
|
153
|
+
});
|
|
154
|
+
setSigningEnrollmentId(enrollmentId);
|
|
155
|
+
setSigningStatus("pending");
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
setSigningError(err instanceof ApiRequestError ? err.message : "Could not start signing certificate enrollment.");
|
|
159
|
+
setSigningStatus("idle");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function handleIdleTimeoutChange(e) {
|
|
163
|
+
const minutes = Number(e.target.value);
|
|
164
|
+
setIdleTimeoutMinutes(minutes);
|
|
165
|
+
setIdleTimeoutMinutesState(minutes);
|
|
166
|
+
}
|
|
167
|
+
function loadVault() {
|
|
168
|
+
return getKeyVault(mailboxUid)
|
|
169
|
+
.then(setVault)
|
|
170
|
+
.catch((err) => setLoadError(err instanceof ApiRequestError ? err.message : "Could not load your key vault."));
|
|
171
|
+
}
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
loadVault();
|
|
174
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
175
|
+
}, [mailboxUid]);
|
|
176
|
+
const unlocked = getUnlockedKeys(mailboxUid);
|
|
177
|
+
const passwordWraps = vault?.masterKeyWraps.filter((w) => w.method === "password") ?? [];
|
|
178
|
+
const recoveryWraps = vault?.masterKeyWraps.filter((w) => w.method === "recovery") ?? [];
|
|
179
|
+
const otherWraps = vault?.masterKeyWraps.filter((w) => w.method !== "password" && w.method !== "recovery") ?? [];
|
|
180
|
+
async function handleRemove(wrap) {
|
|
181
|
+
const key = `${wrap.method}:${wrap.methodId ?? ""}`;
|
|
182
|
+
setRemovingMethod(key);
|
|
183
|
+
setActionError(null);
|
|
184
|
+
try {
|
|
185
|
+
await removeMasterKeyWrap(mailboxUid, wrap.method, wrap.methodId);
|
|
186
|
+
await loadVault();
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
setActionError(err instanceof ApiRequestError ? err.message : "Could not remove this unlock method.");
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
setRemovingMethod(null);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async function handleAddPassword(e) {
|
|
196
|
+
e.preventDefault();
|
|
197
|
+
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
|
198
|
+
setActionError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (newPassword !== confirmNewPassword) {
|
|
202
|
+
setActionError("Passwords do not match.");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
setActionError(null);
|
|
206
|
+
setAddingPassword(true);
|
|
207
|
+
try {
|
|
208
|
+
// Only reachable once `unlocked` is defined - `EncryptionGate` guarantees this mailbox is
|
|
209
|
+
// unlocked before `EncryptionContent` ever mounts.
|
|
210
|
+
const wrap = await buildPasswordWrap(mailboxUid, unlocked.masterKey, newPassword);
|
|
211
|
+
await addMasterKeyWrap(mailboxUid, wrap);
|
|
212
|
+
setNewPassword("");
|
|
213
|
+
setConfirmNewPassword("");
|
|
214
|
+
await loadVault();
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
setActionError(err instanceof ApiRequestError ? err.message : "Could not add this password.");
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
setAddingPassword(false);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async function handleRegenerateRecoveryCodes() {
|
|
224
|
+
setActionError(null);
|
|
225
|
+
setRegenerating(true);
|
|
226
|
+
try {
|
|
227
|
+
for (const wrap of recoveryWraps) {
|
|
228
|
+
await removeMasterKeyWrap(mailboxUid, "recovery", wrap.methodId);
|
|
229
|
+
}
|
|
230
|
+
const { wraps, codes } = await buildRecoveryWraps(mailboxUid, unlocked.masterKey);
|
|
231
|
+
for (const wrap of wraps) {
|
|
232
|
+
await addMasterKeyWrap(mailboxUid, wrap);
|
|
233
|
+
}
|
|
234
|
+
setRecoveryCodesReason("regenerate");
|
|
235
|
+
setNewRecoveryCodes(codes);
|
|
236
|
+
setCodesSaved(false);
|
|
237
|
+
await loadVault();
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
setActionError(err instanceof ApiRequestError ? err.message : "Could not regenerate recovery codes.");
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
setRegenerating(false);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Real revocation for a captured wrap (`keyvaultApi.ts`'s `rekey()` - see that function's own doc
|
|
248
|
+
* comment): re-wraps this mailbox's already-unlocked private keys under a brand new master key
|
|
249
|
+
* (`rewrapPrivateKeysUnderNewMasterKey()`), wraps that new MK under a freshly entered password and a
|
|
250
|
+
* fresh set of recovery codes, and atomically replaces the vault - the enrolled keypair/certificate
|
|
251
|
+
* itself is unchanged (restapi's own `rekey()` rejects anything else), only how it's protected.
|
|
252
|
+
* Every *other* unlock method this mailbox had (a second password, a passkey, an old set of recovery
|
|
253
|
+
* codes) stops working the instant this succeeds, since `rekey()` replaces `masterKeyWraps` wholesale
|
|
254
|
+
* - the whole point, for a captured-wrap scenario where it's unclear which method was compromised.
|
|
255
|
+
*/
|
|
256
|
+
async function handleRotateKeys(e) {
|
|
257
|
+
e.preventDefault();
|
|
258
|
+
if (rotationPassword.length < MIN_PASSWORD_LENGTH) {
|
|
259
|
+
setActionError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (rotationPassword !== rotationConfirmPassword) {
|
|
263
|
+
setActionError("Passwords do not match.");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
setActionError(null);
|
|
267
|
+
setEscrowError(null);
|
|
268
|
+
setRotating(true);
|
|
269
|
+
try {
|
|
270
|
+
// Only reachable once `unlocked` is defined - see `handleAddPassword`'s identical note.
|
|
271
|
+
const { mk, wrappedKeys } = await rewrapPrivateKeysUnderNewMasterKey(mailboxUid, unlocked);
|
|
272
|
+
const passwordWrap = await buildPasswordWrap(mailboxUid, mk, rotationPassword);
|
|
273
|
+
const { wraps: newRecoveryWraps, codes } = await buildRecoveryWraps(mailboxUid, mk);
|
|
274
|
+
await rekey(mailboxUid, { wrappedKeys, masterKeyWraps: [passwordWrap, ...newRecoveryWraps], keys: mailbox.keys ?? [] });
|
|
275
|
+
// restapi's own rekey() can never accept a fresh escrow wrap (its validateMasterKeyWrap()
|
|
276
|
+
// always passes allowEscrow: false there) - it preserves this mailbox's existing escrow wrap
|
|
277
|
+
// verbatim instead, which now encrypts a master key nobody has any longer. Re-wrap it
|
|
278
|
+
// separately, via the same addMasterKeyWrap() path "Add escrow protection" above already
|
|
279
|
+
// uses, so rotating keys for an unrelated reason (lost device, password hygiene) doesn't
|
|
280
|
+
// silently drop real escrow coverage while this page keeps claiming it's still active. A
|
|
281
|
+
// failure here is reported via escrowError, not as a rotation failure - the rotation itself
|
|
282
|
+
// (password/recovery codes) already succeeded by this point and must not be rolled back for
|
|
283
|
+
// an escrow-specific hiccup the user can retry independently.
|
|
284
|
+
if (hasEscrowWrap) {
|
|
285
|
+
try {
|
|
286
|
+
const escrowInfo = await getEscrowInfo(mailboxUid);
|
|
287
|
+
const escrowWrap = await buildEscrowWrap(mk, escrowInfo.escrowScopeId, fromBase64(escrowInfo.publicKey.publicKey));
|
|
288
|
+
await addMasterKeyWrap(mailboxUid, escrowWrap);
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
setEscrowError(err instanceof ApiRequestError
|
|
292
|
+
? err.message
|
|
293
|
+
: 'Your keys were rotated, but escrow protection could not be re-established automatically. Use "Add escrow protection" below to restore it.');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// Refreshes this session's own cached keys against the new MK, via the password we just set -
|
|
297
|
+
// the underlying private key material didn't change, but the stale MK in memory would silently
|
|
298
|
+
// build wrong future wraps (e.g. a second "Add a password") if left as-is.
|
|
299
|
+
await unlockWithPassword(mailboxUid, mailbox.keys ?? [], rotationPassword);
|
|
300
|
+
setRotationPassword("");
|
|
301
|
+
setRotationConfirmPassword("");
|
|
302
|
+
setRecoveryCodesReason("rotate");
|
|
303
|
+
setNewRecoveryCodes(codes);
|
|
304
|
+
setCodesSaved(false);
|
|
305
|
+
await loadVault();
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
setActionError(err instanceof ApiRequestError ? err.message : "Could not rotate your encryption keys.");
|
|
309
|
+
}
|
|
310
|
+
finally {
|
|
311
|
+
setRotating(false);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function handleDestroyKeysNow() {
|
|
315
|
+
destroyUnlockedKeys(mailboxUid);
|
|
316
|
+
setDestroyed(true);
|
|
317
|
+
}
|
|
318
|
+
if (destroyed) {
|
|
319
|
+
return (_jsx("div", { className: "flex-1 min-w-0 overflow-y-auto p-6", children: _jsx("div", { className: "max-w-xl", children: _jsx(Alert, { children: "Your encryption keys have been removed from this session. Reload the page (or open Mail again) to unlock them when you need to read or send encrypted mail." }) }) }));
|
|
320
|
+
}
|
|
321
|
+
if (newRecoveryCodes) {
|
|
322
|
+
return (_jsx("div", { className: "flex-1 min-w-0 overflow-y-auto p-6", children: _jsxs("div", { className: "max-w-xl", children: [_jsx("h1", { className: "text-lg font-bold tracking-tight mb-1", children: "Save your new recovery codes" }), _jsxs("p", { className: "text-sm text-text-muted mb-4", children: [recoveryCodesReason === "rotate"
|
|
323
|
+
? "Your keys have been rotated - every previous unlock method (password, recovery codes, or anything else on file) has stopped working. "
|
|
324
|
+
: "Your old recovery codes no longer work. ", "If you lose your password, these new codes are the only way to recover your encrypted mail. Each code can be used once. Store them somewhere safe \u2014 they will not be shown again."] }), _jsx("ul", { className: "grid grid-cols-2 gap-2 mb-5 font-mono text-sm", children: newRecoveryCodes.map((code) => (_jsx("li", { className: "bg-surface-alt rounded-sm py-1.5 px-2 text-center", children: code }, code))) }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: codesSaved, onChange: (e) => setCodesSaved(e.target.checked) }), "I have saved these recovery codes in a safe place."] }), _jsx(Button, { type: "button", disabled: !codesSaved, onClick: () => setNewRecoveryCodes(null), className: "!w-auto", children: "Done" })] }) }));
|
|
325
|
+
}
|
|
326
|
+
return (_jsx("div", { className: "flex-1 min-w-0 overflow-y-auto p-6", children: _jsxs("div", { className: "max-w-xl flex flex-col gap-6", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-lg font-bold tracking-tight mb-1", children: "Encryption" }), _jsxs("p", { className: "text-sm text-text-muted", children: ["Manage how ", mailbox.displayName, " unlocks its encryption keys on this and other devices."] })] }), loadError && _jsx(Alert, { children: loadError }), actionError && _jsx(Alert, { children: actionError }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Encryption keys" }), displayedKeys.length > 0 ? (_jsx("ul", { className: "flex flex-col gap-1 text-sm", children: displayedKeys.map((key) => (_jsxs("li", { className: "font-mono text-xs", children: [key.useType === "sign" ? "Signing" : "Encryption", " key: ", key.fingerprint, key.revokedAt && _jsx("span", { className: "text-danger", children: " (revoked)" })] }, key.fingerprint))) })) : (_jsx("p", { className: "text-sm text-text-muted", children: "No keys enrolled yet." }))] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Digital signatures" }), activeSigningKey ? (_jsx("p", { className: "text-sm text-text-muted", children: "Enabled \u2014 outgoing mail from this mailbox is signed with a publicly-trusted certificate." })) : signingStatus === "pending" ? (_jsx("p", { className: "text-sm text-text-muted", children: "Requested \u2014 a public certificate authority issues this via an automated email exchange, which can take a few minutes. You can leave this page; it finishes in the background and takes effect automatically once issued." })) : (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("p", { className: "text-xs text-text-muted", children: "Lets recipients verify that mail from this mailbox is genuinely from you. Optional \u2014 encryption already works without it." }), signingError && _jsx(Alert, { children: signingError }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: signingStatus === "enrolling", disabled: signingStatus === "enrolling", onClick: handleEnrollSigning, children: "Enable digital signatures" })] }))] }), mailbox.escrowScopeId && (_jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Escrow" }), escrowError && _jsx(Alert, { children: escrowError }), hasEscrowWrap ? (_jsx("p", { className: "text-sm text-text-muted", children: "This mailbox is under legal/compliance escrow \u2014 an authorized holder in your organization can recover its encrypted mail if needed. This does not weaken protection against anyone else." })) : (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("p", { className: "text-xs text-text-muted", children: "Your organization has assigned this mailbox to an escrow scope, but nothing has been protected yet \u2014 an authorized holder cannot recover this mailbox's encrypted mail until you complete this step. This does not weaken protection against anyone else." }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: wrappingEscrow, disabled: wrappingEscrow, onClick: handleWrapEscrow, children: "Add escrow protection" })] }))] })), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Unlock methods" }), _jsxs("p", { className: "text-xs text-text-muted mb-3", children: ["Removing a method here stops it from being usable to unlock this mailbox going forward, but it is ", _jsx("strong", { children: "not" }), " full revocation \u2014 anyone who already captured a wrapped copy and knows its secret could still use it. For real revocation (e.g. after a lost device), rotate your keys entirely below instead."] }), vault && vault.masterKeyWraps.length > 0 ? (_jsx("ul", { className: "flex flex-col gap-2", children: [...passwordWraps, ...recoveryWraps, ...otherWraps].map((wrap) => {
|
|
327
|
+
const key = `${wrap.method}:${wrap.methodId ?? ""}`;
|
|
328
|
+
return (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsx("span", { children: METHOD_LABELS[wrap.method] ?? wrap.method }), wrap.method !== "escrow" && (_jsx(Button, { type: "button", variant: "text", className: "!w-auto text-danger", loading: removingMethod === key, disabled: removingMethod !== null, onClick: () => handleRemove(wrap), children: "Remove" }))] }, key));
|
|
329
|
+
}) })) : (_jsx("p", { className: "text-sm text-text-muted", children: "No unlock methods on file." }))] }), _jsxs("form", { onSubmit: handleAddPassword, className: "flex flex-col gap-2", children: [_jsx("h2", { className: "text-sm font-semibold", children: "Add a password" }), _jsx("input", { type: "password", "aria-label": "New password", placeholder: "New password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: newPassword, onChange: (e) => setNewPassword(e.target.value), disabled: addingPassword, autoComplete: "new-password" }), _jsx("input", { type: "password", "aria-label": "Confirm new password", placeholder: "Confirm new password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: confirmNewPassword, onChange: (e) => setConfirmNewPassword(e.target.value), disabled: addingPassword, autoComplete: "new-password" }), _jsx("div", { children: _jsx(Button, { type: "submit", loading: addingPassword, disabled: addingPassword, className: "!w-auto", children: "Add password" }) })] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Recovery codes" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Regenerating replaces all of your existing recovery codes \u2014 old ones stop working immediately." }), _jsx(Button, { type: "button", variant: "secondary", loading: regenerating, disabled: regenerating, onClick: handleRegenerateRecoveryCodes, className: "!w-auto", children: "Regenerate recovery codes" })] }), _jsxs("form", { onSubmit: handleRotateKeys, className: "flex flex-col gap-2 border-t border-border pt-6", children: [_jsx("h2", { className: "text-sm font-semibold", children: "Rotate keys" }), _jsx("p", { className: "text-xs text-text-muted mb-1", children: "Real revocation, for when a device or an unlock method may have been compromised. Re-protects your existing encryption key under a brand new master key - your signing/ encryption keypair itself doesn\u2019t change, so mail you\u2019ve already sent or received still decrypts normally. Every current unlock method (password, recovery codes, and anything else on file) stops working immediately; you\u2019ll set a new password and get new recovery codes below." }), _jsx("input", { type: "password", "aria-label": "New password for rotated keys", placeholder: "New password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: rotationPassword, onChange: (e) => setRotationPassword(e.target.value), disabled: rotating, autoComplete: "new-password" }), _jsx("input", { type: "password", "aria-label": "Confirm new password for rotated keys", placeholder: "Confirm new password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: rotationConfirmPassword, onChange: (e) => setRotationConfirmPassword(e.target.value), disabled: rotating, autoComplete: "new-password" }), _jsx("div", { children: _jsx(Button, { type: "submit", variant: "secondary", className: "!w-auto text-danger", loading: rotating, disabled: rotating, children: "Rotate keys now" }) })] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Session timeout" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Automatically destroys your unlocked keys on this device after this much time with no activity anywhere in the app - not just Mail or Settings. Applies the next time you open or reload the app." }), _jsx("select", { "aria-label": "Session timeout", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: idleTimeoutMinutes, onChange: handleIdleTimeoutChange, children: IDLE_TIMEOUT_OPTIONS_MINUTES.map((minutes) => (_jsx("option", { value: minutes, children: idleTimeoutLabel(minutes) }, minutes))) })] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "This session" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Removes your unlocked keys from this browser tab\u2019s memory right now, without affecting any other device. You\u2019ll be asked to unlock again the next time you read or send encrypted mail here." }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", onClick: handleDestroyKeysNow, children: "Destroy keys on this device now" })] })] }) }));
|
|
330
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
+
import { createLabel, deleteLabel, listLabels, updateLabel } from "@rapidmx/react-shared/mail/labelsApi.js";
|
|
9
|
+
import SettingsShell, { useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
|
|
10
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
11
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
12
|
+
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
13
|
+
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
14
|
+
export default function SettingsLabelsPage(props) {
|
|
15
|
+
return (_jsx(SettingsShell, { ...props, active: "labels", children: _jsx(LabelsContent, {}) }));
|
|
16
|
+
}
|
|
17
|
+
const DEFAULT_COLOR = "#6366f1";
|
|
18
|
+
function LabelsContent() {
|
|
19
|
+
const { mailboxUid } = useSettingsShell();
|
|
20
|
+
const [labels, setLabels] = useState([]);
|
|
21
|
+
const [loading, setLoading] = useState(true);
|
|
22
|
+
const [error, setError] = useState(null);
|
|
23
|
+
// `editing === null` means the create form; otherwise the label currently being renamed/recolored.
|
|
24
|
+
const [editing, setEditing] = useState(null);
|
|
25
|
+
const [formOpen, setFormOpen] = useState(false);
|
|
26
|
+
const [formName, setFormName] = useState("");
|
|
27
|
+
const [formColor, setFormColor] = useState(DEFAULT_COLOR);
|
|
28
|
+
const [formError, setFormError] = useState(null);
|
|
29
|
+
const [saving, setSaving] = useState(false);
|
|
30
|
+
const [deleting, setDeleting] = useState(null);
|
|
31
|
+
const [deleteError, setDeleteError] = useState(null);
|
|
32
|
+
const [deleteBusy, setDeleteBusy] = useState(false);
|
|
33
|
+
// `SettingsShell` only ever renders its children once `mailboxUid` has resolved — same established
|
|
34
|
+
// non-null pattern as `apps/www/settings/auto-reply/index.tsx`.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
listLabels(mailboxUid)
|
|
37
|
+
.then(setLabels)
|
|
38
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load labels."))
|
|
39
|
+
.finally(() => setLoading(false));
|
|
40
|
+
}, [mailboxUid]);
|
|
41
|
+
function openCreateForm() {
|
|
42
|
+
setEditing(null);
|
|
43
|
+
setFormName("");
|
|
44
|
+
setFormColor(DEFAULT_COLOR);
|
|
45
|
+
setFormError(null);
|
|
46
|
+
setFormOpen(true);
|
|
47
|
+
}
|
|
48
|
+
function openEditForm(label) {
|
|
49
|
+
setEditing(label);
|
|
50
|
+
setFormName(label.name);
|
|
51
|
+
setFormColor(label.color ?? DEFAULT_COLOR);
|
|
52
|
+
setFormError(null);
|
|
53
|
+
setFormOpen(true);
|
|
54
|
+
}
|
|
55
|
+
async function handleSave() {
|
|
56
|
+
setSaving(true);
|
|
57
|
+
setFormError(null);
|
|
58
|
+
try {
|
|
59
|
+
if (editing) {
|
|
60
|
+
const updated = await updateLabel({ uid: editing.uid, version: editing.version, name: formName, color: formColor });
|
|
61
|
+
setLabels((prev) => prev.map((l) => (l.uid === updated.uid ? updated : l)));
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
const created = await createLabel({ mailboxUid: mailboxUid, name: formName, color: formColor });
|
|
65
|
+
setLabels((prev) => [...prev, created]);
|
|
66
|
+
}
|
|
67
|
+
setFormOpen(false);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
setFormError(err instanceof ApiRequestError ? err.message : "Could not save this label.");
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
setSaving(false);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function handleDelete() {
|
|
77
|
+
setDeleteBusy(true);
|
|
78
|
+
setDeleteError(null);
|
|
79
|
+
try {
|
|
80
|
+
await deleteLabel(deleting.uid, deleting.version);
|
|
81
|
+
setLabels((prev) => prev.filter((l) => l.uid !== deleting.uid));
|
|
82
|
+
setDeleting(null);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
setDeleteError(err instanceof ApiRequestError ? err.message : "Could not delete this label.");
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
setDeleteBusy(false);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return (_jsxs("div", { className: "flex-1 min-w-0 overflow-y-auto p-6", children: [_jsxs("div", { className: "max-w-3xl", children: [_jsxs("div", { className: "flex items-center justify-between mb-5", children: [_jsx("h1", { className: "text-lg font-bold tracking-tight", children: "Labels" }), _jsx(Button, { type: "button", className: "!w-auto", onClick: openCreateForm, children: "+ New label" })] }), error && _jsx(Alert, { children: error }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : labels.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No labels yet." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: labels.map((label) => (_jsxs("li", { className: "flex items-center justify-between border border-border rounded-md p-3", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "w-3 h-3 rounded-full shrink-0", style: { backgroundColor: label.color ?? DEFAULT_COLOR }, "aria-hidden": "true" }), _jsx("span", { className: "text-sm font-semibold", children: label.name })] }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("button", { type: "button", className: "text-primary-dark hover:underline font-medium text-sm", onClick: () => openEditForm(label), children: "Edit" }), _jsx("button", { type: "button", className: "text-danger hover:underline font-medium text-sm", onClick: () => {
|
|
92
|
+
setDeleting(label);
|
|
93
|
+
setDeleteError(null);
|
|
94
|
+
}, children: "Delete" })] })] }, label.uid))) }))] }), _jsxs(Modal, { open: formOpen, onClose: () => setFormOpen(false), title: editing ? "Edit label" : "New label", children: [formError && _jsx(Alert, { children: formError }), _jsx(FormField, { label: "Name", htmlFor: "label-name", children: _jsx("input", { id: "label-name", type: "text", value: formName, onChange: (e) => setFormName(e.target.value), className: "w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface" }) }), _jsx(FormField, { label: "Color", htmlFor: "label-color", children: _jsx("input", { id: "label-color", type: "color", value: formColor, onChange: (e) => setFormColor(e.target.value), className: "h-9 w-16 rounded-md border border-border bg-surface" }) }), _jsxs("div", { className: "flex justify-end gap-2 mt-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setFormOpen(false), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: saving, disabled: saving || !formName.trim(), onClick: handleSave, children: "Save" })] })] }), _jsxs(Modal, { open: !!deleting, onClose: () => setDeleting(null), title: "Delete this label?", children: [deleteError && _jsx(Alert, { children: deleteError }), _jsxs("p", { className: "text-sm text-text-muted mb-4", children: ["This removes \u201C", deleting?.name, "\u201D from every message it's applied to. This can't be undone."] }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setDeleting(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: deleteBusy, disabled: deleteBusy, onClick: handleDelete, children: "Delete label" })] })] })] }));
|
|
95
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useRef, useState } from "react";
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
+
import { createExportRequest, exportRequestDownloadUrl, listExportRequests, } from "@rapidmx/react-shared/mail/dataExportApi.js";
|
|
9
|
+
import { listImportRequests, uploadMailboxImport, } from "@rapidmx/react-shared/mail/mailboxImportApi.js";
|
|
10
|
+
import { createErasureRequest, listErasureRequests, } from "@rapidmx/react-shared/mail/erasureRequestApi.js";
|
|
11
|
+
import { listFolders } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
12
|
+
import SettingsShell, { useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
|
|
13
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
14
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
15
|
+
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
16
|
+
const NON_MAIL_FOLDER_TYPES = new Set(["calendar", "contacts", "tasks", "notes"]);
|
|
17
|
+
function importFormatFromFilename(filename) {
|
|
18
|
+
return filename.toLowerCase().endsWith(".pst") ? "pst" : "mbox";
|
|
19
|
+
}
|
|
20
|
+
function statusBadgeClass(status) {
|
|
21
|
+
if (status === "failed" || status === "denied")
|
|
22
|
+
return "bg-danger-bg text-danger";
|
|
23
|
+
if (status === "pending" || status === "processing")
|
|
24
|
+
return "bg-surface-alt text-text-muted";
|
|
25
|
+
return "bg-success text-white";
|
|
26
|
+
}
|
|
27
|
+
export default function SettingsPrivacyPage(props) {
|
|
28
|
+
return (_jsx(SettingsShell, { ...props, active: "privacy", children: _jsx(PrivacyContent, {}) }));
|
|
29
|
+
}
|
|
30
|
+
function PrivacyContent() {
|
|
31
|
+
const { mailboxUid } = useSettingsShell();
|
|
32
|
+
return (_jsx("div", { className: "flex-1 min-w-0 overflow-y-auto p-6", children: _jsxs("div", { className: "max-w-xl flex flex-col gap-8", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-lg font-bold tracking-tight mb-1", children: "Privacy & Data" }), _jsx("p", { className: "text-sm text-text-muted", children: "Export or manage the data associated with this mailbox." })] }), _jsx(ExportSection, { mailboxUid: mailboxUid }), _jsx(ImportSection, { mailboxUid: mailboxUid }), _jsx(ErasureSection, {})] }) }));
|
|
33
|
+
}
|
|
34
|
+
function ExportSection({ mailboxUid }) {
|
|
35
|
+
const [requests, setRequests] = useState([]);
|
|
36
|
+
const [loading, setLoading] = useState(true);
|
|
37
|
+
const [loadError, setLoadError] = useState(null);
|
|
38
|
+
const [format, setFormat] = useState("json");
|
|
39
|
+
const [creating, setCreating] = useState(false);
|
|
40
|
+
const [createError, setCreateError] = useState(null);
|
|
41
|
+
// The create form below isn't gated behind `loading`, so a fast create-then-reload can resolve before
|
|
42
|
+
// the initial mount fetch does; without this guard the mount fetch's now-stale response would land
|
|
43
|
+
// last and silently revert the list. Only the most-recently-issued load's response is ever applied.
|
|
44
|
+
const loadSeq = useRef(0);
|
|
45
|
+
function loadRequests() {
|
|
46
|
+
const seq = ++loadSeq.current;
|
|
47
|
+
return listExportRequests()
|
|
48
|
+
.then((data) => {
|
|
49
|
+
if (seq === loadSeq.current)
|
|
50
|
+
setRequests(data);
|
|
51
|
+
})
|
|
52
|
+
.catch((err) => {
|
|
53
|
+
if (seq === loadSeq.current)
|
|
54
|
+
setLoadError(err instanceof ApiRequestError ? err.message : "Could not load your export requests.");
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
loadRequests().finally(() => setLoading(false));
|
|
59
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
60
|
+
}, [mailboxUid]);
|
|
61
|
+
async function handleCreate(e) {
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
setCreateError(null);
|
|
64
|
+
setCreating(true);
|
|
65
|
+
try {
|
|
66
|
+
await createExportRequest({ format });
|
|
67
|
+
await loadRequests();
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
setCreateError(err instanceof ApiRequestError ? err.message : "Could not start this export.");
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
setCreating(false);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return (_jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Export my data" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Requests a copy of this mailbox\u2019s messages, contacts, calendar, tasks, and notes. This runs in the background and can take a few minutes \u2014 check back here for a download link once it\u2019s ready." }), _jsxs("form", { onSubmit: handleCreate, className: "flex items-end gap-3 mb-5", children: [_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: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", 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, children: "Request export" })] }), createError && _jsx(Alert, { children: createError }), loadError && _jsx(Alert, { children: loadError }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : requests.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No export requests yet." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: requests.map((request) => (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsxs("span", { children: [request.format.toUpperCase(), " \u00B7 ", new Date(request.dateCreated).toLocaleString(), request.status === "failed" && request.errorMessage && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.errorMessage] }))] }), _jsxs("span", { className: "flex items-center gap-3", children: [_jsx("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${statusBadgeClass(request.status)}`, children: request.status }), request.status === "ready" && (_jsx("a", { href: exportRequestDownloadUrl(request.uid), className: "text-primary-dark hover:underline font-medium", children: "Download" }))] })] }, request.uid))) }))] }));
|
|
77
|
+
}
|
|
78
|
+
function ImportSection({ mailboxUid }) {
|
|
79
|
+
const [folders, setFolders] = useState([]);
|
|
80
|
+
const [targetFolderUid, setTargetFolderUid] = useState("");
|
|
81
|
+
const [requests, setRequests] = useState([]);
|
|
82
|
+
const [loading, setLoading] = useState(true);
|
|
83
|
+
const [loadError, setLoadError] = useState(null);
|
|
84
|
+
const [uploading, setUploading] = useState(false);
|
|
85
|
+
const [uploadError, setUploadError] = useState(null);
|
|
86
|
+
const fileInputRef = useRef(null);
|
|
87
|
+
// Same stale-response guard as ExportSection's loadRequests() - the Upload button is enabled
|
|
88
|
+
// independently of this section's own `loading`, so a fast upload-then-reload can resolve before the
|
|
89
|
+
// initial mount fetch does.
|
|
90
|
+
const loadSeq = useRef(0);
|
|
91
|
+
function loadRequests() {
|
|
92
|
+
const seq = ++loadSeq.current;
|
|
93
|
+
return listImportRequests()
|
|
94
|
+
.then((data) => {
|
|
95
|
+
if (seq === loadSeq.current)
|
|
96
|
+
setRequests(data);
|
|
97
|
+
})
|
|
98
|
+
.catch((err) => {
|
|
99
|
+
if (seq === loadSeq.current)
|
|
100
|
+
setLoadError(err instanceof ApiRequestError ? err.message : "Could not load your import requests.");
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
// Only reachable once mailboxUid is resolved - SettingsShell never renders this page's children
|
|
105
|
+
// until then, the same invariant every other settings page in this codebase already relies on.
|
|
106
|
+
Promise.all([
|
|
107
|
+
listFolders(mailboxUid).then((all) => {
|
|
108
|
+
const mailFolders = all.filter((f) => !NON_MAIL_FOLDER_TYPES.has(f.type));
|
|
109
|
+
setFolders(mailFolders);
|
|
110
|
+
setTargetFolderUid(mailFolders[0]?.uid ?? "");
|
|
111
|
+
}),
|
|
112
|
+
loadRequests(),
|
|
113
|
+
]).finally(() => setLoading(false));
|
|
114
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
115
|
+
}, [mailboxUid]);
|
|
116
|
+
function handleFileChange(e) {
|
|
117
|
+
const file = e.target.files?.[0];
|
|
118
|
+
e.target.value = "";
|
|
119
|
+
if (!file || !targetFolderUid) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
setUploadError(null);
|
|
123
|
+
setUploading(true);
|
|
124
|
+
uploadMailboxImport(file, { format: importFormatFromFilename(file.name), targetFolderUid })
|
|
125
|
+
.then(() => loadRequests())
|
|
126
|
+
.catch((err) => setUploadError(err instanceof ApiRequestError ? err.message : "Could not upload this file."))
|
|
127
|
+
.finally(() => setUploading(false));
|
|
128
|
+
}
|
|
129
|
+
return (_jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Import mail" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Uploads an Mbox or PST archive and imports every message it contains into the folder you choose below. This runs in the background \u2014 check back here for its progress." }), folders.length > 0 && (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm mb-3 max-w-xs", children: [_jsx("span", { className: "font-semibold", children: "Destination folder" }), _jsx("select", { "aria-label": "Destination folder", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", 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 mb-3", 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 }), uploadError && _jsx(Alert, { children: uploadError }), loadError && _jsx(Alert, { children: loadError }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : requests.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No import requests yet." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: requests.map((request) => (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsxs("span", { children: [request.format.toUpperCase(), " \u00B7 ", new Date(request.dateCreated).toLocaleString(), request.status === "completed" && (_jsxs("span", { className: "text-text-muted", children: [" ", "\u2014 ", request.importedCount ?? 0, " imported", request.failedCount ? `, ${request.failedCount} failed` : ""] })), request.status === "failed" && request.errorMessage && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.errorMessage] }))] }), _jsx("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${statusBadgeClass(request.status)}`, children: request.status })] }, request.uid))) }))] }));
|
|
130
|
+
}
|
|
131
|
+
function ErasureSection() {
|
|
132
|
+
const [requests, setRequests] = useState([]);
|
|
133
|
+
const [loading, setLoading] = useState(true);
|
|
134
|
+
const [loadError, setLoadError] = useState(null);
|
|
135
|
+
const [confirming, setConfirming] = useState(false);
|
|
136
|
+
const [creating, setCreating] = useState(false);
|
|
137
|
+
const [createError, setCreateError] = useState(null);
|
|
138
|
+
function loadRequests() {
|
|
139
|
+
return listErasureRequests()
|
|
140
|
+
.then(setRequests)
|
|
141
|
+
.catch((err) => setLoadError(err instanceof ApiRequestError ? err.message : "Could not load your erasure requests."));
|
|
142
|
+
}
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
loadRequests().finally(() => setLoading(false));
|
|
145
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
146
|
+
}, []);
|
|
147
|
+
const hasPending = requests.some((request) => request.status === "pending");
|
|
148
|
+
function closeConfirm() {
|
|
149
|
+
setConfirming(false);
|
|
150
|
+
}
|
|
151
|
+
async function handleConfirm() {
|
|
152
|
+
setCreateError(null);
|
|
153
|
+
setCreating(true);
|
|
154
|
+
try {
|
|
155
|
+
await createErasureRequest();
|
|
156
|
+
setConfirming(false);
|
|
157
|
+
await loadRequests();
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
setCreateError(err instanceof ApiRequestError ? err.message : "Could not submit this request.");
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
setCreating(false);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return (_jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Delete my account" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Permanently and irreversibly deletes this mailbox and everything in it, once a compliance administrator reviews and approves the request. There is no way to cancel a request or undo the deletion once it runs." }), loadError && _jsx(Alert, { children: loadError }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : (_jsxs(_Fragment, { children: [requests.length > 0 && (_jsx("ul", { className: "flex flex-col gap-2 mb-3", children: requests.map((request) => (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsxs("span", { children: [new Date(request.dateCreated).toLocaleString(), request.status === "denied" && request.reason && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.reason] }))] }), _jsx("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${statusBadgeClass(request.status)}`, children: request.status })] }, request.uid))) })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto !border-danger !text-danger hover:!border-danger hover:!text-danger", disabled: hasPending, onClick: () => setConfirming(true), children: "Request account erasure" })] })), _jsxs(Modal, { open: confirming, onClose: closeConfirm, title: "Delete my account", children: [_jsx("p", { className: "text-sm mb-5", children: "This permanently deletes this mailbox and everything in it \u2014 messages, contacts, calendar, tasks, and notes \u2014 once approved. This cannot be undone and cannot be cancelled once submitted." }), createError && _jsx(Alert, { children: createError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: creating, onClick: closeConfirm, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: creating, disabled: creating, onClick: handleConfirm, children: "Request erasure" })] })] })] }));
|
|
167
|
+
}
|