@rapidmx/web-client 0.3.1 → 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 (202) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/branding/index.tsx +39 -404
  3. package/apps/admin/data-requests/index.tsx +480 -485
  4. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  5. package/apps/admin/domains/[uid].tsx +92 -259
  6. package/apps/admin/encryption-policy/index.tsx +19 -0
  7. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  8. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  9. package/apps/admin/index.tsx +118 -82
  10. package/apps/admin/ingest-queue/index.tsx +44 -5
  11. package/apps/admin/mailbox-policy/index.tsx +19 -0
  12. package/apps/admin/mailboxes/[uid].tsx +211 -184
  13. package/apps/admin/mailboxes/new/index.tsx +28 -291
  14. package/apps/admin/plugins/index.tsx +15 -0
  15. package/apps/admin/quarantine/index.tsx +46 -14
  16. package/apps/admin/retention-policy/index.tsx +39 -132
  17. package/apps/admin/setup/index.tsx +15 -0
  18. package/apps/admin/transport-rules/[uid].tsx +30 -1
  19. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  20. package/apps/admin/transport-rules/index.tsx +24 -6
  21. package/apps/admin/transport-rules/new/index.tsx +30 -1
  22. package/apps/escrow/_layout.tsx +2 -3
  23. package/apps/escrow/audit-log/index.tsx +196 -168
  24. package/apps/escrow/matters/[uid].tsx +621 -521
  25. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  26. package/apps/shared/components/admin/layout/AdminShell.tsx +251 -210
  27. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  28. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  29. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  30. package/apps/shared/components/admin/settings/BrandingForm.tsx +385 -0
  31. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +187 -0
  32. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +112 -0
  33. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  34. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +308 -0
  35. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +152 -0
  36. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -0
  37. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -0
  38. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +290 -0
  39. package/apps/shared/components/admin/setup/SetupWizard.tsx +416 -0
  40. package/apps/shared/components/admin/signOut.ts +79 -0
  41. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  42. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  43. package/apps/shared/components/calendar/EventModal.tsx +717 -513
  44. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  45. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  46. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  47. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  48. package/apps/shared/components/calendar/allDay.ts +124 -0
  49. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  50. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  51. package/apps/shared/components/contacts/ContactForm.tsx +377 -314
  52. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  53. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  54. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  55. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  56. package/apps/shared/components/layout/AppShell.tsx +270 -169
  57. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  58. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +150 -26
  59. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  60. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  61. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -0
  62. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  63. package/apps/shared/components/mail/MessageDetailPane.tsx +799 -76
  64. package/apps/shared/components/mail/compose/ComposeContext.tsx +26 -16
  65. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1652 -765
  66. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  67. package/apps/shared/components/mail/layout/MailShell.tsx +418 -302
  68. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  69. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  70. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  71. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  72. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  73. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  74. package/apps/shared/mail/listAllPages.ts +39 -0
  75. package/apps/shared/search/LocalIndexLifecycle.tsx +98 -0
  76. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  77. package/apps/shared/search/localIndexBuilder.ts +481 -0
  78. package/apps/shared/search/localIndexKey.ts +27 -0
  79. package/apps/shared/search/localIndexRpcClient.ts +316 -0
  80. package/apps/shared/search/localIndexSchema.ts +236 -0
  81. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  82. package/apps/shared/search/localIndexStorage.ts +98 -0
  83. package/apps/shared/search/localIndexVFS.ts +266 -0
  84. package/apps/shared/search/localIndexWorker.ts +926 -0
  85. package/apps/shared/search/searchTier2.ts +79 -0
  86. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  87. package/apps/www/calendar/index.tsx +438 -407
  88. package/apps/www/contacts/[uid].tsx +21 -2
  89. package/apps/www/contacts/index.tsx +148 -17
  90. package/apps/www/index.tsx +1314 -486
  91. package/apps/www/messages/[uid].tsx +23 -7
  92. package/apps/www/settings/auto-reply/index.tsx +134 -129
  93. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  94. package/apps/www/settings/encryption/index.tsx +800 -215
  95. package/apps/www/settings/filters/[uid].tsx +171 -155
  96. package/apps/www/settings/filters/new/index.tsx +8 -1
  97. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  98. package/apps/www/settings/privacy/index.tsx +494 -436
  99. package/apps/www/settings/read-receipts/index.tsx +148 -125
  100. package/apps/www/settings/sharing/index.tsx +274 -0
  101. package/apps/www/tasks/index.tsx +638 -530
  102. package/dist/apps/admin/audit-log/index.js +38 -8
  103. package/dist/apps/admin/branding/index.js +4 -98
  104. package/dist/apps/admin/data-requests/index.js +52 -72
  105. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  106. package/dist/apps/admin/domains/[uid].js +5 -68
  107. package/dist/apps/admin/encryption-policy/index.js +8 -0
  108. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  109. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  110. package/dist/apps/admin/index.js +17 -1
  111. package/dist/apps/admin/ingest-queue/index.js +24 -6
  112. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  113. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  114. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  115. package/dist/apps/admin/plugins/index.js +6 -0
  116. package/dist/apps/admin/quarantine/index.js +20 -11
  117. package/dist/apps/admin/retention-policy/index.js +3 -36
  118. package/dist/apps/admin/setup/index.js +6 -0
  119. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  120. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  121. package/dist/apps/admin/transport-rules/index.js +22 -6
  122. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  123. package/dist/apps/escrow/_layout.js +3 -3
  124. package/dist/apps/escrow/audit-log/index.js +26 -1
  125. package/dist/apps/escrow/matters/[uid].js +77 -42
  126. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  127. package/dist/apps/shared/components/admin/layout/AdminShell.js +37 -4
  128. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  129. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  130. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  131. package/dist/apps/shared/components/admin/settings/BrandingForm.js +116 -0
  132. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +83 -0
  133. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +62 -0
  134. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  135. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +112 -0
  136. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +74 -0
  137. package/dist/apps/shared/components/admin/settings/PluginsManager.js +572 -0
  138. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +73 -0
  139. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +147 -0
  140. package/dist/apps/shared/components/admin/setup/SetupWizard.js +208 -0
  141. package/dist/apps/shared/components/admin/signOut.js +75 -0
  142. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  143. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  144. package/dist/apps/shared/components/calendar/EventModal.js +154 -21
  145. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  146. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  147. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  148. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  149. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  150. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  151. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  152. package/dist/apps/shared/components/contacts/ContactForm.js +45 -12
  153. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  154. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  155. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  156. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  157. package/dist/apps/shared/components/layout/AppShell.js +100 -8
  158. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  159. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +63 -14
  160. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  161. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  162. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +136 -0
  163. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  164. package/dist/apps/shared/components/mail/MessageDetailPane.js +464 -34
  165. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  166. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +807 -116
  167. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  168. package/dist/apps/shared/components/mail/layout/MailShell.js +116 -41
  169. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  170. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  171. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  172. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  173. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  174. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  175. package/dist/apps/shared/mail/listAllPages.js +25 -0
  176. package/dist/apps/shared/search/LocalIndexLifecycle.js +80 -0
  177. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  178. package/dist/apps/shared/search/localIndexBuilder.js +408 -0
  179. package/dist/apps/shared/search/localIndexKey.js +25 -0
  180. package/dist/apps/shared/search/localIndexRpcClient.js +241 -0
  181. package/dist/apps/shared/search/localIndexSchema.js +185 -0
  182. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  183. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  184. package/dist/apps/shared/search/localIndexVFS.js +258 -0
  185. package/dist/apps/shared/search/localIndexWorker.js +687 -0
  186. package/dist/apps/shared/search/searchTier2.js +38 -0
  187. package/dist/apps/www/calendar/index.js +38 -18
  188. package/dist/apps/www/contacts/[uid].js +13 -2
  189. package/dist/apps/www/contacts/index.js +94 -15
  190. package/dist/apps/www/index.js +770 -137
  191. package/dist/apps/www/messages/[uid].js +23 -7
  192. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  193. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  194. package/dist/apps/www/settings/encryption/index.js +541 -96
  195. package/dist/apps/www/settings/filters/[uid].js +22 -7
  196. package/dist/apps/www/settings/filters/new/index.js +8 -2
  197. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  198. package/dist/apps/www/settings/privacy/index.js +57 -22
  199. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  200. package/dist/apps/www/settings/sharing/index.js +124 -0
  201. package/dist/apps/www/tasks/index.js +76 -14
  202. package/package.json +3 -2
