@rapidmx/web-client 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/data-requests/index.tsx +480 -485
  3. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  4. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  5. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  6. package/apps/admin/index.tsx +19 -1
  7. package/apps/admin/ingest-queue/index.tsx +44 -5
  8. package/apps/admin/mailboxes/[uid].tsx +211 -184
  9. package/apps/admin/quarantine/index.tsx +46 -14
  10. package/apps/admin/transport-rules/[uid].tsx +30 -1
  11. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  12. package/apps/admin/transport-rules/index.tsx +24 -6
  13. package/apps/admin/transport-rules/new/index.tsx +30 -1
  14. package/apps/escrow/_layout.tsx +2 -3
  15. package/apps/escrow/audit-log/index.tsx +196 -168
  16. package/apps/escrow/matters/[uid].tsx +621 -521
  17. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  18. package/apps/shared/components/admin/layout/AdminShell.tsx +3 -1
  19. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  20. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  21. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  22. package/apps/shared/components/admin/settings/BrandingForm.tsx +15 -4
  23. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +18 -7
  24. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +10 -2
  25. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +6 -0
  26. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +45 -12
  27. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -595
  28. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -98
  29. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +49 -13
  30. package/apps/shared/components/admin/setup/SetupWizard.tsx +150 -34
  31. package/apps/shared/components/admin/signOut.ts +79 -0
  32. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  33. package/apps/shared/components/calendar/EventModal.tsx +717 -549
  34. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  35. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  36. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  37. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  38. package/apps/shared/components/calendar/allDay.ts +124 -0
  39. package/apps/shared/components/calendar/layout/CalendarShell.tsx +1 -1
  40. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  41. package/apps/shared/components/contacts/ContactForm.tsx +377 -348
  42. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  43. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  44. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  45. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  46. package/apps/shared/components/layout/AppShell.tsx +87 -15
  47. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  48. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +389 -298
  49. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  50. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  51. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -128
  52. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  53. package/apps/shared/components/mail/MessageDetailPane.tsx +1318 -630
  54. package/apps/shared/components/mail/compose/ComposeContext.tsx +20 -15
  55. package/apps/shared/components/mail/compose/ComposeWindow.tsx +854 -132
  56. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  57. package/apps/shared/components/mail/layout/MailShell.tsx +12 -2
  58. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  59. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  60. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  61. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  62. package/apps/shared/mail/listAllPages.ts +39 -0
  63. package/apps/shared/search/LocalIndexLifecycle.tsx +64 -55
  64. package/apps/shared/search/localIndexBuilder.ts +481 -179
  65. package/apps/shared/search/localIndexRpcClient.ts +228 -28
  66. package/apps/shared/search/localIndexSchema.ts +15 -6
  67. package/apps/shared/search/localIndexStorage.ts +98 -0
  68. package/apps/shared/search/localIndexVFS.ts +32 -1
  69. package/apps/shared/search/localIndexWorker.ts +648 -186
  70. package/apps/www/calendar/index.tsx +438 -417
  71. package/apps/www/contacts/[uid].tsx +21 -2
  72. package/apps/www/contacts/index.tsx +116 -12
  73. package/apps/www/index.tsx +1314 -1041
  74. package/apps/www/messages/[uid].tsx +18 -6
  75. package/apps/www/settings/auto-reply/index.tsx +134 -129
  76. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  77. package/apps/www/settings/encryption/index.tsx +739 -216
  78. package/apps/www/settings/filters/[uid].tsx +171 -155
  79. package/apps/www/settings/filters/new/index.tsx +8 -1
  80. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  81. package/apps/www/settings/privacy/index.tsx +494 -436
  82. package/apps/www/settings/read-receipts/index.tsx +148 -125
  83. package/apps/www/settings/sharing/index.tsx +17 -14
  84. package/apps/www/tasks/index.tsx +638 -579
  85. package/dist/apps/admin/audit-log/index.js +38 -8
  86. package/dist/apps/admin/data-requests/index.js +52 -72
  87. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  88. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  89. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  90. package/dist/apps/admin/index.js +4 -1
  91. package/dist/apps/admin/ingest-queue/index.js +24 -6
  92. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  93. package/dist/apps/admin/quarantine/index.js +20 -11
  94. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  95. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  96. package/dist/apps/admin/transport-rules/index.js +22 -6
  97. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  98. package/dist/apps/escrow/_layout.js +3 -3
  99. package/dist/apps/escrow/audit-log/index.js +26 -1
  100. package/dist/apps/escrow/matters/[uid].js +77 -42
  101. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  102. package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -1
  103. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  104. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  105. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  106. package/dist/apps/shared/components/admin/settings/BrandingForm.js +15 -4
  107. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +13 -7
  108. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +7 -2
  109. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +6 -0
  110. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +34 -12
  111. package/dist/apps/shared/components/admin/settings/PluginsManager.js +390 -78
  112. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +40 -11
  113. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +29 -13
  114. package/dist/apps/shared/components/admin/setup/SetupWizard.js +87 -21
  115. package/dist/apps/shared/components/admin/signOut.js +75 -0
  116. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  117. package/dist/apps/shared/components/calendar/EventModal.js +142 -21
  118. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  119. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  120. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  121. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  122. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  123. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +1 -1
  124. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  125. package/dist/apps/shared/components/contacts/ContactForm.js +32 -10
  126. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  127. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  128. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  129. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  130. package/dist/apps/shared/components/layout/AppShell.js +83 -14
  131. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  132. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +49 -12
  133. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  134. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  135. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +73 -14
  136. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  137. package/dist/apps/shared/components/mail/MessageDetailPane.js +441 -36
  138. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  139. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +699 -128
  140. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  141. package/dist/apps/shared/components/mail/layout/MailShell.js +12 -3
  142. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  143. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  144. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  145. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  146. package/dist/apps/shared/mail/listAllPages.js +25 -0
  147. package/dist/apps/shared/search/LocalIndexLifecycle.js +58 -54
  148. package/dist/apps/shared/search/localIndexBuilder.js +292 -37
  149. package/dist/apps/shared/search/localIndexRpcClient.js +188 -25
  150. package/dist/apps/shared/search/localIndexSchema.js +12 -6
  151. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  152. package/dist/apps/shared/search/localIndexVFS.js +68 -40
  153. package/dist/apps/shared/search/localIndexWorker.js +519 -173
  154. package/dist/apps/www/calendar/index.js +28 -10
  155. package/dist/apps/www/contacts/[uid].js +13 -2
  156. package/dist/apps/www/contacts/index.js +80 -10
  157. package/dist/apps/www/index.js +339 -108
  158. package/dist/apps/www/messages/[uid].js +18 -6
  159. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  160. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  161. package/dist/apps/www/settings/encryption/index.js +514 -97
  162. package/dist/apps/www/settings/filters/[uid].js +22 -7
  163. package/dist/apps/www/settings/filters/new/index.js +8 -2
  164. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  165. package/dist/apps/www/settings/privacy/index.js +57 -22
  166. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  167. package/dist/apps/www/settings/sharing/index.js +7 -11
  168. package/dist/apps/www/tasks/index.js +50 -10
  169. package/package.json +2 -2
