@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,664 @@
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 {
8
+ KeyVault,
9
+ MasterKeyWrap,
10
+ PublicKey,
11
+ addMasterKeyWrap,
12
+ checkSignEnrollmentStatus,
13
+ findActivePublicKey,
14
+ getEscrowInfo,
15
+ getKeyVault,
16
+ rekey,
17
+ removeMasterKeyWrap,
18
+ startSignEnrollment,
19
+ } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
20
+ import {
21
+ SIGNING_PRIVATE_KEY_AAD_PURPOSE,
22
+ destroyUnlockedKeys,
23
+ getUnlockedKeys,
24
+ unlockWithPassword,
25
+ } from "@rapidmx/react-shared/crypto/keySession.js";
26
+ import { IDLE_TIMEOUT_OPTIONS_MINUTES, getIdleTimeoutMinutes, setIdleTimeoutMinutes } from "@rapidmx/react-shared/crypto/idleTimeout.js";
27
+ import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
28
+ import { buildAad, sealWithKey } from "@rapidmx/react-shared/crypto/masterKey.js";
29
+ import { buildEscrowWrap, buildPasswordWrap, buildRecoveryWraps } from "@rapidmx/react-shared/crypto/masterKeyWraps.js";
30
+ import { rewrapPrivateKeysUnderNewMasterKey } from "@rapidmx/react-shared/crypto/keyRotation.js";
31
+ import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "@rapidmx/react-shared/crypto/keys.js";
32
+ import { getMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
33
+ import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
34
+ import KeyEnrollmentGate from "../../../shared/components/layout/KeyEnrollmentGate.js";
35
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
36
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
37
+
38
+ const MIN_PASSWORD_LENGTH = 8;
39
+
40
+ // RFC 8823 ACME issuance is a real email round-trip with a public CA - "likely minutes," not seconds -
41
+ // so this polls infrequently rather than hammering the endpoint.
42
+ const SIGNING_ENROLLMENT_POLL_INTERVAL_MS = 15_000;
43
+
44
+ const METHOD_LABELS: Record<string, string> = {
45
+ password: "Password",
46
+ passkey: "Passkey",
47
+ recovery: "Recovery code",
48
+ escrow: "Escrow (managed by your organization)",
49
+ };
50
+
51
+ function idleTimeoutLabel(minutes: number): string {
52
+ if (minutes === 0) {
53
+ return "Never";
54
+ }
55
+ if (minutes === 60) {
56
+ return "1 hour";
57
+ }
58
+ return `${minutes} minutes`;
59
+ }
60
+
61
+ export type SettingsEncryptionPageProps = Omit<SettingsShellProps, "active">;
62
+
63
+ export default function SettingsEncryptionPage(props: SettingsEncryptionPageProps) {
64
+ return (
65
+ <SettingsShell {...props} active="encryption">
66
+ <EncryptionGate />
67
+ </SettingsShell>
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Unlike `MailShell`, `SettingsShell` doesn't already gate its children behind `KeyEnrollmentGate` (a
73
+ * user can reach Settings without ever having opened Mail this session) - wrapped here instead, so
74
+ * `EncryptionContent` below can always assume `getUnlockedKeys()` has something for this mailbox before
75
+ * it renders. See `KeyEnrollmentGate`'s own doc comment for why it's safe to mount more than once (it
76
+ * short-circuits to `children` immediately for a mailbox already unlocked elsewhere this session).
77
+ */
78
+ function EncryptionGate() {
79
+ const { mailboxUid, mailboxes } = useSettingsShell();
80
+ const mailbox = mailboxes.find((mb) => mb.uid === mailboxUid)!;
81
+ return (
82
+ <KeyEnrollmentGate mailboxUid={mailboxUid} mailboxAddress={mailbox.primarySmtpAddress} mailboxKeys={mailbox.keys}>
83
+ <EncryptionContent />
84
+ </KeyEnrollmentGate>
85
+ );
86
+ }
87
+
88
+ function EncryptionContent() {
89
+ const { mailboxUid, mailboxes } = useSettingsShell();
90
+ const mailbox = mailboxes.find((mb) => mb.uid === mailboxUid)!;
91
+
92
+ const [vault, setVault] = useState<KeyVault | null>(null);
93
+ const [loadError, setLoadError] = useState<string | null>(null);
94
+ const [removingMethod, setRemovingMethod] = useState<string | null>(null);
95
+ const [actionError, setActionError] = useState<string | null>(null);
96
+
97
+ const [newPassword, setNewPassword] = useState("");
98
+ const [confirmNewPassword, setConfirmNewPassword] = useState("");
99
+ const [addingPassword, setAddingPassword] = useState(false);
100
+
101
+ const [regenerating, setRegenerating] = useState(false);
102
+ const [newRecoveryCodes, setNewRecoveryCodes] = useState<string[] | null>(null);
103
+ // Distinguishes the "Save your new recovery codes" screen's copy for the two different actions that
104
+ // land on it - a plain regeneration only invalidates old recovery codes, while a rotation also
105
+ // invalidates every other unlock method, which the copy needs to say plainly.
106
+ const [recoveryCodesReason, setRecoveryCodesReason] = useState<"regenerate" | "rotate">("regenerate");
107
+ const [codesSaved, setCodesSaved] = useState(false);
108
+
109
+ const [rotationPassword, setRotationPassword] = useState("");
110
+ const [rotationConfirmPassword, setRotationConfirmPassword] = useState("");
111
+ const [rotating, setRotating] = useState(false);
112
+
113
+ const [destroyed, setDestroyed] = useState(false);
114
+ const [idleTimeoutMinutes, setIdleTimeoutMinutesState] = useState(() => getIdleTimeoutMinutes());
115
+
116
+ // `mailbox.keys` comes from `SettingsShell`'s one-time `listMailboxes()` fetch - once an ACME
117
+ // enrollment issues, the server has installed a new signing key that fetch never saw. Only this
118
+ // page refetches (via `getMailbox()`) to notice; `null` means "no fresher data yet, use mailbox.keys".
119
+ const [refreshedKeys, setRefreshedKeys] = useState<PublicKey[] | null>(null);
120
+ const displayedKeys = refreshedKeys ?? mailbox.keys ?? [];
121
+ const activeSigningKey = findActivePublicKey(displayedKeys, "sign");
122
+
123
+ const [signingStatus, setSigningStatus] = useState<"idle" | "enrolling" | "pending">("idle");
124
+ const [signingEnrollmentId, setSigningEnrollmentId] = useState<string | null>(null);
125
+ const [signingError, setSigningError] = useState<string | null>(null);
126
+
127
+ const hasEscrowWrap = vault?.masterKeyWraps.some((w) => w.method === "escrow") ?? false;
128
+ const [wrappingEscrow, setWrappingEscrow] = useState(false);
129
+ const [escrowError, setEscrowError] = useState<string | null>(null);
130
+
131
+ async function handleWrapEscrow() {
132
+ setEscrowError(null);
133
+ setWrappingEscrow(true);
134
+ try {
135
+ // Only reachable when mailbox.escrowScopeId is set (see the render guard below) and
136
+ // `unlocked` is defined - see `handleAddPassword`'s identical note on the latter.
137
+ const escrowInfo = await getEscrowInfo(mailboxUid!);
138
+ const wrap = await buildEscrowWrap(unlocked!.masterKey, escrowInfo.escrowScopeId, fromBase64(escrowInfo.publicKey.publicKey));
139
+ await addMasterKeyWrap(mailboxUid!, wrap);
140
+ await loadVault();
141
+ } catch (err) {
142
+ setEscrowError(err instanceof ApiRequestError ? err.message : "Could not add escrow protection for this mailbox.");
143
+ } finally {
144
+ setWrappingEscrow(false);
145
+ }
146
+ }
147
+
148
+ useEffect(() => {
149
+ if (signingStatus !== "pending" || !signingEnrollmentId) {
150
+ return;
151
+ }
152
+ let cancelled = false;
153
+ const interval = setInterval(async () => {
154
+ try {
155
+ const result = await checkSignEnrollmentStatus(mailboxUid!, signingEnrollmentId);
156
+ if (cancelled) {
157
+ return;
158
+ }
159
+ if (result.status === "issued") {
160
+ setSigningStatus("idle");
161
+ setSigningEnrollmentId(null);
162
+ const refreshed = await getMailbox(mailboxUid!);
163
+ if (!cancelled) {
164
+ setRefreshedKeys(refreshed.keys ?? []);
165
+ }
166
+ } else if (result.status === "failed") {
167
+ setSigningStatus("idle");
168
+ setSigningEnrollmentId(null);
169
+ setSigningError(result.error ?? "Signing certificate enrollment failed.");
170
+ }
171
+ // "pending" leaves state as-is - the interval below just tries again.
172
+ } catch {
173
+ // Transient network error - keep polling rather than surfacing a one-off failure.
174
+ }
175
+ }, SIGNING_ENROLLMENT_POLL_INTERVAL_MS);
176
+ return () => {
177
+ cancelled = true;
178
+ clearInterval(interval);
179
+ };
180
+ }, [signingStatus, signingEnrollmentId, mailboxUid]);
181
+
182
+ async function handleEnrollSigning() {
183
+ setSigningError(null);
184
+ setSigningStatus("enrolling");
185
+ try {
186
+ // Only reachable once `unlocked` is defined - see `handleAddPassword`'s identical note.
187
+ const { keyPair, csrPem } = await generateKeyPairWithCsr(mailbox.primarySmtpAddress, "sign");
188
+ const privateKeyRaw = await exportPrivateKeyPkcs8(keyPair.privateKey);
189
+ const wrappedKeySealed = await sealWithKey(
190
+ unlocked!.masterKey,
191
+ privateKeyRaw,
192
+ buildAad(mailboxUid!, SIGNING_PRIVATE_KEY_AAD_PURPOSE),
193
+ );
194
+ const { enrollmentId } = await startSignEnrollment(mailboxUid!, {
195
+ csr: csrPem,
196
+ wrappedKey: { ciphertext: wrappedKeySealed.ciphertext, nonce: wrappedKeySealed.nonce, algorithm: "AES-256-GCM" },
197
+ });
198
+ setSigningEnrollmentId(enrollmentId);
199
+ setSigningStatus("pending");
200
+ } catch (err) {
201
+ setSigningError(err instanceof ApiRequestError ? err.message : "Could not start signing certificate enrollment.");
202
+ setSigningStatus("idle");
203
+ }
204
+ }
205
+
206
+ function handleIdleTimeoutChange(e: React.ChangeEvent<HTMLSelectElement>) {
207
+ const minutes = Number(e.target.value);
208
+ setIdleTimeoutMinutes(minutes);
209
+ setIdleTimeoutMinutesState(minutes);
210
+ }
211
+
212
+ function loadVault() {
213
+ return getKeyVault(mailboxUid!)
214
+ .then(setVault)
215
+ .catch((err) => setLoadError(err instanceof ApiRequestError ? err.message : "Could not load your key vault."));
216
+ }
217
+
218
+ useEffect(() => {
219
+ loadVault();
220
+ // eslint-disable-next-line react-hooks/exhaustive-deps
221
+ }, [mailboxUid]);
222
+
223
+ const unlocked = getUnlockedKeys(mailboxUid!);
224
+ const passwordWraps = vault?.masterKeyWraps.filter((w) => w.method === "password") ?? [];
225
+ const recoveryWraps = vault?.masterKeyWraps.filter((w) => w.method === "recovery") ?? [];
226
+ const otherWraps = vault?.masterKeyWraps.filter((w) => w.method !== "password" && w.method !== "recovery") ?? [];
227
+
228
+ async function handleRemove(wrap: MasterKeyWrap) {
229
+ const key = `${wrap.method}:${wrap.methodId ?? ""}`;
230
+ setRemovingMethod(key);
231
+ setActionError(null);
232
+ try {
233
+ await removeMasterKeyWrap(mailboxUid!, wrap.method, wrap.methodId);
234
+ await loadVault();
235
+ } catch (err) {
236
+ setActionError(err instanceof ApiRequestError ? err.message : "Could not remove this unlock method.");
237
+ } finally {
238
+ setRemovingMethod(null);
239
+ }
240
+ }
241
+
242
+ async function handleAddPassword(e: FormEvent) {
243
+ e.preventDefault();
244
+ if (newPassword.length < MIN_PASSWORD_LENGTH) {
245
+ setActionError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
246
+ return;
247
+ }
248
+ if (newPassword !== confirmNewPassword) {
249
+ setActionError("Passwords do not match.");
250
+ return;
251
+ }
252
+ setActionError(null);
253
+ setAddingPassword(true);
254
+ try {
255
+ // Only reachable once `unlocked` is defined - `EncryptionGate` guarantees this mailbox is
256
+ // unlocked before `EncryptionContent` ever mounts.
257
+ const wrap = await buildPasswordWrap(mailboxUid!, unlocked!.masterKey, newPassword);
258
+ await addMasterKeyWrap(mailboxUid!, wrap);
259
+ setNewPassword("");
260
+ setConfirmNewPassword("");
261
+ await loadVault();
262
+ } catch (err) {
263
+ setActionError(err instanceof ApiRequestError ? err.message : "Could not add this password.");
264
+ } finally {
265
+ setAddingPassword(false);
266
+ }
267
+ }
268
+
269
+ async function handleRegenerateRecoveryCodes() {
270
+ setActionError(null);
271
+ setRegenerating(true);
272
+ try {
273
+ for (const wrap of recoveryWraps) {
274
+ await removeMasterKeyWrap(mailboxUid!, "recovery", wrap.methodId);
275
+ }
276
+ const { wraps, codes } = await buildRecoveryWraps(mailboxUid!, unlocked!.masterKey);
277
+ for (const wrap of wraps) {
278
+ await addMasterKeyWrap(mailboxUid!, wrap);
279
+ }
280
+ setRecoveryCodesReason("regenerate");
281
+ setNewRecoveryCodes(codes);
282
+ setCodesSaved(false);
283
+ await loadVault();
284
+ } catch (err) {
285
+ setActionError(err instanceof ApiRequestError ? err.message : "Could not regenerate recovery codes.");
286
+ } finally {
287
+ setRegenerating(false);
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Real revocation for a captured wrap (`keyvaultApi.ts`'s `rekey()` - see that function's own doc
293
+ * comment): re-wraps this mailbox's already-unlocked private keys under a brand new master key
294
+ * (`rewrapPrivateKeysUnderNewMasterKey()`), wraps that new MK under a freshly entered password and a
295
+ * fresh set of recovery codes, and atomically replaces the vault - the enrolled keypair/certificate
296
+ * itself is unchanged (restapi's own `rekey()` rejects anything else), only how it's protected.
297
+ * Every *other* unlock method this mailbox had (a second password, a passkey, an old set of recovery
298
+ * codes) stops working the instant this succeeds, since `rekey()` replaces `masterKeyWraps` wholesale
299
+ * - the whole point, for a captured-wrap scenario where it's unclear which method was compromised.
300
+ */
301
+ async function handleRotateKeys(e: FormEvent) {
302
+ e.preventDefault();
303
+ if (rotationPassword.length < MIN_PASSWORD_LENGTH) {
304
+ setActionError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
305
+ return;
306
+ }
307
+ if (rotationPassword !== rotationConfirmPassword) {
308
+ setActionError("Passwords do not match.");
309
+ return;
310
+ }
311
+ setActionError(null);
312
+ setEscrowError(null);
313
+ setRotating(true);
314
+ try {
315
+ // Only reachable once `unlocked` is defined - see `handleAddPassword`'s identical note.
316
+ const { mk, wrappedKeys } = await rewrapPrivateKeysUnderNewMasterKey(mailboxUid!, unlocked!);
317
+ const passwordWrap = await buildPasswordWrap(mailboxUid!, mk, rotationPassword);
318
+ const { wraps: newRecoveryWraps, codes } = await buildRecoveryWraps(mailboxUid!, mk);
319
+ await rekey(mailboxUid!, { wrappedKeys, masterKeyWraps: [passwordWrap, ...newRecoveryWraps], keys: mailbox.keys ?? [] });
320
+
321
+ // restapi's own rekey() can never accept a fresh escrow wrap (its validateMasterKeyWrap()
322
+ // always passes allowEscrow: false there) - it preserves this mailbox's existing escrow wrap
323
+ // verbatim instead, which now encrypts a master key nobody has any longer. Re-wrap it
324
+ // separately, via the same addMasterKeyWrap() path "Add escrow protection" above already
325
+ // uses, so rotating keys for an unrelated reason (lost device, password hygiene) doesn't
326
+ // silently drop real escrow coverage while this page keeps claiming it's still active. A
327
+ // failure here is reported via escrowError, not as a rotation failure - the rotation itself
328
+ // (password/recovery codes) already succeeded by this point and must not be rolled back for
329
+ // an escrow-specific hiccup the user can retry independently.
330
+ if (hasEscrowWrap) {
331
+ try {
332
+ const escrowInfo = await getEscrowInfo(mailboxUid!);
333
+ const escrowWrap = await buildEscrowWrap(mk, escrowInfo.escrowScopeId, fromBase64(escrowInfo.publicKey.publicKey));
334
+ await addMasterKeyWrap(mailboxUid!, escrowWrap);
335
+ } catch (err) {
336
+ setEscrowError(
337
+ err instanceof ApiRequestError
338
+ ? err.message
339
+ : 'Your keys were rotated, but escrow protection could not be re-established automatically. Use "Add escrow protection" below to restore it.',
340
+ );
341
+ }
342
+ }
343
+
344
+ // Refreshes this session's own cached keys against the new MK, via the password we just set -
345
+ // the underlying private key material didn't change, but the stale MK in memory would silently
346
+ // build wrong future wraps (e.g. a second "Add a password") if left as-is.
347
+ await unlockWithPassword(mailboxUid!, mailbox.keys ?? [], rotationPassword);
348
+ setRotationPassword("");
349
+ setRotationConfirmPassword("");
350
+ setRecoveryCodesReason("rotate");
351
+ setNewRecoveryCodes(codes);
352
+ setCodesSaved(false);
353
+ await loadVault();
354
+ } catch (err) {
355
+ setActionError(err instanceof ApiRequestError ? err.message : "Could not rotate your encryption keys.");
356
+ } finally {
357
+ setRotating(false);
358
+ }
359
+ }
360
+
361
+ function handleDestroyKeysNow() {
362
+ destroyUnlockedKeys(mailboxUid);
363
+ setDestroyed(true);
364
+ }
365
+
366
+ if (destroyed) {
367
+ return (
368
+ <div className="flex-1 min-w-0 overflow-y-auto p-6">
369
+ <div className="max-w-xl">
370
+ <Alert>
371
+ Your encryption keys have been removed from this session. Reload the page (or open Mail
372
+ again) to unlock them when you need to read or send encrypted mail.
373
+ </Alert>
374
+ </div>
375
+ </div>
376
+ );
377
+ }
378
+
379
+ if (newRecoveryCodes) {
380
+ return (
381
+ <div className="flex-1 min-w-0 overflow-y-auto p-6">
382
+ <div className="max-w-xl">
383
+ <h1 className="text-lg font-bold tracking-tight mb-1">Save your new recovery codes</h1>
384
+ <p className="text-sm text-text-muted mb-4">
385
+ {recoveryCodesReason === "rotate"
386
+ ? "Your keys have been rotated - every previous unlock method (password, recovery codes, or anything else on file) has stopped working. "
387
+ : "Your old recovery codes no longer work. "}
388
+ If you lose your password, these new codes are the only way to recover your encrypted mail.
389
+ Each code can be used once. Store them somewhere safe — they will not be shown again.
390
+ </p>
391
+ <ul className="grid grid-cols-2 gap-2 mb-5 font-mono text-sm">
392
+ {newRecoveryCodes.map((code) => (
393
+ <li key={code} className="bg-surface-alt rounded-sm py-1.5 px-2 text-center">
394
+ {code}
395
+ </li>
396
+ ))}
397
+ </ul>
398
+ <label className="flex items-center gap-2 text-sm mb-4">
399
+ <input type="checkbox" checked={codesSaved} onChange={(e) => setCodesSaved(e.target.checked)} />
400
+ I have saved these recovery codes in a safe place.
401
+ </label>
402
+ <Button type="button" disabled={!codesSaved} onClick={() => setNewRecoveryCodes(null)} className="!w-auto">
403
+ Done
404
+ </Button>
405
+ </div>
406
+ </div>
407
+ );
408
+ }
409
+
410
+ return (
411
+ <div className="flex-1 min-w-0 overflow-y-auto p-6">
412
+ <div className="max-w-xl flex flex-col gap-6">
413
+ <div>
414
+ <h1 className="text-lg font-bold tracking-tight mb-1">Encryption</h1>
415
+ <p className="text-sm text-text-muted">
416
+ Manage how {mailbox.displayName} unlocks its encryption keys on this and other devices.
417
+ </p>
418
+ </div>
419
+
420
+ {loadError && <Alert>{loadError}</Alert>}
421
+ {actionError && <Alert>{actionError}</Alert>}
422
+
423
+ <div>
424
+ <h2 className="text-sm font-semibold mb-2">Encryption keys</h2>
425
+ {displayedKeys.length > 0 ? (
426
+ <ul className="flex flex-col gap-1 text-sm">
427
+ {displayedKeys.map((key) => (
428
+ <li key={key.fingerprint} className="font-mono text-xs">
429
+ {key.useType === "sign" ? "Signing" : "Encryption"} key: {key.fingerprint}
430
+ {key.revokedAt && <span className="text-danger"> (revoked)</span>}
431
+ </li>
432
+ ))}
433
+ </ul>
434
+ ) : (
435
+ <p className="text-sm text-text-muted">No keys enrolled yet.</p>
436
+ )}
437
+ </div>
438
+
439
+ <div>
440
+ <h2 className="text-sm font-semibold mb-2">Digital signatures</h2>
441
+ {activeSigningKey ? (
442
+ <p className="text-sm text-text-muted">
443
+ Enabled — outgoing mail from this mailbox is signed with a publicly-trusted
444
+ certificate.
445
+ </p>
446
+ ) : signingStatus === "pending" ? (
447
+ <p className="text-sm text-text-muted">
448
+ Requested — a public certificate authority issues this via an automated email
449
+ exchange, which can take a few minutes. You can leave this page; it finishes in
450
+ the background and takes effect automatically once issued.
451
+ </p>
452
+ ) : (
453
+ <div className="flex flex-col gap-2">
454
+ <p className="text-xs text-text-muted">
455
+ Lets recipients verify that mail from this mailbox is genuinely from you.
456
+ Optional — encryption already works without it.
457
+ </p>
458
+ {signingError && <Alert>{signingError}</Alert>}
459
+ <Button
460
+ type="button"
461
+ variant="secondary"
462
+ className="!w-auto"
463
+ loading={signingStatus === "enrolling"}
464
+ disabled={signingStatus === "enrolling"}
465
+ onClick={handleEnrollSigning}
466
+ >
467
+ Enable digital signatures
468
+ </Button>
469
+ </div>
470
+ )}
471
+ </div>
472
+
473
+ {mailbox.escrowScopeId && (
474
+ <div>
475
+ <h2 className="text-sm font-semibold mb-2">Escrow</h2>
476
+ {/* Rendered regardless of hasEscrowWrap - a rotation-triggered re-wrap failure
477
+ (see handleRotateKeys) leaves the mailbox's old, now-stale escrow wrap in place,
478
+ so hasEscrowWrap alone can't be trusted to hide this. */}
479
+ {escrowError && <Alert>{escrowError}</Alert>}
480
+ {hasEscrowWrap ? (
481
+ <p className="text-sm text-text-muted">
482
+ This mailbox is under legal/compliance escrow — an authorized holder in your
483
+ organization can recover its encrypted mail if needed. This does not weaken
484
+ protection against anyone else.
485
+ </p>
486
+ ) : (
487
+ <div className="flex flex-col gap-2">
488
+ <p className="text-xs text-text-muted">
489
+ Your organization has assigned this mailbox to an escrow scope, but nothing
490
+ has been protected yet — an authorized holder cannot recover this mailbox's
491
+ encrypted mail until you complete this step. This does not weaken protection
492
+ against anyone else.
493
+ </p>
494
+ <Button
495
+ type="button"
496
+ variant="secondary"
497
+ className="!w-auto"
498
+ loading={wrappingEscrow}
499
+ disabled={wrappingEscrow}
500
+ onClick={handleWrapEscrow}
501
+ >
502
+ Add escrow protection
503
+ </Button>
504
+ </div>
505
+ )}
506
+ </div>
507
+ )}
508
+
509
+ <div>
510
+ <h2 className="text-sm font-semibold mb-2">Unlock methods</h2>
511
+ <p className="text-xs text-text-muted mb-3">
512
+ Removing a method here stops it from being usable to unlock this mailbox going forward,
513
+ but it is <strong>not</strong> full revocation — anyone who already captured a wrapped
514
+ copy and knows its secret could still use it. For real revocation (e.g. after a lost
515
+ device), rotate your keys entirely below instead.
516
+ </p>
517
+ {vault && vault.masterKeyWraps.length > 0 ? (
518
+ <ul className="flex flex-col gap-2">
519
+ {[...passwordWraps, ...recoveryWraps, ...otherWraps].map((wrap) => {
520
+ const key = `${wrap.method}:${wrap.methodId ?? ""}`;
521
+ return (
522
+ <li key={key} className="flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm">
523
+ <span>{METHOD_LABELS[wrap.method] ?? wrap.method}</span>
524
+ {wrap.method !== "escrow" && (
525
+ <Button
526
+ type="button"
527
+ variant="text"
528
+ className="!w-auto text-danger"
529
+ loading={removingMethod === key}
530
+ disabled={removingMethod !== null}
531
+ onClick={() => handleRemove(wrap)}
532
+ >
533
+ Remove
534
+ </Button>
535
+ )}
536
+ </li>
537
+ );
538
+ })}
539
+ </ul>
540
+ ) : (
541
+ <p className="text-sm text-text-muted">No unlock methods on file.</p>
542
+ )}
543
+ </div>
544
+
545
+ <form onSubmit={handleAddPassword} className="flex flex-col gap-2">
546
+ <h2 className="text-sm font-semibold">Add a password</h2>
547
+ <input
548
+ type="password"
549
+ aria-label="New password"
550
+ placeholder="New password"
551
+ className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
552
+ value={newPassword}
553
+ onChange={(e) => setNewPassword(e.target.value)}
554
+ disabled={addingPassword}
555
+ autoComplete="new-password"
556
+ />
557
+ <input
558
+ type="password"
559
+ aria-label="Confirm new password"
560
+ placeholder="Confirm new password"
561
+ className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
562
+ value={confirmNewPassword}
563
+ onChange={(e) => setConfirmNewPassword(e.target.value)}
564
+ disabled={addingPassword}
565
+ autoComplete="new-password"
566
+ />
567
+ <div>
568
+ <Button type="submit" loading={addingPassword} disabled={addingPassword} className="!w-auto">
569
+ Add password
570
+ </Button>
571
+ </div>
572
+ </form>
573
+
574
+ <div>
575
+ <h2 className="text-sm font-semibold mb-2">Recovery codes</h2>
576
+ <p className="text-xs text-text-muted mb-3">
577
+ Regenerating replaces all of your existing recovery codes — old ones stop working
578
+ immediately.
579
+ </p>
580
+ <Button
581
+ type="button"
582
+ variant="secondary"
583
+ loading={regenerating}
584
+ disabled={regenerating}
585
+ onClick={handleRegenerateRecoveryCodes}
586
+ className="!w-auto"
587
+ >
588
+ Regenerate recovery codes
589
+ </Button>
590
+ </div>
591
+
592
+ <form onSubmit={handleRotateKeys} className="flex flex-col gap-2 border-t border-border pt-6">
593
+ <h2 className="text-sm font-semibold">Rotate keys</h2>
594
+ <p className="text-xs text-text-muted mb-1">
595
+ Real revocation, for when a device or an unlock method may have been compromised.
596
+ Re-protects your existing encryption key under a brand new master key - your signing/
597
+ encryption keypair itself doesn&rsquo;t change, so mail you&rsquo;ve already sent or
598
+ received still decrypts normally. Every current unlock method (password, recovery codes,
599
+ and anything else on file) stops working immediately; you&rsquo;ll set a new password and
600
+ get new recovery codes below.
601
+ </p>
602
+ <input
603
+ type="password"
604
+ aria-label="New password for rotated keys"
605
+ placeholder="New password"
606
+ className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
607
+ value={rotationPassword}
608
+ onChange={(e) => setRotationPassword(e.target.value)}
609
+ disabled={rotating}
610
+ autoComplete="new-password"
611
+ />
612
+ <input
613
+ type="password"
614
+ aria-label="Confirm new password for rotated keys"
615
+ placeholder="Confirm new password"
616
+ className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
617
+ value={rotationConfirmPassword}
618
+ onChange={(e) => setRotationConfirmPassword(e.target.value)}
619
+ disabled={rotating}
620
+ autoComplete="new-password"
621
+ />
622
+ <div>
623
+ <Button type="submit" variant="secondary" className="!w-auto text-danger" loading={rotating} disabled={rotating}>
624
+ Rotate keys now
625
+ </Button>
626
+ </div>
627
+ </form>
628
+
629
+ <div>
630
+ <h2 className="text-sm font-semibold mb-2">Session timeout</h2>
631
+ <p className="text-xs text-text-muted mb-3">
632
+ Automatically destroys your unlocked keys on this device after this much time with no
633
+ activity anywhere in the app - not just Mail or Settings. Applies the next time you open
634
+ or reload the app.
635
+ </p>
636
+ <select
637
+ aria-label="Session timeout"
638
+ className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
639
+ value={idleTimeoutMinutes}
640
+ onChange={handleIdleTimeoutChange}
641
+ >
642
+ {IDLE_TIMEOUT_OPTIONS_MINUTES.map((minutes) => (
643
+ <option key={minutes} value={minutes}>
644
+ {idleTimeoutLabel(minutes)}
645
+ </option>
646
+ ))}
647
+ </select>
648
+ </div>
649
+
650
+ <div>
651
+ <h2 className="text-sm font-semibold mb-2">This session</h2>
652
+ <p className="text-xs text-text-muted mb-3">
653
+ Removes your unlocked keys from this browser tab&rsquo;s memory right now, without
654
+ affecting any other device. You&rsquo;ll be asked to unlock again the next time you read
655
+ or send encrypted mail here.
656
+ </p>
657
+ <Button type="button" variant="secondary" className="!w-auto text-danger" onClick={handleDestroyKeysNow}>
658
+ Destroy keys on this device now
659
+ </Button>
660
+ </div>
661
+ </div>
662
+ </div>
663
+ );
664
+ }