@@ -0,0 +1,63 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useState } from "react";
7
+ import { PinnedKeyChangedError, resolveKeyConflict } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
8
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
9
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
12
+ import { conflictSourceLabel, formatDate, groupFingerprint, keyChangeErrorMessage } from "./contactKeys.js";
13
+ /**
14
+ * The old-versus-new comparison for a changed contact key, with Accept new key (confirmed in a dialog) and Keep current
15
+ * key, calling `resolveKeyConflict()` against the pinned fingerprint shown. Shared by a message's "signing key changed"
16
+ * notice and a contact's recorded key conflicts.
17
+ */
18
+ export default function KeyChangeReview({ mailboxUid, address, useType, ownerName, current, proposed, certificate, canReject, canResolve, onResolved, onPinnedKeyChanged, }) {
19
+ const [confirmOpen, setConfirmOpen] = useState(false);
20
+ const [busy, setBusy] = useState(null);
21
+ const [error, setError] = useState(null);
22
+ const [forbidden, setForbidden] = useState(false);
23
+ const kind = useType === "sign" ? "signing" : "encryption";
24
+ const actionable = !!address && !!current && canResolve !== false && !forbidden;
25
+ async function resolve(action) {
26
+ setBusy(action);
27
+ setError(null);
28
+ try {
29
+ await resolveKeyConflict(mailboxUid, {
30
+ address: address,
31
+ useType,
32
+ action,
33
+ expectedPinnedFingerprint: current.fingerprint,
34
+ ...(action === "accept" && certificate !== undefined ? { certificate } : {}),
35
+ });
36
+ setConfirmOpen(false);
37
+ onResolved(action);
38
+ }
39
+ catch (err) {
40
+ setConfirmOpen(false);
41
+ if (err instanceof PinnedKeyChangedError) {
42
+ onPinnedKeyChanged();
43
+ return;
44
+ }
45
+ if (err instanceof ApiRequestError && err.status === 403) {
46
+ setForbidden(true);
47
+ }
48
+ setError(keyChangeErrorMessage(err));
49
+ }
50
+ finally {
51
+ setBusy(null);
52
+ }
53
+ }
54
+ const proposedSeen = proposed.observedAt !== undefined
55
+ ? `First seen ${formatDate(proposed.observedAt)}${proposed.source ? `, ${conflictSourceLabel(proposed.source)}` : ""}`
56
+ : "Only seen in this message";
57
+ return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-2", children: [_jsxs("div", { className: "rounded-sm bg-surface p-2", children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Current key" }), current ? (_jsxs("dd", { children: [_jsx("span", { className: "font-mono text-xs break-all", children: groupFingerprint(current.fingerprint) }), _jsxs("span", { className: "block text-xs text-text-muted", children: ["First seen ", formatDate(current.since)] })] })) : (_jsx("dd", { className: "text-xs text-text-muted", children: "The key you trust couldn\u2019t be loaded." }))] }), _jsxs("div", { className: "rounded-sm bg-surface p-2", children: [_jsx("dt", { className: "text-xs text-text-muted", children: "New key" }), _jsxs("dd", { children: [_jsx("span", { className: "font-mono text-xs break-all", children: groupFingerprint(proposed.fingerprint) }), proposed.emails && (_jsxs("span", { className: "block text-xs text-text-muted", children: ["Certificate for ", proposed.emails.length > 0 ? proposed.emails.join(", ") : "no email address"] })), _jsx("span", { className: "block text-xs text-text-muted", children: proposedSeen })] })] })] }), _jsxs("p", { children: ["This is routine when ", ownerName, " renews a certificate, but it can also mean someone is impersonating them. Confirm the new fingerprint with ", ownerName, " another way, such as a phone call, before accepting it."] }), error && _jsx(Alert, { children: error }), actionable && (_jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx(Button, { type: "button", className: "!w-auto", disabled: busy !== null, onClick: () => {
58
+ setError(null);
59
+ setConfirmOpen(true);
60
+ }, children: "Accept new key" }), canReject && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: busy === "reject", disabled: busy !== null, onClick: () => resolve("reject"), children: "Keep current key" }))] })), _jsx(Modal, { open: confirmOpen, onClose: () => busy === null && setConfirmOpen(false), title: "Accept the new key?", children: _jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [_jsxs("p", { children: [useType === "sign"
61
+ ? `Mail from ${address} signed with this key will show as verified.`
62
+ : `Mail you send to ${address} will be encrypted to this key.`, " ", "The current ", kind, " key moves to this contact\u2019s key history."] }), _jsxs("dl", { className: "flex flex-col gap-1", children: [_jsx("dt", { className: "text-xs text-text-muted", children: "New key fingerprint" }), _jsx("dd", { className: "font-mono text-xs break-all", children: groupFingerprint(proposed.fingerprint) })] }), _jsxs("p", { className: "text-text-muted", children: ["Only accept it once you\u2019ve confirmed this fingerprint with ", ownerName, "."] }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: busy === "accept", disabled: busy !== null, onClick: () => resolve("accept"), children: "Accept new key" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: busy !== null, onClick: () => setConfirmOpen(false), children: "Cancel" })] })] }) })] }));
63
+ }
@@ -0,0 +1,64 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** Display and error helpers for a contact's pinned keys and key changes (`KeyChangeReview`, `ContactDetailPane`,
6
+ * `MessageDetailPane`). */
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { PinnedKeyChangedError } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
9
+ export const KEY_CHANGE_STALE_MESSAGE = "This contact's keys changed while you were looking; reload to see the latest.";
10
+ export const KEY_CHANGE_INVALID_MESSAGE = "The new key isn't a valid, current certificate for this address, so it can't be accepted. Nothing was changed.";
11
+ export const KEY_CHANGE_FORBIDDEN_MESSAGE = "You don't have permission to change this contact's keys.";
12
+ export const KEY_CHANGE_NOT_FOUND_MESSAGE = "There's no key change to resolve for this address any more. It may already have been resolved, or the contact was removed.";
13
+ export const KEY_CHANGE_GENERIC_MESSAGE = "Couldn't update this contact's keys. Try again.";
14
+ /** The error text for a failed `resolveKeyConflict()` other than `PinnedKeyChangedError` (which the caller reloads
15
+ * on and reports with `KEY_CHANGE_STALE_MESSAGE`). */
16
+ export function keyChangeErrorMessage(err) {
17
+ if (err instanceof PinnedKeyChangedError) {
18
+ return KEY_CHANGE_STALE_MESSAGE;
19
+ }
20
+ if (err instanceof ApiRequestError && err.status === 400) {
21
+ return KEY_CHANGE_INVALID_MESSAGE;
22
+ }
23
+ if (err instanceof ApiRequestError && err.status === 403) {
24
+ return KEY_CHANGE_FORBIDDEN_MESSAGE;
25
+ }
26
+ if (err instanceof ApiRequestError && err.status === 404) {
27
+ return KEY_CHANGE_NOT_FOUND_MESSAGE;
28
+ }
29
+ return KEY_CHANGE_GENERIC_MESSAGE;
30
+ }
31
+ /** Groups a fingerprint into 4-character blocks (`ab12 cd34 ...`), ignoring any separators it came with, so it can be
32
+ * read out and compared over another channel. */
33
+ export function groupFingerprint(fingerprint) {
34
+ const compact = fingerprint.replace(/[\s:]/g, "").toLowerCase();
35
+ return compact.match(/.{1,4}/g)?.join(" ") ?? compact;
36
+ }
37
+ /** Whether two fingerprints name the same key (case and separators ignored). */
38
+ export function sameFingerprint(a, b) {
39
+ return a !== undefined && b !== undefined && a.replace(/[\s:]/g, "").toLowerCase() === b.replace(/[\s:]/g, "").toLowerCase();
40
+ }
41
+ /** A revoked key's label: `"superseded"` for a routine replacement, `"revoked"` for a compromised key or one revoked
42
+ * with no reason; `undefined` when the key isn't revoked. */
43
+ export function revocationLabel(key) {
44
+ if (!key.revokedAt) {
45
+ return undefined;
46
+ }
47
+ return key.revocationReason === "superseded" ? "superseded" : "revoked";
48
+ }
49
+ /** Where a key conflict was observed, for display. */
50
+ export function conflictSourceLabel(source) {
51
+ return source === "header" ? "from an incoming message" : "by key discovery";
52
+ }
53
+ /** When `key` (one of `contact`'s pinned keys) started being trusted: when it replaced the newest previous key of its
54
+ * use, else the contact's `keysFirstSeen`, else the key's own `notBefore`. */
55
+ export function keyPinnedSince(contact, key) {
56
+ const replaced = (contact.previousKeys ?? []).filter((previous) => previous.useType === key.useType).map((previous) => previous.replacedAt);
57
+ if (replaced.length > 0) {
58
+ return Math.max(...replaced);
59
+ }
60
+ return contact.keysFirstSeen ?? key.notBefore;
61
+ }
62
+ export function formatDate(ms) {
63
+ return new Date(ms).toLocaleDateString();
64
+ }
@@ -9,11 +9,12 @@ import { HiOutlineClipboardDocumentList, HiOutlineFolderOpen } from "react-icons
9
9
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
10
10
  import { listMatters } from "@rapidmx/react-shared/admin/mattersApi.js";