@@ -1,29 +1,160 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  ///////////////////////////////////////////////////////////////////////////////
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useEffect, useState } from "react";
7
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";
8
+ import { addMasterKeyWrap, cancelSignEnrollment, checkSignEnrollmentStatus, findActivePublicKey, getEscrowInfo, getKeyVault, rekey, removeMasterKeyWrap, startSignEnrollment, } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
9
+ import { ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, SIGNING_PRIVATE_KEY_AAD_PURPOSE, destroyUnlockedKeys, getUnlockedKeys, unlockWithPassword, } from "@rapidmx/react-shared/crypto/keySession.js";
10
10
  import { IDLE_TIMEOUT_OPTIONS_MINUTES, getIdleTimeoutMinutes, setIdleTimeoutMinutes } from "@rapidmx/react-shared/crypto/idleTimeout.js";
11
11
  import { LOCAL_INDEX_SIZE_OPTIONS, getDefaultLocalIndexByteBudget, getLocalIndexByteBudget, setLocalIndexByteBudget, } from "../../../shared/search/localIndexSizePreference.js";
12
12
  import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
13
- import { buildAad, sealWithKey } from "@rapidmx/react-shared/crypto/masterKey.js";
13
+ import { KeysLockedError, buildAad, generateMasterKey, openWithKey, sealWithKey } from "@rapidmx/react-shared/crypto/masterKey.js";
14
14
  import { buildEscrowWrap, buildPasswordWrap, buildRecoveryWraps } from "@rapidmx/react-shared/crypto/masterKeyWraps.js";
15
- import { rewrapPrivateKeysUnderNewMasterKey } from "@rapidmx/react-shared/crypto/keyRotation.js";
16
15
  import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "@rapidmx/react-shared/crypto/keys.js";