11
11
  import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
12
- import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
12
+ import { getBranding } from "@rapidmx/react-shared/branding/brandingApi.js";
13
13
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
14
14
  import BottomTabBar from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
15
- import { BrandingFooter, BrandingHeader } from "../../layout/BrandingChrome.js";
16
15
  import UserMenu from "../../layout/UserMenu.js";
16
+ import { signOutOfConsole } from "../../admin/signOut.js";
17
+ const DEFAULT_ICON_SRC = "/images/logo.svg";
17
18
  const NAV_ITEMS = [
18
19
  { id: "matters", href: "/escrow", label: "Matters", icon: HiOutlineFolderOpen },
19
20
  { id: "auditLog", href: "/escrow/audit-log", label: "Audit Log", icon: HiOutlineClipboardDocumentList },
@@ -37,8 +38,25 @@ const NAV_ITEMS = [
37
38
  export default function EscrowShell({ active, userUid, authServerUrl, children }) {
38
39
  const [status, setStatus] = useState("checking");
39
40
  const [error, setError] = useState(null);
40
- const { branding, iconSrc } = useBranding();
41
+ const [iconSrc, setIconSrc] = useState(DEFAULT_ICON_SRC);
41
42
  useRedirectIfUnauthenticated(userUid, authServerUrl);
43
+ // Only the icon is taken from branding here. The escrow console deliberately skips `useBranding()`, which
44
+ // would inject the custom stylesheet, and never renders the admin-configured header/footer HTML - this
45
+ // console handles escrow key material, so no admin-supplied markup or CSS runs on it.
46
+ useEffect(() => {
47
+ let cancelled = false;
48
+ getBranding()
49
+ .then((branding) => {
50
+ if (!cancelled)
51
+ setIconSrc(branding.iconUrl || branding.logoUrl || DEFAULT_ICON_SRC);
52
+ })
53
+ .catch(() => {
54
+ // Decorative only - keep the default icon.
55
+ });
56
+ return () => {
57
+ cancelled = true;
58
+ };
59
+ }, []);
42
60
  useEffect(() => {
43
61
  if (!userUid) {
44
62
  return;
@@ -53,7 +71,8 @@ export default function EscrowShell({ active, userUid, authServerUrl, children }
53
71
  });
54
72
  }, [userUid]);
55
73
  function handleSignOut() {
56
- window.location.href = authServerUrl ?? "/";
74
+ // Ends the auth-server session and tells other tabs, not just navigates - see `signOutOfConsole()`.
75
+ void signOutOfConsole(authServerUrl);
57
76
  }
58
77
  let content;
59
78
  if (!userUid || status === "checking") {
@@ -71,5 +90,5 @@ export default function EscrowShell({ active, userUid, authServerUrl, children }
71
90
  : "text-text-muted hover:bg-surface-alt hover:text-text",
72
91
  ].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] }), _jsx(BottomTabBar, { apps: NAV_ITEMS, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [_jsxs("header", { className: "h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: activeItem?.label }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut })] }), _jsx("div", { className: "flex-1 pb-14 md:pb-0", children: _jsx("main", { className: "max-w-6xl mx-auto px-6 py-8", children: children }) })] })] }) }));
73
92
  }
74
- return (_jsxs(_Fragment, { children: [_jsx(BrandingHeader, { branding: branding }), content, _jsx(BrandingFooter, { branding: branding })] }));
93
+ return _jsx(_Fragment, { children: content });
75
94
  }
@@ -4,16 +4,46 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import "../../styles/app.css";
7
- import { useState } from "react";
7
+ import { useEffect, useRef, useState } from "react";
8
8
  import { HiOutlineCalendarDays, HiOutlineClipboardDocumentList, HiOutlineEnvelope, HiOutlineUsers } from "react-icons/hi2";
9
9
  import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
10
+ import { getSetupStatus } from "@rapidmx/react-shared/admin/setupApi.js";
10
11
  import { stopImpersonating } from "@rapidmx/react-shared/mail/mailApi.js";
11
12
  import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
12
13
  import { useIdleKeyTimeout } from "@rapidmx/react-shared/crypto/useIdleKeyTimeout.js";
13
14
  import ComposeProvider from "../mail/compose/ComposeContext.js";
15
+ import { flushComposeDrafts, markSigningOut } from "../mail/compose/composeFlushRegistry.js";
14
16
  import BottomTabBar from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
15
17
  import { BrandingFooter, BrandingHeader } from "./BrandingChrome.js";
16
18
  import UserMenu from "./UserMenu.js";
19
+ import { UnlockPromptProvider } from "./UnlockPromptProvider.js";
20
+ import { SIGN_OUT_CHANNEL, destroyAllLocalIndexes } from "../../search/localIndexRpcClient.js";
21
+ import { authApiFetch } from "@rapidmx/react-shared/util/api.js";
22
+ import { destroyUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
23
+ import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
24
+ /** How long sign-out waits for auth-server's logout before navigating anyway. */
25
+ export const LOGOUT_TIMEOUT_MS = 3000;
26
+ /**
27
+ * Calls auth-server's logout (`POST /api/auth/logout`, cross-origin with credentials), which clears the auth
28
+ * cookie and invalidates the session's refresh token. Bounded by `LOGOUT_TIMEOUT_MS` and never rejects - a
29
+ * failure (unreachable server, CORS) must not keep the user from leaving.
30
+ */
31
+ async function logOutOfAuthServer(authServerUrl) {
32
+ if (!authServerUrl) {
33
+ return;
34
+ }
35
+ const controller = new AbortController();
36
+ const timer = setTimeout(() => controller.abort(), LOGOUT_TIMEOUT_MS);
37
+ try {
38
+ await authApiFetch(authServerUrl, "/auth/logout", { method: "POST", signal: controller.signal });
39
+ }
40
+ catch {
41
+ // Navigate anyway - see this function's doc comment.
42
+ }
43
+ finally {
44
+ clearTimeout(timer);
45
+ }
46
+ }
17
47
  export const APPS = [
18
48
  { id: "mail", href: "/", label: "Mail", icon: HiOutlineEnvelope },
19
49
  { id: "calendar", href: "/calendar", label: "Calendar", icon: HiOutlineCalendarDays },
@@ -38,12 +68,74 @@ const ACTIVE_LABELS = {
38
68
  */
39
69
  export default function AppShell({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, children, }) {
40
70
  const [stoppingImpersonation, setStoppingImpersonation] = useState(false);
71
+ const signingOutRef = useRef(false);
41
72
  const { branding, iconSrc } = useBranding();
42
73
  useRedirectIfUnauthenticated(userUid, authServerUrl);
43
74
  // Mounted here, not scoped to Mail/Settings (the only shells that actually read unlocked keys),
44
75
  // specifically so activity in *any* app resets the idle clock - see that hook's own doc comment.
45
76
  useIdleKeyTimeout();
46
- function handleSignOut() {
77
+ // An administrator on a server that hasn't finished first-run setup is sent to the setup wizard. The status
78
+ // check is admin-only, so it's only made for a trusted caller - everyone else would just get a 403 on every
79
+ // page load. Any failure is ignored.
80
+ useEffect(() => {
81
+ if (!userUid || impersonating || !trusted) {
82
+ return;
83
+ }
84
+ getSetupStatus()
85
+ .then((status) => {
86
+ if (status.required) {
87
+ window.location.href = "/admin/setup";
88
+ }
89
+ })
90
+ .catch(() => undefined);
91
+ }, [userUid, impersonating, trusted]);
92
+ // Another tab signing out (this app's `handleSignOut`, or the admin/escrow consoles' `signOutOfConsole()`)
93
+ // ended this session too - its auth cookie is gone - so this tab destroys its own unlocked keys and every
94
+ // local search index on the device, then leaves as well. The consoles have no local-index client of their
95
+ // own, so this is what actually removes the indexes after a console sign-out (the console also records a
96
+ // pending deletion, retried on the next mail load, in case no mail tab is open). `destroyAllLocalIndexes()`
97
+ // announces the sign-out on this same channel, which this tab then hears itself, so the ref is set first:
98
+ // each tab reacts once, and the tab that started the sign-out ignores its own announcement.
99
+ useEffect(() => {
100
+ if (!userUid || typeof BroadcastChannel === "undefined") {
101
+ return;
102
+ }
103
+ const channel = new BroadcastChannel(SIGN_OUT_CHANNEL);
104
+ channel.addEventListener("message", (event) => {
105
+ if (event.data?.type !== "sign-out" || signingOutRef.current) {
106
+ return;
107
+ }
108
+ signingOutRef.current = true;
109
+ // Compose windows must not ask "Leave site?" - that would let this forced navigation be cancelled.
110
+ markSigningOut();
111
+ destroyUnlockedKeys();
112
+ clearPinnedSignerCache();
113
+ // Bounded by its own timeout and never rejects - awaited so navigating doesn't kill the Worker mid-delete.
114
+ void destroyAllLocalIndexes().then(() => {
115
+ window.location.href = authServerUrl ?? "/";
116
+ });
117
+ });
118
+ return () => channel.close();
119
+ }, [userUid, authServerUrl]);
120
+ async function handleSignOut() {
121
+ signingOutRef.current = true;
122
+ // Before flushing and navigating: compose windows then skip their "Leave site?" prompt, which could
123
+ // otherwise cancel the sign-out's own navigation.
124
+ markSigningOut();
125
+ // Unlocked private keys never outlive an explicit sign-out.
126
+ destroyUnlockedKeys();
127
+ // Trusted signer pins read from contacts don't outlive the session either.
128
+ clearPinnedSignerCache();
129
+ // Open compose windows save edits still waiting on their autosave debounce while the session is still
130
+ // valid - logout invalidates it. Bounded the same way as logout itself, and never rejects.
131
+ await flushComposeDrafts(LOGOUT_TIMEOUT_MS);
132
+ // The Tier 2 local index MUST be destroyed on explicit logout, the same as unlocked keys themselves
133
+ // (spec §11). Destroys every index on this device (not only mailboxes opened this page load) and is
134
+ // awaited before navigating - a navigation tears down the Worker mid-delete otherwise.
135
+ // destroyAllLocalIndexes() is bounded by its own timeout and never rejects, so sign-out can't hang.
136
+ // In parallel, auth-server's logout clears the auth cookie and invalidates the session's refresh
137
+ // token - without it, "Sign Out" would only navigate away from a still-valid session.
138
+ await Promise.all([destroyAllLocalIndexes(), logOutOfAuthServer(authServerUrl)]);
47
139
  window.location.href = authServerUrl ?? "/";
48
140
  }
49
141
  async function handleStopImpersonating() {
@@ -63,10 +155,10 @@ export default function AppShell({ active, userUid, authServerUrl, impersonating
63
155
  if (!userUid) {
64
156
  return _jsx("div", { className: "min-h-screen" });
65
157
  }
66
- return (_jsx(ComposeProvider, { children: _jsxs("div", { className: "min-h-screen flex flex-col bg-surface-alt", children: [_jsx(BrandingHeader, { branding: branding }), impersonating && (_jsxs("div", { className: "h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4", children: [_jsxs("span", { children: ["You are viewing as ", _jsx("strong", { children: userUid }), "."] }), _jsx("button", { type: "button", onClick: handleStopImpersonating, disabled: stoppingImpersonation, className: "underline hover:no-underline disabled:opacity-60", children: stoppingImpersonation ? "Returning to admin…" : "Return to admin" })] })), _jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsxs("nav", { "aria-label": "Apps", className: "hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1", children: [_jsx("img", { src: iconSrc, width: "96", height: "96", alt: "", className: "mb-3" }), APPS.map(({ id, href, label, icon: Icon }) => (_jsx("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: label, className: [
67
- "w-10 h-10 flex items-center justify-center rounded-sm",
68
- id === active
69
- ? "bg-primary/10 text-primary-dark"
70
- : "text-text-muted hover:bg-surface-alt hover:text-text",
71
- ].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] }), _jsx(BottomTabBar, { apps: APPS, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [_jsxs("header", { className: "h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: ACTIVE_LABELS[active] }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut, showAdminLink: trusted, showSettingsLink: true })] }), _jsx("div", { className: "flex-1 flex min-h-0 pb-14 md:pb-0", children: children })] })] }), _jsx(BrandingFooter, { branding: branding })] }) }));
158
+ return (_jsx(UnlockPromptProvider, { children: _jsx(ComposeProvider, { userUid: userUid, trusted: !!trusted && !impersonating, children: _jsxs("div", { className: "min-h-screen flex flex-col bg-surface-alt", children: [_jsx(BrandingHeader, { branding: branding }), impersonating && (_jsxs("div", { className: "h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4", children: [_jsxs("span", { children: ["You are viewing as ", _jsx("strong", { children: userUid }), "."] }), _jsx("button", { type: "button", onClick: handleStopImpersonating, disabled: stoppingImpersonation, className: "underline hover:no-underline disabled:opacity-60", children: stoppingImpersonation ? "Returning to admin…" : "Return to admin" })] })), _jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsxs("nav", { "aria-label": "Apps", className: "hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1", children: [_jsx("img", { src: iconSrc, width: "96", height: "96", alt: "", className: "mb-3" }), APPS.map(({ id, href, label, icon: Icon }) => (_jsx("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: label, className: [
159
+ "w-10 h-10 flex items-center justify-center rounded-sm",
160
+ id === active
161
+ ? "bg-primary/10 text-primary-dark"
162
+ : "text-text-muted hover:bg-surface-alt hover:text-text",
163
+ ].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] }), _jsx(BottomTabBar, { apps: APPS, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [_jsxs("header", { className: "h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: ACTIVE_LABELS[active] }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut, showAdminLink: trusted, showSettingsLink: true })] }), _jsx("div", { className: "flex-1 flex min-h-0 pb-14 md:pb-0", children: children })] })] }), _jsx(BrandingFooter, { branding: branding })] }) }) }));
72
164
  }
@@ -1,18 +1,53 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import DOMPurify from "dompurify";
3
+ /** Elements that could run script, restyle the whole page, collect input, or embed another page - stripped on
4
+ * top of DOMPurify's defaults (which already drop `<script>`, `on*` handlers, and `javascript:` URLs). */
5
+ const FORBIDDEN_TAGS = [
6
+ "script",
7
+ "style",
8
+ "form",
9
+ "input",
10
+ "button",
11
+ "textarea",
12
+ "select",
13
+ "option",
14
+ "iframe",
15
+ "frame",
16
+ "frameset",
17
+ "object",
18
+ "embed",
19
+ "link",
20
+ "meta",
21
+ "base",
22
+ "svg",
23
+ "math",
24
+ ];
25
+ const FORBIDDEN_ATTRS = ["action", "formaction", "srcdoc"];
2
26
  /**
3
- * Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`) — shared by every
4
- * chrome in this project (`AppShell`, `AdminShell`, and the public `apps/book` pages) so the setting applies
5
- * everywhere its own admin UI copy (`apps/admin/branding/index.tsx`) claims it does.
27
+ * Sanitizes admin-configured branding HTML before it's rendered. The server sanitizes on save too; this also
28
+ * covers anything stored before that. Returns `""` where there's no DOM to sanitize with (server-side
29
+ * rendering), so unsanitized HTML is never emitted.
6
30
  */
7
- export function BrandingHeader({ branding }) {
8
- if (!branding?.headerHtml) {
9
- return null;
31
+ export function sanitizeBrandingHtml(html) {
32
+ if (!DOMPurify.isSupported) {
33
+ return "";
10
34
  }
11
- return _jsx("div", { dangerouslySetInnerHTML: { __html: branding.headerHtml } });
35
+ return DOMPurify.sanitize(html, { FORBID_TAGS: FORBIDDEN_TAGS, FORBID_ATTR: FORBIDDEN_ATTRS });
12
36
  }
13
- export function BrandingFooter({ branding }) {
14
- if (!branding?.footerHtml) {
37
+ function BrandingHtml({ html }) {
38
+ const clean = html ? sanitizeBrandingHtml(html) : "";
39
+ if (!clean) {
15
40
  return null;
16
41
  }
17
- return _jsx("div", { dangerouslySetInnerHTML: { __html: branding.footerHtml } });
42
+ return _jsx("div", { dangerouslySetInnerHTML: { __html: clean } });
43
+ }
44
+ /**
45
+ * Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`), sanitized - shared by
46
+ * the webmail and admin chromes. The escrow console deliberately renders no branding HTML at all.
47
+ */
48
+ export function BrandingHeader({ branding }) {
49
+ return _jsx(BrandingHtml, { html: branding?.headerHtml });
50
+ }
51
+ export function BrandingFooter({ branding }) {
52
+ return _jsx(BrandingHtml, { html: branding?.footerHtml });
18
53
  }
@@ -8,7 +8,9 @@ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
8
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
9
9
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
10
10
  import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
11
- import { enrollKey, getKeyVault } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
11
+ import { enrollKey, getKeyVault, VaultAlreadyInitializedError } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
12
+ import { UnopenableKeysNotice, unlockErrorMessage } from "./UnlockPromptProvider.js";
13
+ import { RecoveryFollowUpModal, UnlockModeToggle, startRecoveryUnlock } from "./RecoveryCodeUnlock.js";
12
14
  import { ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, getUnlockedKeys, unlockWithPassword, } from "@rapidmx/react-shared/crypto/keySession.js";
13
15
  import { buildAad, generateMasterKey, sealWithKey } from "@rapidmx/react-shared/crypto/masterKey.js";
14
16
  import { buildPasswordWrap, buildRecoveryWraps } from "@rapidmx/react-shared/crypto/masterKeyWraps.js";
@@ -48,13 +50,19 @@ async function provisionEncryptionKey(mailboxUid, mailboxAddress, password) {
48
50
  * `children` unchanged rather than blocking mail entirely - encryption is optional and gradual by design
49
51
  * (the spec's own "near zero frictionless experience" goal), not a hard prerequisite for reading mail.
50
52
  */
51
- export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxKeys, children }) {
53
+ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxKeys, blocking = true, canProvision = false, children, }) {
52
54
  const [status, setStatus] = useState(mailboxUid ? "checking" : "ready");
53
55
  const [password, setPassword] = useState("");
54
56
  const [confirmPassword, setConfirmPassword] = useState("");
55
57
  const [error, setError] = useState(null);
56
58
  const [recoveryCodes, setRecoveryCodes] = useState([]);
57
59
  const [codesSaved, setCodesSaved] = useState(false);
60
+ const [codesCopied, setCodesCopied] = useState(false);
61
+ const [unopenableKeys, setUnopenableKeys] = useState(null);
62
+ const [unlockMode, setUnlockMode] = useState("password");
63
+ const [recoveryCode, setRecoveryCode] = useState("");
64
+ // Set after a recovery-code unlock: `children` render at once, with the follow-up steps in a dialog on top.
65
+ const [followUp, setFollowUp] = useState(null);
58
66
  useEffect(() => {
59
67
  if (!mailboxUid) {
60
68
  return;
@@ -70,7 +78,7 @@ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxK
70
78
  getKeyVault(mailboxUid)
71
79
  .then((vault) => {
72
80
  if (!cancelled) {
73
- setStatus(vault.wrappedKeys.length > 0 ? "unlock" : "setup_password");
81
+ setStatus(vault.wrappedKeys.length > 0 ? "unlock" : canProvision ? "setup_password" : "ready");
74
82
  }
75
83
  })
76
84
  .catch(() => {
@@ -81,7 +89,7 @@ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxK
81
89
  return () => {
82
90
  cancelled = true;
83
91
  };
84
- }, [mailboxUid]);
92
+ }, [mailboxUid, canProvision]);
85
93
  async function handleUnlock(e) {
86
94
  e.preventDefault();
87
95
  setError(null);
@@ -89,14 +97,21 @@ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxK
89
97
  try {
90
98
  // Only reachable via "unlock", which the effect above only ever sets once mailboxUid was
91
99
  // defined.
92
- await unlockWithPassword(mailboxUid, mailboxKeys ?? [], password);
100
+ const result = await (unlockMode === "recovery"
101
+ ? startRecoveryUnlock(mailboxUid, mailboxKeys ?? [], recoveryCode)
102
+ : unlockWithPassword(mailboxUid, mailboxKeys ?? [], password));
103
+ const recovery = unlockMode === "recovery" ? result : null;
104
+ if (result.unopenableKeys.length > 0) {
105
+ setUnopenableKeys(result.unopenableKeys);
106
+ }
107
+ setRecoveryCode("");
108
+ setFollowUp(recovery);
93
109
  setStatus("ready");
94
110
  }
95
- catch {
96
- // Deliberately generic - see `unlockWithPassword()`'s own doc comment: it throws the same way
97
- // for "no password wrap enrolled" and "wrong password" today, and this UI has no way to tell
98
- // those apart without leaking which is which to a potential attacker guessing passwords.
99
- setError("Incorrect password.");
111
+ catch (err) {
112
+ // "Incorrect password." (or "That recovery code didn't work.") unless the secret was right but the
113
+ // encryption key won't open - see `unlockErrorMessage()`.
114
+ setError(unlockErrorMessage(err, unlockMode));
100
115
  setStatus("unlock");
101
116
  }
102
117
  }
@@ -113,30 +128,64 @@ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxK
113
128
  setError(null);
114
129
  setStatus("enrolling");
115
130
  try {
131
+ // The vault was only checked on mount - another tab or device may have set encryption up since.
132
+ // Provisioning now would publish a second master key, orphaning whichever setup lost the race, so
133
+ // re-check right before generating anything. restapi's enrollKey() also refuses (409) wraps for a
134
+ // vault that already has them, for a race inside this window.
116
135
  // Only reachable via "setup_password", which the effect above only ever sets once mailboxUid
117
136
  // was defined (and mailboxAddress necessarily came with it - see KeyEnrollmentGateProps).
137
+ const fresh = await getKeyVault(mailboxUid);
138
+ if (fresh.wrappedKeys.length > 0 || fresh.masterKeyWraps.length > 0) {
139
+ setStatus("already_set_up");
140
+ return;
141
+ }
118
142
  const { recoveryCodes: codes } = await provisionEncryptionKey(mailboxUid, mailboxAddress, password);
119
143
  setRecoveryCodes(codes);
120
144
  setStatus("show_recovery_codes");
121
145
  }
122
146
  catch (err) {
147
+ // Only a 409 that is really "the vault already has wraps" - an unrelated lost optimistic-lock race is shown
148
+ // as an ordinary error.
149
+ if (err instanceof VaultAlreadyInitializedError) {
150
+ setStatus("already_set_up");
151
+ return;
152
+ }
123
153
  setError(err instanceof ApiRequestError ? err.message : "Could not set up encryption for this mailbox.");
124
154
  setStatus("setup_password");
125
155
  }
126
156
  }
157
+ async function handleCopyCodes() {
158
+ try {
159
+ await navigator.clipboard.writeText(recoveryCodes.join("\n"));
160
+ setCodesCopied(true);
161
+ setTimeout(() => setCodesCopied(false), 2000);
162
+ }
163
+ catch {
164
+ // Clipboard access can be denied by the browser - the codes are still selectable/copyable by
165
+ // hand from the list below.
166
+ }
167
+ }
127
168
  if (status === "checking") {
128
169
  return null;
129
170
  }
130
- if (status === "unlock" || status === "unlocking") {
171
+ if ((status === "unlock" || status === "unlocking") && blocking) {
131
172
  const unlocking = status === "unlocking";
132
- return (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Unlock your mailbox" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Enter your encryption password to unlock signing and reading protected mail this session." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleUnlock, children: [_jsx(FormField, { label: "Encryption password", htmlFor: "key-unlock-password", children: _jsx("input", { id: "key-unlock-password", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: password, onChange: (e) => setPassword(e.target.value), disabled: unlocking, autoComplete: "current-password" }) }), _jsx(Button, { type: "submit", loading: unlocking, disabled: unlocking, children: "Unlock" })] })] }) }));
173
+ return (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Unlock your mailbox" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: unlockMode === "password"
174
+ ? "Enter your encryption password to unlock signing and reading protected mail this session."
175
+ : "Enter one of your recovery codes to unlock signing and reading protected mail this session. Each code works once." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleUnlock, children: [unlockMode === "password" ? (_jsx(FormField, { label: "Encryption password", htmlFor: "key-unlock-password", children: _jsx("input", { id: "key-unlock-password", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: password, onChange: (e) => setPassword(e.target.value), disabled: unlocking, autoComplete: "current-password" }) })) : (_jsx(FormField, { label: "Recovery code", htmlFor: "key-unlock-recovery-code", children: _jsx("input", { id: "key-unlock-recovery-code", type: "text", className: "w-full text-sm font-mono border border-border rounded-sm py-1.5 px-2 bg-surface", value: recoveryCode, onChange: (e) => setRecoveryCode(e.target.value), disabled: unlocking, autoComplete: "off", autoCapitalize: "characters", spellCheck: false }) })), _jsx(Button, { type: "submit", loading: unlocking, disabled: unlocking, children: "Unlock" })] }), _jsx("div", { className: "mt-3", children: _jsx(UnlockModeToggle, { mode: unlockMode, disabled: unlocking, onChange: (next) => {
176
+ setUnlockMode(next);
177
+ setError(null);
178
+ } }) })] }) }));
133
179
  }
134
180
  if (status === "setup_password" || status === "enrolling") {
135
181
  const enrolling = status === "enrolling";
136
182
  return (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Protect your mailbox" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Choose a password to protect your encryption keys. This is separate from your sign-in password and is never sent to the server." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSetPassword, children: [_jsx(FormField, { label: "Encryption password", htmlFor: "key-enrollment-password", children: _jsx("input", { id: "key-enrollment-password", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: password, onChange: (e) => setPassword(e.target.value), disabled: enrolling, autoComplete: "new-password" }) }), _jsx(FormField, { label: "Confirm password", htmlFor: "key-enrollment-password-confirm", children: _jsx("input", { id: "key-enrollment-password-confirm", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: confirmPassword, onChange: (e) => setConfirmPassword(e.target.value), disabled: enrolling, autoComplete: "new-password" }) }), _jsx(Button, { type: "submit", loading: enrolling, disabled: enrolling, children: "Continue" })] })] }) }));
137
183
  }
184
+ if (status === "already_set_up") {
185
+ return (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Encryption is already set up" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "This mailbox\u2019s encryption was set up in another tab or on another device while this page was open, so nothing was changed here. Reload the page and unlock with the password chosen there." }), _jsx(Button, { type: "button", onClick: () => window.location.reload(), children: "Reload" })] }) }));
186
+ }
138
187
  if (status === "show_recovery_codes") {
139
- return (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Save your recovery codes" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "If you lose your password, these codes are the only way to recover your encrypted mail. Each code can be used once. Store them somewhere safe \u2014 they will not be shown again." }), _jsx("ul", { className: "grid grid-cols-2 gap-2 mb-5 font-mono text-sm", children: recoveryCodes.map((code) => (_jsx("li", { className: "bg-surface-alt rounded-sm py-1.5 px-2 text-center", children: code }, code))) }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: codesSaved, onChange: (e) => setCodesSaved(e.target.checked) }), "I have saved these recovery codes in a safe place."] }), _jsx(Button, { type: "button", disabled: !codesSaved, onClick: () => setStatus("ready"), children: "Continue" })] }) }));
188
+ return (_jsx("div", { className: "min-h-screen flex items-center justify-center p-8 bg-surface-alt", children: _jsxs("div", { className: "w-full max-w-md bg-surface border border-border rounded-md p-8", children: [_jsx("h1", { className: "text-lg font-bold mb-2", children: "Save your recovery codes" }), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "If you lose your password, these 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: recoveryCodes.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: () => setStatus("ready"), children: "Continue" })] }) }));
140
189
  }
141
- return _jsx(_Fragment, { children: children });
190
+ return (_jsxs(_Fragment, { children: [children, unopenableKeys && _jsx(UnopenableKeysNotice, { fingerprints: unopenableKeys, onDismiss: () => setUnopenableKeys(null) }), followUp && _jsx(RecoveryFollowUpModal, { followUp: followUp, onDone: () => setFollowUp(null) })] }));
142
191
  }
@@ -24,7 +24,10 @@ export default function MailboxProvisioning() {
24
24
  const [options, setOptions] = useState([]);
25
25
  const [selected, setSelected] = useState("");
26
26
  const [error, setError] = useState(null);
27
+ // Bumped by "Retry" to re-run the check below.
28
+ const [attempt, setAttempt] = useState(0);
27
29
  useEffect(() => {
30
+ setStatus("checking");
28
31
  autoProvisionMailbox()
29
32
  .then((result) => {
30
33
  if (result.status === "needs_selection") {
@@ -42,8 +45,10 @@ export default function MailboxProvisioning() {
42
45
  window.location.reload();
43
46
  }
44
47
  })
45
- .catch(() => setStatus("unavailable"));
46
- }, []);
48
+ // A 503 means the server couldn't read its own provisioning policy right now - not that there's no
49
+ // mailbox to be had - so it gets a retry instead of the permanent "ask an administrator" message.
50
+ .catch((err) => setStatus(err instanceof ApiRequestError && err.status === 503 ? "retryable" : "unavailable"));
51
+ }, [attempt]);
47
52
  async function handleConfirm() {
48
53
  // `selected` only ever takes a value from `options` itself (the initial default, or the
49
54
  // <select>'s own onChange, which can only report one of its own rendered <option> values), so
@@ -68,6 +73,9 @@ export default function MailboxProvisioning() {
68
73
  const creating = status === "creating";
69
74
  content = (_jsxs("div", { className: "w-full flex flex-col gap-3", children: [_jsx("p", { className: "text-sm text-text-muted text-center", children: "Choose your mailbox address to get started:" }), error && _jsx(Alert, { children: error }), _jsx("select", { "aria-label": "Mailbox address", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: selected, onChange: (e) => setSelected(e.target.value), disabled: creating, children: options.map((option) => (_jsx("option", { value: option.primarySmtpAddress, children: option.primarySmtpAddress }, option.primarySmtpAddress))) }), _jsx(Button, { type: "button", onClick: handleConfirm, loading: creating, disabled: creating, className: "!w-auto self-center", children: "Continue" })] }));
70
75
  }
76
+ else if (status === "retryable") {
77
+ content = (_jsxs(_Fragment, { children: [_jsx("h1", { className: "text-lg font-bold uppercase tracking-wide", children: "Couldn\u2019t check right now" }), _jsx("p", { className: "text-sm text-text-muted", children: "We couldn\u2019t check whether a mailbox can be set up for you. Please try again." }), _jsx(Button, { type: "button", onClick: () => setAttempt((n) => n + 1), className: "!w-auto self-center", children: "Retry" })] }));
78
+ }
71
79
  else {
72
80
  content = (_jsxs(_Fragment, { children: [_jsx("h1", { className: "text-lg font-bold uppercase tracking-wide", children: "No mailbox available" }), _jsx("p", { className: "text-sm text-text-muted", children: "Ask an administrator to create one for you." })] }));
73
81
  }