17
16
  import { getMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
18
17
  import SettingsShell, { useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
19
18
  import KeyEnrollmentGate from "../../../shared/components/layout/KeyEnrollmentGate.js";
19
+ import { useUnlockPrompt } from "../../../shared/components/layout/UnlockPromptProvider.js";
20
20
  import { destroyLocalIndex } from "../../../shared/search/localIndexRpcClient.js";
21
21
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
22
22
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
23
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
23
24
  const MIN_PASSWORD_LENGTH = 8;
25
+ /** Mirrors restapi's `BaseKeyVaultRoute` `MAX_MASTER_KEY_WRAPS` - `addMasterKeyWrap()` refuses a vault
26
+ * already holding this many wraps (escrow included). */
27
+ export const MAX_MASTER_KEY_WRAPS = 20;
28
+ const KEYS_LOCKED_MESSAGE = "Your encryption keys were locked before this could finish. Unlock them and try again.";
29
+ /** Thrown inside `handleRotateKeys()` when the escrow wrap of the new master key can't be built - the rotation is
30
+ * then abandoned before `rekey()`. */
31
+ class EscrowWrapUnavailableError extends Error {
32
+ constructor(cause) {
33
+ super("Could not prepare escrow protection for the new master key.");
34
+ this.cause = cause;
35
+ }
36
+ }
37
+ /** Thrown by `rewrapVaultPrivateKeys()` when a vault entry can't be opened with the current master key. */
38
+ class UncoveredVaultKeysError extends Error {
39
+ constructor(fingerprints) {
40
+ super(`Could not open ${fingerprints.length} wrapped private key(s).`);
41
+ this.fingerprints = fingerprints;
42
+ }
43
+ }
44
+ /** Thrown when this session's master key no longer opens the vault - another device rotated the keys since this
45
+ * session unlocked. Anything wrapped or sealed under it would be unusable (and a signing key sealed under it would
46
+ * be installed at the vault's current generation, blocking rotation), so nothing is written. */
47
+ class StaleSessionKeysError extends Error {
48
+ constructor() {
49
+ super("This session's master key no longer opens the key vault.");
50
+ }
51
+ }
52
+ const STALE_KEYS_MESSAGE = "Your encryption keys were changed on another device (for example, rotated), so the copy unlocked in this session no longer works. Nothing was changed. Unlock again with your current password, then try again.";
53
+ function wrappedKeyAad(mailboxUid, entry) {
54
+ return buildAad(mailboxUid, entry.useType === "sign" ? SIGNING_PRIVATE_KEY_AAD_PURPOSE : ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE);
55
+ }
56
+ /**
57
+ * Whether `masterKey` opens at least one of `vault`'s wrapped private keys - i.e. it is still the vault's current
58
+ * master key. A vault with no wrapped keys has nothing to check against and passes. The opened key bytes are zeroed
59
+ * straight away. `KeysLockedError` (a destroyed master key) propagates.
60
+ */
61
+ async function masterKeyOpensVault(mailboxUid, masterKey, vault) {
62
+ if (vault.wrappedKeys.length === 0) {
63
+ return true;
64
+ }
65
+ for (const entry of vault.wrappedKeys) {
66
+ try {
67
+ const raw = await openWithKey(masterKey, entry, wrappedKeyAad(mailboxUid, entry));
68
+ raw.fill(0);
69
+ return true;
70
+ }
71
+ catch (err) {
72
+ if (err instanceof KeysLockedError) {
73
+ throw err;
74
+ }
75
+ }
76
+ }
77
+ return false;
78
+ }
79
+ /**
80
+ * Re-seals EVERY private key in the vault (not just the ones this session imported - an inactive or
81
+ * newly issued key would otherwise be silently dropped by `rekey()`, which replaces `wrappedKeys`
82
+ * wholesale) under a brand new master key. Opens each entry with `currentMasterKey` first and refuses
83
+ * (`UncoveredVaultKeysError`) if any can't be opened, before generating anything. Plaintext key bytes
84
+ * are zeroed as soon as they've been re-sealed. `KeysLockedError` (a destroyed master key) propagates.
85
+ */
86
+ async function rewrapVaultPrivateKeys(mailboxUid, currentMasterKey, wrappedKeys) {
87
+ const aadFor = (entry) => wrappedKeyAad(mailboxUid, entry);
88
+ const opened = [];
89
+ try {
90
+ const failed = [];
91
+ for (const entry of wrappedKeys) {
92
+ try {
93
+ opened.push({ entry, raw: await openWithKey(currentMasterKey, entry, aadFor(entry)) });
94
+ }
95
+ catch (err) {
96
+ if (err instanceof KeysLockedError) {
97
+ throw err;
98
+ }
99
+ failed.push(entry.fingerprint);
100
+ }
101
+ }
102
+ if (failed.length > 0) {
103
+ throw new UncoveredVaultKeysError(failed);
104
+ }
105
+ const mk = generateMasterKey();
106
+ const rewrapped = [];
107
+ for (const { entry, raw } of opened) {
108
+ const sealed = await sealWithKey(mk, raw, aadFor(entry));
109
+ rewrapped.push({
110
+ ciphertext: sealed.ciphertext,
111
+ nonce: sealed.nonce,
112
+ algorithm: "AES-256-GCM",
113
+ fingerprint: entry.fingerprint,
114
+ useType: entry.useType,
115
+ });
116
+ }
117
+ return { mk, wrappedKeys: rewrapped };
118
+ }
119
+ finally {
120
+ for (const { raw } of opened) {
121
+ raw.fill(0);
122
+ }
123
+ }
124
+ }
24
125
  // RFC 8823 ACME issuance is a real email round-trip with a public CA - "likely minutes," not seconds -
25
126
  // so this polls infrequently rather than hammering the endpoint.
26
127
  const SIGNING_ENROLLMENT_POLL_INTERVAL_MS = 15000;
128
+ /**
129
+ * A started signing enrollment's id, per mailbox. Kept only so a reload can ask the server
130
+ * (`checkSignEnrollmentStatus()`) whether that enrollment is still pending: a rotation while it is pending
131
+ * would strand the enrollment's already-submitted private key under a master key that no longer exists. The
132
+ * stored id is never trusted on its own - the server's answer decides the state, and restapi's own `rekey()`
133
+ * refuses (409) a rotation during an enrollment this browser never saw (e.g. one started on another device).
134
+ */
135
+ const SIGN_ENROLLMENT_STORAGE_PREFIX = "rapidmx.signEnrollment.";
136
+ function readStoredSignEnrollment(mailboxUid) {
137
+ try {
138
+ return localStorage.getItem(SIGN_ENROLLMENT_STORAGE_PREFIX + mailboxUid);
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ }
144
+ function storeSignEnrollment(mailboxUid, enrollmentId) {
145
+ try {
146
+ if (enrollmentId) {
147
+ localStorage.setItem(SIGN_ENROLLMENT_STORAGE_PREFIX + mailboxUid, enrollmentId);
148
+ }
149
+ else {
150
+ localStorage.removeItem(SIGN_ENROLLMENT_STORAGE_PREFIX + mailboxUid);
151
+ }
152
+ }
153
+ catch {
154
+ // Storage blocked - restapi's own 409 on rekey still guards a rotation.
155
+ }
156
+ }
157
+ const ROTATION_CONFLICT_MESSAGE = "Your keys were not rotated because the server reported a conflicting change. If a signing certificate enrollment is still in progress for this mailbox (it may have been started on another device), wait for it to finish, then try again. If this mailbox is under escrow, its escrow protection may have changed meanwhile - reload this page and try again. Nothing was changed.";
27
158
  const METHOD_LABELS = {
28
159
  password: "Password",
29
160
  passkey: "Passkey",
@@ -40,7 +171,7 @@ function idleTimeoutLabel(minutes) {
40
171
  return `${minutes} minutes`;
41
172
  }
42
173
  export default function SettingsEncryptionPage(props) {
43
- return (_jsx(SettingsShell, { ...props, active: "encryption", children: _jsx(EncryptionGate, {}) }));
174
+ return (_jsx(SettingsShell, { ...props, active: "encryption", children: _jsx(EncryptionGate, { userUid: props.userUid, impersonating: props.impersonating }) }));
44
175
  }
45
176
  /**
46
177
  * Unlike `MailShell`, `SettingsShell` doesn't already gate its children behind `KeyEnrollmentGate` (a
@@ -49,17 +180,22 @@ export default function SettingsEncryptionPage(props) {
49
180
  * it renders. See `KeyEnrollmentGate`'s own doc comment for why it's safe to mount more than once (it
50
181
  * short-circuits to `children` immediately for a mailbox already unlocked elsewhere this session).
51
182
  */
52
- function EncryptionGate() {
183
+ function EncryptionGate({ userUid, impersonating }) {
53
184
  const { mailboxUid, mailboxes } = useSettingsShell();
54
185
  const mailbox = mailboxes.find((mb) => mb.uid === mailboxUid);
55
- return (_jsx(KeyEnrollmentGate, { mailboxUid: mailboxUid, mailboxAddress: mailbox.primarySmtpAddress, mailboxKeys: mailbox.keys, children: _jsx(EncryptionContent, {}) }));
186
+ // restapi only lets the mailbox's actual owner write its key vault (`requireMailboxOwner()`), so a
187
+ // delegate (or an admin impersonating the owner) never provisions keys here, and sees no write actions.
188
+ const isOwner = mailbox.ownerUserUid !== undefined && mailbox.ownerUserUid === userUid && !impersonating;
189
+ return (_jsx(KeyEnrollmentGate, { mailboxUid: mailboxUid, mailboxAddress: mailbox.primarySmtpAddress, mailboxKeys: mailbox.keys, canProvision: isOwner, children: _jsx(EncryptionContent, { canManageKeys: isOwner }) }));
56
190
  }
57
- function EncryptionContent() {
191
+ function EncryptionContent({ canManageKeys }) {
58
192
  const { mailboxUid, mailboxes } = useSettingsShell();
59
193
  const mailbox = mailboxes.find((mb) => mb.uid === mailboxUid);
194
+ const { requestUnlock } = useUnlockPrompt();
60
195
  const [vault, setVault] = useState(null);
61
196
  const [loadError, setLoadError] = useState(null);
62
197
  const [removingMethod, setRemovingMethod] = useState(null);
198
+ const [pendingRemoval, setPendingRemoval] = useState(null);
63
199
  const [actionError, setActionError] = useState(null);
64
200
  const [newPassword, setNewPassword] = useState("");
65
201
  const [confirmNewPassword, setConfirmNewPassword] = useState("");
@@ -72,43 +208,152 @@ function EncryptionContent() {
72
208
  const [recoveryCodesReason, setRecoveryCodesReason] = useState("regenerate");
73
209
  const [codesSaved, setCodesSaved] = useState(false);
74
210
  const [codesCopied, setCodesCopied] = useState(false);
211
+ // Shown on the "Save your new recovery codes" screen itself - a regeneration or rotation that only
212
+ // partly finished (e.g. some new codes saved but not all, or old codes that couldn't be removed) still
213
+ // lands there, since whatever codes did save must be shown now or never.
214
+ const [codesWarning, setCodesWarning] = useState(null);
215
+ // Set when a rotation succeeded but this session couldn't re-unlock under the new master key - the
216
+ // stale keys are destroyed, and the "keys removed" screen explains why.
217
+ const [rotationRelockReason, setRotationRelockReason] = useState(null);
75
218
  const [rotationPassword, setRotationPassword] = useState("");
76
219
  const [rotationConfirmPassword, setRotationConfirmPassword] = useState("");
77
220
  const [rotating, setRotating] = useState(false);
78
221
  const [destroyed, setDestroyed] = useState(false);
79
222
  const [idleTimeoutMinutes, setIdleTimeoutMinutesState] = useState(() => getIdleTimeoutMinutes());
80
223
  const [localIndexByteBudget, setLocalIndexByteBudgetState] = useState(() => getLocalIndexByteBudget());
81
- const localIndexDefaultByteBudgetLabel = LOCAL_INDEX_SIZE_OPTIONS.find((option) => option.bytes === getDefaultLocalIndexByteBudget())?.label ?? "500 MB";
224
+ // Both possible defaults (web and Electron) are themselves entries in `LOCAL_INDEX_SIZE_OPTIONS`.
225
+ const localIndexDefaultByteBudgetLabel = LOCAL_INDEX_SIZE_OPTIONS.find((option) => option.bytes === getDefaultLocalIndexByteBudget()).label;
82
226
  // `mailbox.keys` comes from `SettingsShell`'s one-time `listMailboxes()` fetch - once an ACME
83
227
  // enrollment issues, the server has installed a new signing key that fetch never saw. Only this
84
228
  // page refetches (via `getMailbox()`) to notice; `null` means "no fresher data yet, use mailbox.keys".
85
229
  const [refreshedKeys, setRefreshedKeys] = useState(null);
86
230
  const displayedKeys = refreshedKeys ?? mailbox.keys ?? [];
87
231
  const activeSigningKey = findActivePublicKey(displayedKeys, "sign");
88
- const [signingStatus, setSigningStatus] = useState("idle");
232
+ // "checking" = a stored enrollment id is being confirmed with the server. Rotation stays disabled unless
233
+ // this is "idle".
234
+ const [signingStatus, setSigningStatus] = useState(() => readStoredSignEnrollment(mailboxUid) ? "checking" : "idle");
89
235
  const [signingEnrollmentId, setSigningEnrollmentId] = useState(null);
90
236
  const [signingError, setSigningError] = useState(null);
237
+ const rotationBlockedBySigning = signingStatus !== "idle";
238
+ const [cancelingEnrollment, setCancelingEnrollment] = useState(false);
239
+ const [cancelEnrollmentError, setCancelEnrollmentError] = useState(null);
240
+ // restapi's rekey() drops every old escrow wrap and requires a fresh one for the new master key (see
241
+ // handleRotateKeys), so an escrow wrap in the vault always covers the current master key.
91
242
  const hasEscrowWrap = vault?.masterKeyWraps.some((w) => w.method === "escrow") ?? false;
92
243
  const [wrappingEscrow, setWrappingEscrow] = useState(false);
93
244
  const [escrowError, setEscrowError] = useState(null);
245
+ /**
246
+ * This mailbox's unlocked keys *right now* - never a copy read at render time, which a lock (idle
247
+ * timeout, another tab's sign-out) may since have destroyed. Prompts for the password when locked;
248
+ * rejects if the prompt is dismissed.
249
+ */
250
+ async function currentUnlockedKeys() {
251
+ const current = getUnlockedKeys(mailboxUid);
252
+ if (current && !current.destroyed) {
253
+ return current;
254
+ }
255
+ return requestUnlock(mailboxUid, displayedKeys);
256
+ }
257
+ /**
258
+ * `currentUnlockedKeys()`, checked against a freshly fetched vault before anything is wrapped or sealed under its
259
+ * master key: after a rotation on another device the session's master key is dead, and every write built from
260
+ * it would be too. Rejects with `StaleSessionKeysError` then. Resolves to the keys and that fresh vault.
261
+ */
262
+ async function verifiedUnlockedKeys() {
263
+ const current = await currentUnlockedKeys();
264
+ const freshVault = await getKeyVault(mailboxUid);
265
+ if (!(await masterKeyOpensVault(mailboxUid, current.masterKey, freshVault))) {
266
+ throw new StaleSessionKeysError();
267
+ }
268
+ return { current, freshVault };
269
+ }
270
+ /** Drops this session's out-of-date keys (and, as with every key destruction, the local index) and asks for the
271
+ * current password, so the next attempt works with the vault's real master key. */
272
+ function relockStaleKeys() {
273
+ destroyUnlockedKeys(mailboxUid);
274
+ void destroyLocalIndex(mailboxUid);
275
+ requestUnlock(mailboxUid, displayedKeys).catch(() => undefined);
276
+ }
277
+ function errorMessage(err, fallback) {
278
+ if (err instanceof KeysLockedError) {
279
+ return KEYS_LOCKED_MESSAGE;
280
+ }
281
+ if (err instanceof StaleSessionKeysError) {
282
+ relockStaleKeys();
283
+ return STALE_KEYS_MESSAGE;
284
+ }
285
+ return err instanceof ApiRequestError ? err.message : fallback;
286
+ }
94
287
  async function handleWrapEscrow() {
95
288
  setEscrowError(null);
96
289
  setWrappingEscrow(true);
97
290
  try {
98
- // Only reachable when mailbox.escrowScopeId is set (see the render guard below) and
99
- // `unlocked` is defined - see `handleAddPassword`'s identical note on the latter.
291
+ // Only reachable when mailbox.escrowScopeId is set (see the render guard below).
292
+ const { current } = await verifiedUnlockedKeys();
100
293
  const escrowInfo = await getEscrowInfo(mailboxUid);
101
- const wrap = await buildEscrowWrap(unlocked.masterKey, escrowInfo.escrowScopeId, fromBase64(escrowInfo.publicKey.publicKey));
294
+ const wrap = await buildEscrowWrap(current.masterKey, escrowInfo.escrowScopeId, fromBase64(escrowInfo.publicKey.publicKey));
102
295
  await addMasterKeyWrap(mailboxUid, wrap);
103
296
  await loadVault();
104
297
  }
105
298
  catch (err) {
106
- setEscrowError(err instanceof ApiRequestError ? err.message : "Could not add escrow protection for this mailbox.");
299
+ setEscrowError(errorMessage(err, "Could not add escrow protection for this mailbox."));
107
300
  }
108
301
  finally {
109
302
  setWrappingEscrow(false);
110
303
  }
111
304
  }
305
+ /** Applies a server-reported enrollment status - shared by the reload check and the poll below. */
306
+ async function applyEnrollmentResult(enrollmentId, result, isCancelled) {
307
+ if (result.status === "pending") {
308
+ setSigningEnrollmentId(enrollmentId);
309
+ setSigningStatus("pending");
310
+ return;
311
+ }
312
+ storeSignEnrollment(mailboxUid, null);
313
+ setSigningStatus("idle");
314
+ setSigningEnrollmentId(null);
315
+ if (result.status === "failed") {
316
+ setSigningError(result.error ?? "Signing certificate enrollment failed.");
317
+ return;
318
+ }
319
+ try {
320
+ const refreshed = await getMailbox(mailboxUid);
321
+ if (!isCancelled()) {
322
+ setRefreshedKeys(refreshed.keys ?? []);
323
+ }
324
+ }
325
+ catch {
326
+ // The new key shows on the next load - the enrollment itself is finished either way.
327
+ }
328
+ }
329
+ // Confirms an enrollment started before a reload with the server before rotation is offered again.
330
+ useEffect(() => {
331
+ const storedId = readStoredSignEnrollment(mailboxUid);
332
+ if (!storedId) {
333
+ return;
334
+ }
335
+ let cancelled = false;
336
+ setSigningStatus("checking");
337
+ checkSignEnrollmentStatus(mailboxUid, storedId)
338
+ .then((result) => (cancelled ? undefined : applyEnrollmentResult(storedId, result, () => cancelled)))
339
+ .catch((err) => {
340
+ if (cancelled) {
341
+ return;
342
+ }
343
+ if (err instanceof ApiRequestError && err.status === 404) {
344
+ // The server no longer knows this enrollment, so nothing is pending.
345
+ storeSignEnrollment(mailboxUid, null);
346
+ setSigningStatus("idle");
347
+ return;
348
+ }
349
+ // Unknown (e.g. a network error): assume it is still pending, and let polling keep asking.
350
+ setSigningEnrollmentId(storedId);
351
+ setSigningStatus("pending");
352
+ });
353
+ return () => {
354
+ cancelled = true;
355
+ };
356
+ }, [mailboxUid]);
112
357
  useEffect(() => {
113
358
  if (signingStatus !== "pending" || !signingEnrollmentId) {
114
359
  return;
@@ -117,23 +362,9 @@ function EncryptionContent() {
117
362
  const interval = setInterval(async () => {
118
363
  try {
119
364
  const result = await checkSignEnrollmentStatus(mailboxUid, signingEnrollmentId);
120
- if (cancelled) {
121
- return;
122
- }
123
- if (result.status === "issued") {
124
- setSigningStatus("idle");
125
- setSigningEnrollmentId(null);
126
- const refreshed = await getMailbox(mailboxUid);
127
- if (!cancelled) {
128
- setRefreshedKeys(refreshed.keys ?? []);
129
- }
365
+ if (!cancelled) {
366
+ await applyEnrollmentResult(signingEnrollmentId, result, () => cancelled);
130
367
  }
131
- else if (result.status === "failed") {
132
- setSigningStatus("idle");
133
- setSigningEnrollmentId(null);
134
- setSigningError(result.error ?? "Signing certificate enrollment failed.");
135
- }
136
- // "pending" leaves state as-is - the interval below just tries again.
137
368
  }
138
369
  catch {
139
370
  // Transient network error - keep polling rather than surfacing a one-off failure.
@@ -144,23 +375,59 @@ function EncryptionContent() {
144
375
  clearInterval(interval);
145
376
  };
146
377
  }, [signingStatus, signingEnrollmentId, mailboxUid]);
378
+ /** Cancels the pending enrollment (restapi's owner-only DELETE), which is what lets rotation run again. */
379
+ async function handleCancelEnrollment(enrollmentId) {
380
+ setCancelEnrollmentError(null);
381
+ setCancelingEnrollment(true);
382
+ try {
383
+ const result = await cancelSignEnrollment(mailboxUid, enrollmentId);
384
+ if (result.status === "pending") {
385
+ setCancelEnrollmentError("The enrollment couldn't be cancelled yet. Try again.");
386
+ }
387
+ else if (result.status === "failed") {
388
+ // Cancelled - the "failure" is the cancellation itself, not something to report.
389
+ storeSignEnrollment(mailboxUid, null);
390
+ setSigningStatus("idle");
391
+ setSigningEnrollmentId(null);
392
+ }
393
+ else {
394
+ // Issued before the cancel landed: it finishes like any other issued enrollment.
395
+ await applyEnrollmentResult(enrollmentId, result, () => false);
396
+ }
397
+ }
398
+ catch (err) {
399
+ if (err instanceof ApiRequestError && err.status === 404) {
400
+ // The server no longer knows this enrollment, so nothing is pending.
401
+ storeSignEnrollment(mailboxUid, null);
402
+ setSigningStatus("idle");
403
+ setSigningEnrollmentId(null);
404
+ }
405
+ else {
406
+ setCancelEnrollmentError(errorMessage(err, "Could not cancel the signing certificate enrollment."));
407
+ }
408
+ }
409
+ finally {
410
+ setCancelingEnrollment(false);
411
+ }
412
+ }
147
413
  async function handleEnrollSigning() {
148
414
  setSigningError(null);
149
415
  setSigningStatus("enrolling");
150
416
  try {
151
- // Only reachable once `unlocked` is defined - see `handleAddPassword`'s identical note.
417
+ const { current } = await verifiedUnlockedKeys();
152
418
  const { keyPair, csrPem } = await generateKeyPairWithCsr(mailbox.primarySmtpAddress, "sign");
153
419
  const privateKeyRaw = await exportPrivateKeyPkcs8(keyPair.privateKey);
154
- const wrappedKeySealed = await sealWithKey(unlocked.masterKey, privateKeyRaw, buildAad(mailboxUid, SIGNING_PRIVATE_KEY_AAD_PURPOSE));
420
+ const wrappedKeySealed = await sealWithKey(current.masterKey, privateKeyRaw, buildAad(mailboxUid, SIGNING_PRIVATE_KEY_AAD_PURPOSE));
155
421
  const { enrollmentId } = await startSignEnrollment(mailboxUid, {
156
422
  csr: csrPem,
157
423
  wrappedKey: { ciphertext: wrappedKeySealed.ciphertext, nonce: wrappedKeySealed.nonce, algorithm: "AES-256-GCM" },
158
424
  });
425
+ storeSignEnrollment(mailboxUid, enrollmentId);
159
426
  setSigningEnrollmentId(enrollmentId);
160
427
  setSigningStatus("pending");
161
428
  }
162
429
  catch (err) {
163
- setSigningError(err instanceof ApiRequestError ? err.message : "Could not start signing certificate enrollment.");
430
+ setSigningError(errorMessage(err, "Could not start signing certificate enrollment."));
164
431
  setSigningStatus("idle");
165
432
  }
166
433
  }
@@ -180,14 +447,22 @@ function EncryptionContent() {
180
447
  .catch((err) => setLoadError(err instanceof ApiRequestError ? err.message : "Could not load your key vault."));
181
448
  }
182
449
  useEffect(() => {
183
- loadVault();
184
- // eslint-disable-next-line react-hooks/exhaustive-deps
450
+ void loadVault();
185
451
  }, [mailboxUid]);
186
- const unlocked = getUnlockedKeys(mailboxUid);
187
452
  const passwordWraps = vault?.masterKeyWraps.filter((w) => w.method === "password") ?? [];
188
453
  const recoveryWraps = vault?.masterKeyWraps.filter((w) => w.method === "recovery") ?? [];
189
454
  const otherWraps = vault?.masterKeyWraps.filter((w) => w.method !== "password" && w.method !== "recovery") ?? [];
455
+ // The owner's own unlock methods are every non-escrow wrap - restapi refuses (409) to remove the last
456
+ // one. The app unlocks with a password or a single-use recovery code (passkeys have no unlock path yet),
457
+ // so the last password wrap is never offered for removal either: without it only a dwindling set of
458
+ // recovery codes would be left. A forgotten password is replaced after a recovery-code unlock instead
459
+ // (`RecoveryCodeUnlock.tsx`).
460
+ const ownUnlockWrapCount = vault?.masterKeyWraps.filter((w) => w.method !== "escrow").length ?? 0;
461
+ function canRemoveWrap(wrap) {
462
+ return wrap.method === "password" ? passwordWraps.length > 1 : ownUnlockWrapCount > 1;
463
+ }
190
464
  async function handleRemove(wrap) {
465
+ setPendingRemoval(null);
191
466
  const key = `${wrap.method}:${wrap.methodId ?? ""}`;
192
467
  setRemovingMethod(key);
193
468
  setActionError(null);
@@ -202,6 +477,8 @@ function EncryptionContent() {
202
477
  setRemovingMethod(null);
203
478
  }
204
479
  }
480
+ /** Only offered while the vault has no password wrap at all - `unlockWithPassword()` only ever tries
481
+ * the first one, so a second password would be accepted here but never unlock anything. */
205
482
  async function handleAddPassword(e) {
206
483
  e.preventDefault();
207
484
  if (newPassword.length < MIN_PASSWORD_LENGTH) {
@@ -215,43 +492,133 @@ function EncryptionContent() {
215
492
  setActionError(null);
216
493
  setAddingPassword(true);
217
494
  try {
218
- // Only reachable once `unlocked` is defined - `EncryptionGate` guarantees this mailbox is
219
- // unlocked before `EncryptionContent` ever mounts.
220
- const wrap = await buildPasswordWrap(mailboxUid, unlocked.masterKey, newPassword);
495
+ const { current } = await verifiedUnlockedKeys();
496
+ const wrap = await buildPasswordWrap(mailboxUid, current.masterKey, newPassword);
221
497
  await addMasterKeyWrap(mailboxUid, wrap);
222
498
  setNewPassword("");
223
499
  setConfirmNewPassword("");
224
500
  await loadVault();
225
501
  }
226
502
  catch (err) {
227
- setActionError(err instanceof ApiRequestError ? err.message : "Could not add this password.");
503
+ setActionError(errorMessage(err, "Could not add this password."));
228
504
  }
229
505
  finally {
230
506
  setAddingPassword(false);
231
507
  }
232
508
  }
509
+ /**
510
+ * Adds the new recovery wraps *before* removing any old one, so a failure part-way through can never
511
+ * leave the mailbox with fewer working recovery codes than it started with. `buildRecoveryWraps()`
512
+ * labels its wraps `recovery-1..N` - the same labels the old set most likely has, and restapi's
513
+ * `removeMasterKeyWrap()` removes every wrap matching a `methodId` - so each new wrap gets a
514
+ * batch-unique label first, letting the old ones be removed by their own `methodId` without touching
515
+ * the new ones.
516
+ *
517
+ * The vault holds at most `MAX_MASTER_KEY_WRAPS` wraps. Checked against a fresh copy of the vault before
518
+ * anything is written: when the new set only fits once old recovery codes are gone, an old code is
519
+ * removed just before each new one that has no room yet (the working-code count never drops below the
520
+ * original set's), and when it can't fit even then, nothing is written and the user is told how many
521
+ * other methods to remove.
522
+ */
233
523
  async function handleRegenerateRecoveryCodes() {
524
+ // Only reachable once the vault has loaded (the button is disabled until then).
234
525
  setActionError(null);
235
526
  setRegenerating(true);
527
+ let built;
528
+ let oldRecoveryWraps;
529
+ let free;
236
530
  try {
237
- for (const wrap of recoveryWraps) {
238
- await removeMasterKeyWrap(mailboxUid, "recovery", wrap.methodId);
531
+ const { current, freshVault } = await verifiedUnlockedKeys();
532
+ oldRecoveryWraps = freshVault.masterKeyWraps.filter((w) => w.method === "recovery");
533
+ free = MAX_MASTER_KEY_WRAPS - freshVault.masterKeyWraps.length;
534
+ built = await buildRecoveryWraps(mailboxUid, current.masterKey);
535
+ const shortfall = built.wraps.length - (free + oldRecoveryWraps.length);
536
+ if (shortfall > 0) {
537
+ setVault(freshVault);
538
+ setActionError(`Your key vault can hold at most ${MAX_MASTER_KEY_WRAPS} unlock methods and already has ${freshVault.masterKeyWraps.length}. ` +
539
+ `Regenerating needs room for ${built.wraps.length} new recovery codes, so remove ${shortfall} other unlock ` +
540
+ `method${shortfall === 1 ? "" : "s"} (such as an extra password or passkey) below first.`);
541
+ setRegenerating(false);
542
+ return;
239
543
  }
240
- const { wraps, codes } = await buildRecoveryWraps(mailboxUid, unlocked.masterKey);
241
- for (const wrap of wraps) {
242
- await addMasterKeyWrap(mailboxUid, wrap);
243
- }
244
- setRecoveryCodesReason("regenerate");
245
- setNewRecoveryCodes(codes);
246
- setCodesSaved(false);
247
- await loadVault();
248
544
  }
249
545
  catch (err) {
250
- setActionError(err instanceof ApiRequestError ? err.message : "Could not regenerate recovery codes.");
546
+ setActionError(errorMessage(err, "Could not regenerate recovery codes."));
547
+ setRegenerating(false);
548
+ return;
251
549
  }
252
- finally {
550
+ const batch = Date.now().toString(36);
551
+ const pendingOld = [...oldRecoveryWraps];
552
+ let removedEarly = 0;
553
+ const savedCodes = [];
554
+ let addError = null;
555
+ for (let i = 0; i < built.wraps.length; i++) {
556
+ // The old wrap removed to make room for this new one, if any - put back if the add then fails, so a
557
+ // failed add never costs a working code.
558
+ let removedForThis = null;
559
+ try {
560
+ if (free <= 0) {
561
+ // Guaranteed non-empty by the shortfall check above.
562
+ const old = pendingOld.shift();
563
+ await removeMasterKeyWrap(mailboxUid, "recovery", old.methodId);
564
+ removedForThis = old;
565
+ removedEarly++;
566
+ free++;
567
+ }
568
+ await addMasterKeyWrap(mailboxUid, { ...built.wraps[i], methodId: `recovery-${batch}-${i + 1}` });
569
+ free--;
570
+ savedCodes.push(built.codes[i]);
571
+ }
572
+ catch (err) {
573
+ addError = err;
574
+ if (removedForThis) {
575
+ try {
576
+ await addMasterKeyWrap(mailboxUid, removedForThis);
577
+ removedEarly--;
578
+ }
579
+ catch {
580
+ // Still counted in removedEarly, which the messages below report.
581
+ }
582
+ }
583
+ break;
584
+ }
585
+ }
586
+ const removedEarlyNote = removedEarly > 0
587
+ ? `${removedEarly} of your old recovery codes had to be removed to make room; the rest were kept and still work`
588
+ : "Your old recovery codes were kept and still work";
589
+ if (savedCodes.length === 0) {
590
+ const message = addError instanceof ApiRequestError ? addError.message : "Could not regenerate recovery codes.";
591
+ setActionError(removedEarly > 0 ? `${message} ${removedEarlyNote}.` : message);
253
592
  setRegenerating(false);
593
+ await loadVault();
594
+ return;
595
+ }
596
+ let warning = null;
597
+ if (savedCodes.length < built.wraps.length) {
598
+ // Remaining old codes are deliberately left in place - the new set is incomplete, so the old one
599
+ // is still part of the user's recovery safety net.
600
+ warning = `Only ${savedCodes.length} of ${built.wraps.length} new recovery codes could be saved${addError instanceof ApiRequestError ? ` (${addError.message})` : ""}. ${removedEarlyNote} - save the codes below, then try regenerating again.`;
601
+ }
602
+ else {
603
+ let notRemoved = 0;
604
+ for (const wrap of pendingOld) {
605
+ try {
606
+ await removeMasterKeyWrap(mailboxUid, "recovery", wrap.methodId);
607
+ }
608
+ catch {
609
+ notRemoved++;
610
+ }
611
+ }
612
+ if (notRemoved > 0) {
613
+ warning = `${notRemoved} of your old recovery codes could not be removed and still work. Remove them from the unlock methods list, or regenerate again.`;
614
+ }
254
615
  }
616
+ setRecoveryCodesReason("regenerate");
617
+ setCodesWarning(warning);
618
+ setNewRecoveryCodes(savedCodes);
619
+ setCodesSaved(false);
620
+ setRegenerating(false);
621
+ await loadVault();
255
622
  }
256
623
  async function handleCopyCodes() {
257
624
  try {
@@ -267,16 +634,21 @@ function EncryptionContent() {
267
634
  }
268
635
  /**
269
636
  * Real revocation for a captured wrap (`keyvaultApi.ts`'s `rekey()` - see that function's own doc
270
- * comment): re-wraps this mailbox's already-unlocked private keys under a brand new master key
271
- * (`rewrapPrivateKeysUnderNewMasterKey()`), wraps that new MK under a freshly entered password and a
272
- * fresh set of recovery codes, and atomically replaces the vault - the enrolled keypair/certificate
273
- * itself is unchanged (restapi's own `rekey()` rejects anything else), only how it's protected.
274
- * Every *other* unlock method this mailbox had (a second password, a passkey, an old set of recovery
275
- * codes) stops working the instant this succeeds, since `rekey()` replaces `masterKeyWraps` wholesale
276
- * - the whole point, for a captured-wrap scenario where it's unclear which method was compromised.
637
+ * comment): re-wraps every private key in a freshly fetched vault under a brand new master key
638
+ * (`rewrapVaultPrivateKeys()` - all of them, active or not, since `rekey()` replaces `wrappedKeys`
639
+ * wholesale), wraps that new MK under a freshly entered password and a fresh set of recovery codes,
640
+ * and atomically replaces the vault - the enrolled keypair/certificate itself is unchanged (restapi's
641
+ * own `rekey()` rejects anything else, so `keys` is a fresh copy of the mailbox's, not this page's
642
+ * possibly stale list), only how it's protected. Every *other* unlock method this mailbox had stops
643
+ * working the instant this succeeds, since `rekey()` replaces `masterKeyWraps` wholesale - the whole
644
+ * point, for a captured-wrap scenario where it's unclear which method was compromised.
277
645
  */
278
646
  async function handleRotateKeys(e) {
279
647
  e.preventDefault();
648
+ if (rotationBlockedBySigning) {
649
+ // The form's controls are disabled in this state too; this also covers an implicit submit.
650
+ return;
651
+ }
280
652
  if (rotationPassword.length < MIN_PASSWORD_LENGTH) {
281
653
  setActionError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
282
654
  return;
@@ -288,47 +660,82 @@ function EncryptionContent() {
288
660
  setActionError(null);
289
661
  setEscrowError(null);
290
662
  setRotating(true);
663
+ let keys;
664
+ let codes;
291
665
  try {
292
- // Only reachable once `unlocked` is defined - see `handleAddPassword`'s identical note.
293
- const { mk, wrappedKeys } = await rewrapPrivateKeysUnderNewMasterKey(mailboxUid, unlocked);
666
+ const current = await currentUnlockedKeys();
667
+ const [freshVault, freshMailbox] = await Promise.all([getKeyVault(mailboxUid), getMailbox(mailboxUid)]);
668
+ keys = freshMailbox.keys ?? [];
669
+ // A session key that opens nothing is stale (rotated elsewhere) rather than missing some keys.
670
+ if (!(await masterKeyOpensVault(mailboxUid, current.masterKey, freshVault))) {
671
+ throw new StaleSessionKeysError();
672
+ }
673
+ const rewrapped = await rewrapVaultPrivateKeys(mailboxUid, current.masterKey, freshVault.wrappedKeys);
674
+ const mk = rewrapped.mk;
294
675
  const passwordWrap = await buildPasswordWrap(mailboxUid, mk, rotationPassword);
295
- const { wraps: newRecoveryWraps, codes } = await buildRecoveryWraps(mailboxUid, mk);
296
- await rekey(mailboxUid, { wrappedKeys, masterKeyWraps: [passwordWrap, ...newRecoveryWraps], keys: mailbox.keys ?? [] });
297
- // restapi's own rekey() can never accept a fresh escrow wrap (its validateMasterKeyWrap()
298
- // always passes allowEscrow: false there) - it preserves this mailbox's existing escrow wrap
299
- // verbatim instead, which now encrypts a master key nobody has any longer. Re-wrap it
300
- // separately, via the same addMasterKeyWrap() path "Add escrow protection" above already
301
- // uses, so rotating keys for an unrelated reason (lost device, password hygiene) doesn't
302
- // silently drop real escrow coverage while this page keeps claiming it's still active. A
303
- // failure here is reported via escrowError, not as a rotation failure - the rotation itself
304
- // (password/recovery codes) already succeeded by this point and must not be rolled back for
305
- // an escrow-specific hiccup the user can retry independently.
306
- if (hasEscrowWrap) {
676
+ const recovery = await buildRecoveryWraps(mailboxUid, mk);
677
+ codes = recovery.codes;
678
+ const masterKeyWraps = [passwordWrap, ...recovery.wraps];
679
+ // restapi's rekey() drops the old escrow wraps, and requires a replacement escrow wrap only when the vault
680
+ // already holds one and the mailbox's escrow scope still exists - so one is carried over exactly then, in
681
+ // the same request. A mailbox assigned a scope but never escrowed isn't silently escrowed by a rotation,
682
+ // and a deleted scope (escrow-info 404s) needs nothing. Any other failure to build it aborts the rotation -
683
+ // rotating without it would end escrow coverage. restapi's own 409 stays the final word.
684
+ if (freshVault.masterKeyWraps.some((w) => w.method === "escrow")) {
307
685
  try {
308
686
  const escrowInfo = await getEscrowInfo(mailboxUid);
309
- const escrowWrap = await buildEscrowWrap(mk, escrowInfo.escrowScopeId, fromBase64(escrowInfo.publicKey.publicKey));
310
- await addMasterKeyWrap(mailboxUid, escrowWrap);
687
+ masterKeyWraps.push(await buildEscrowWrap(mk, escrowInfo.escrowScopeId, fromBase64(escrowInfo.publicKey.publicKey)));
311
688
  }
312
689
  catch (err) {
313
- setEscrowError(err instanceof ApiRequestError
314
- ? err.message
315
- : 'Your keys were rotated, but escrow protection could not be re-established automatically. Use "Add escrow protection" below to restore it.');
690
+ if (!(err instanceof ApiRequestError && err.status === 404)) {
691
+ throw new EscrowWrapUnavailableError(err);
692
+ }
316
693
  }
317
694
  }
695
+ await rekey(mailboxUid, { wrappedKeys: rewrapped.wrappedKeys, masterKeyWraps, keys });
696
+ }
697
+ catch (err) {
698
+ if (err instanceof UncoveredVaultKeysError) {
699
+ setActionError(`Your key vault holds ${err.fingerprints.length === 1 ? "a private key" : `${err.fingerprints.length} private keys`} this session can't open (${err.fingerprints.join(", ")}), so rotating now would lose ${err.fingerprints.length === 1 ? "it" : "them"}. Nothing was changed.`);
700
+ }
701
+ else if (err instanceof EscrowWrapUnavailableError) {
702
+ const reason = err.cause instanceof ApiRequestError ? ` (${err.cause.message})` : "";
703
+ setActionError(`Your keys were not rotated: this mailbox is under escrow, and escrow protection for the new keys couldn't be prepared${reason}. Nothing was changed.`);
704
+ }
705
+ else if (err instanceof ApiRequestError && err.status === 409) {
706
+ setActionError(ROTATION_CONFLICT_MESSAGE);
707
+ }
708
+ else {
709
+ setActionError(errorMessage(err, "Could not rotate your encryption keys."));
710
+ }
711
+ setRotating(false);
712
+ return;
713
+ }
714
+ // The rotation is committed - every old unlock method is already dead, so these codes must be on
715
+ // screen right now, before anything below that could fail or hang.
716
+ const newPassword = rotationPassword;
717
+ setRotationPassword("");
718
+ setRotationConfirmPassword("");
719
+ setRecoveryCodesReason("rotate");
720
+ setCodesWarning(null);
721
+ setNewRecoveryCodes(codes);
722
+ setCodesSaved(false);
723
+ setRefreshedKeys(keys);
724
+ try {
318
725
  // Refreshes this session's own cached keys against the new MK, via the password we just set -
319
726
  // the underlying private key material didn't change, but the stale MK in memory would silently
320
- // build wrong future wraps (e.g. a second "Add a password") if left as-is.
321
- await unlockWithPassword(mailboxUid, mailbox.keys ?? [], rotationPassword);
322
- setRotationPassword("");
323
- setRotationConfirmPassword("");
324
- setRecoveryCodesReason("rotate");
325
- setNewRecoveryCodes(codes);
326
- setCodesSaved(false);
727
+ // build wrong future wraps if left as-is. If that fails, the stale keys are destroyed instead.
728
+ try {
729
+ await unlockWithPassword(mailboxUid, keys, newPassword);
730
+ }
731
+ catch {
732
+ destroyUnlockedKeys(mailboxUid);
733
+ void destroyLocalIndex(mailboxUid);
734
+ setRotationRelockReason("Your keys were rotated, but this session couldn't unlock them again with your new password. Reload the page and unlock with your new password.");
735
+ setDestroyed(true);
736
+ }
327
737
  await loadVault();
328
738
  }
329
- catch (err) {
330
- setActionError(err instanceof ApiRequestError ? err.message : "Could not rotate your encryption keys.");
331
- }
332
739
  finally {
333
740
  setRotating(false);
334
741
  }
@@ -343,16 +750,26 @@ function EncryptionContent() {
343
750
  void destroyLocalIndex(mailboxUid);
344
751
  setDestroyed(true);
345
752
  }
346
- if (destroyed) {
347
- 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." }) }) }));
348
- }
753
+ // Checked before `destroyed` - a rotation whose re-unlock failed destroys this session's keys, but its
754
+ // new recovery codes still have to be seen (and acknowledged) first.
349
755
  if (newRecoveryCodes) {
350
- 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"
756
+ 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" }), codesWarning && _jsx(Alert, { children: codesWarning }), _jsxs("p", { className: "text-sm text-text-muted mb-4", children: [recoveryCodesReason === "rotate"
351
757
  ? "Your keys have been rotated - every previous unlock method (password, recovery codes, or anything else on file) has stopped working. "
352
- : "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-3 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))) }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto mb-5", onClick: handleCopyCodes, children: codesCopied ? "Copied" : "Copy codes to clipboard" }), _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" })] }) }));
758
+ : codesWarning
759
+ ? ""
760
+ : "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-3 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))) }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto mb-5", onClick: handleCopyCodes, children: codesCopied ? "Copied" : "Copy codes to clipboard" }), _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" })] }) }));
761
+ }
762
+ if (destroyed) {
763
+ 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: rotationRelockReason ??
764
+ "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." }) }) }));
353
765
  }
354
- 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) => {
766
+ 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 }), !canManageKeys && (_jsx("p", { className: "text-sm text-text-muted", children: "Only this mailbox\u2019s owner, signed in as themselves, can change its encryption keys and unlock methods." })), _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 &&
767
+ (key.revocationReason === "superseded" ? (_jsx("span", { className: "text-text-muted", children: " (superseded)" })) : (_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." })) : !canManageKeys ? (_jsx("p", { className: "text-sm text-text-muted", children: "Not enabled." })) : (_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 !== "idle", 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: [_jsxs("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\u2019s encrypted mail until you complete this step.", " ", "This does not weaken protection against anyone else."] }), canManageKeys && (_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) => {
355
768
  const key = `${wrap.method}:${wrap.methodId ?? ""}`;
356
- 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));
357
- }) })) : (_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: "Local search index size" }), _jsxs("p", { className: "text-xs text-text-muted mb-3", children: ["How much decrypted mail this device keeps in a local encrypted search index, so recent search results and inbox previews work instantly without contacting the server every time. Defaults to ", localIndexDefaultByteBudgetLabel, " on this device. Applies the next time this mailbox\u2019s index rebuilds (e.g. the next time you unlock it) \u2014 lowering it doesn\u2019t delete anything already indexed elsewhere, it just narrows what this device keeps a local copy of."] }), _jsx("select", { "aria-label": "Local search index size", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: localIndexByteBudget, onChange: handleLocalIndexByteBudgetChange, children: LOCAL_INDEX_SIZE_OPTIONS.map((option) => (_jsx("option", { value: option.bytes, children: option.label }, option.bytes))) })] }), _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" })] })] }) }));
769
+ 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" &&
770
+ canManageKeys &&
771
+ (canRemoveWrap(wrap) ? (_jsx(Button, { type: "button", variant: "text", className: "!w-auto text-danger", loading: removingMethod === key, disabled: removingMethod !== null, onClick: () => setPendingRemoval(wrap), children: "Remove" })) : (_jsx("span", { className: "text-xs text-text-muted", children: ownUnlockWrapCount > 1 ? "Needed to unlock" : "Your only unlock method" })))] }, key));
772
+ }) })) : (_jsx("p", { className: "text-sm text-text-muted", children: "No unlock methods on file." })), _jsxs(Modal, { open: pendingRemoval !== null, onClose: () => setPendingRemoval(null), title: "Remove unlock method", children: [_jsx("p", { className: "text-sm mb-5", children: "Remove this unlock method? It will no longer unlock this mailbox\u2019s encrypted mail on any device." }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setPendingRemoval(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: () => handleRemove(pendingRemoval), children: "Remove method" })] })] })] }), canManageKeys && (_jsxs(_Fragment, { children: [passwordWraps.length === 0 ? (vault && (_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: "Changing your password" }), _jsx("p", { className: "text-xs text-text-muted", children: "Only one password can unlock this mailbox. To change it, rotate your keys below - that sets a new password and replaces every other unlock method." })] })), _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 || !vault, 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." }), rotationBlockedBySigning && (_jsx("p", { className: "text-xs text-text-muted", children: signingStatus === "checking"
773
+ ? "Checking whether a signing certificate enrollment is still in progress..."
774
+ : "Rotation is unavailable while a signing certificate enrollment is in progress - rotating now would lose the key being enrolled. Try again once it finishes, or cancel the enrollment." })), signingStatus === "pending" && signingEnrollmentId && (_jsxs("div", { className: "flex flex-col gap-2", children: [cancelEnrollmentError && _jsx(Alert, { children: cancelEnrollmentError }), _jsx("div", { children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: cancelingEnrollment, disabled: cancelingEnrollment, onClick: () => handleCancelEnrollment(signingEnrollmentId), children: "Cancel enrollment" }) })] })), _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 || rotationBlockedBySigning, 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 || rotationBlockedBySigning, autoComplete: "new-password" }), _jsx("div", { children: _jsx(Button, { type: "submit", variant: "secondary", className: "!w-auto text-danger", loading: rotating, disabled: rotating || rotationBlockedBySigning, 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: "Local search index size" }), _jsxs("p", { className: "text-xs text-text-muted mb-3", children: ["How much decrypted mail this device keeps in a local encrypted search index, so recent search results and inbox previews work instantly without contacting the server every time. Defaults to ", localIndexDefaultByteBudgetLabel, " on this device. Applies the next time this mailbox\u2019s index rebuilds (e.g. the next time you unlock it) \u2014 lowering it doesn\u2019t delete anything already indexed elsewhere, it just narrows what this device keeps a local copy of."] }), _jsx("select", { "aria-label": "Local search index size", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: localIndexByteBudget, onChange: handleLocalIndexByteBudgetChange, children: LOCAL_INDEX_SIZE_OPTIONS.map((option) => (_jsx("option", { value: option.bytes, children: option.label }, option.bytes))) })] }), _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" })] })] }) }));
358
775
  }