@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
@@ -4,7 +4,7 @@ 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, useEffect } 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
10
  import { getSetupStatus } from "@rapidmx/react-shared/admin/setupApi.js";
@@ -12,11 +12,38 @@ import { stopImpersonating } from "@rapidmx/react-shared/mail/mailApi.js";
12
12
  import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
13
13
  import { useIdleKeyTimeout } from "@rapidmx/react-shared/crypto/useIdleKeyTimeout.js";
14
14
  import ComposeProvider from "../mail/compose/ComposeContext.js";
15
+ import { flushComposeDrafts, markSigningOut } from "../mail/compose/composeFlushRegistry.js";
15
16
  import BottomTabBar from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
16
17
  import { BrandingFooter, BrandingHeader } from "./BrandingChrome.js";
17
18
  import UserMenu from "./UserMenu.js";
18
19
  import { UnlockPromptProvider } from "./UnlockPromptProvider.js";
19
- import { destroyAllLocalIndexes } from "../../search/localIndexRpcClient.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
+ }
20
47
  export const APPS = [
21
48
  { id: "mail", href: "/", label: "Mail", icon: HiOutlineEnvelope },
22
49
  { id: "calendar", href: "/calendar", label: "Calendar", icon: HiOutlineCalendarDays },
@@ -41,15 +68,17 @@ const ACTIVE_LABELS = {
41
68
  */
42
69
  export default function AppShell({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, children, }) {
43
70
  const [stoppingImpersonation, setStoppingImpersonation] = useState(false);
71
+ const signingOutRef = useRef(false);
44
72
  const { branding, iconSrc } = useBranding();
45
73
  useRedirectIfUnauthenticated(userUid, authServerUrl);
46
74
  // Mounted here, not scoped to Mail/Settings (the only shells that actually read unlocked keys),
47
75
  // specifically so activity in *any* app resets the idle clock - see that hook's own doc comment.
48
76
  useIdleKeyTimeout();
49
- // An administrator on a server that hasn't finished first-run setup is sent to the setup wizard. Everyone else
50
- // gets a 403 from the setup status check (it's admin-only), which is ignored along with any other failure.
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.
51
80
  useEffect(() => {
52
- if (!userUid || impersonating) {
81
+ if (!userUid || impersonating || !trusted) {
53
82
  return;
54
83
  }
55
84
  getSetupStatus()
@@ -59,14 +88,54 @@ export default function AppShell({ active, userUid, authServerUrl, impersonating
59
88
  }
60
89
  })
61
90
  .catch(() => undefined);
62
- }, [userUid, impersonating]);
63
- function handleSignOut() {
64
- // Best-effort, fire-and-forget: the Tier 2 local index MUST be destroyed on explicit logout, the
65
- // same as unlocked keys themselves (spec §11) - this component has no specific mailboxUid of its
66
- // own to pass (it's shared by every app, not just Mail), so this destroys every mailbox's index
67
- // the session has open rather than needing one threaded through. Not awaited - the navigation
68
- // below shouldn't wait on it, and destroyAllLocalIndexes() never throws either way.
69
- void destroyAllLocalIndexes();
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)]);
70
139
  window.location.href = authServerUrl ?? "/";
71
140
  }
72
141
  async function handleStopImpersonating() {
@@ -86,7 +155,7 @@ export default function AppShell({ active, userUid, authServerUrl, impersonating
86
155
  if (!userUid) {
87
156
  return _jsx("div", { className: "min-h-screen" });
88
157
  }
89
- return (_jsx(UnlockPromptProvider, { children: _jsx(ComposeProvider, { userUid: userUid, 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: [
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: [
90
159
  "w-10 h-10 flex items-center justify-center rounded-sm",
91
160
  id === active
92
161
  ? "bg-primary/10 text-primary-dark"
@@ -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,7 +50,7 @@ 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, blocking = true, 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("");
@@ -56,6 +58,11 @@ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxK
56
58
  const [recoveryCodes, setRecoveryCodes] = useState([]);
57
59
  const [codesSaved, setCodesSaved] = useState(false);
58
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);
59
66
  useEffect(() => {
60
67
  if (!mailboxUid) {
61
68
  return;
@@ -71,7 +78,7 @@ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxK
71
78
  getKeyVault(mailboxUid)
72
79
  .then((vault) => {
73
80
  if (!cancelled) {
74
- setStatus(vault.wrappedKeys.length > 0 ? "unlock" : "setup_password");
81
+ setStatus(vault.wrappedKeys.length > 0 ? "unlock" : canProvision ? "setup_password" : "ready");
75
82
  }
76
83
  })
77
84
  .catch(() => {
@@ -82,7 +89,7 @@ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxK
82
89
  return () => {
83
90
  cancelled = true;
84
91
  };
85
- }, [mailboxUid]);
92
+ }, [mailboxUid, canProvision]);
86
93
  async function handleUnlock(e) {
87
94
  e.preventDefault();
88
95
  setError(null);
@@ -90,14 +97,21 @@ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxK
90
97
  try {
91
98
  // Only reachable via "unlock", which the effect above only ever sets once mailboxUid was
92
99
  // defined.
93
- 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);
94
109
  setStatus("ready");
95
110
  }
96
- catch {
97
- // Deliberately generic - see `unlockWithPassword()`'s own doc comment: it throws the same way
98
- // for "no password wrap enrolled" and "wrong password" today, and this UI has no way to tell
99
- // those apart without leaking which is which to a potential attacker guessing passwords.
100
- 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));
101
115
  setStatus("unlock");
102
116
  }
103
117
  }
@@ -114,13 +128,28 @@ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxK
114
128
  setError(null);
115
129
  setStatus("enrolling");
116
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.
117
135
  // Only reachable via "setup_password", which the effect above only ever sets once mailboxUid
118
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
+ }
119
142
  const { recoveryCodes: codes } = await provisionEncryptionKey(mailboxUid, mailboxAddress, password);
120
143
  setRecoveryCodes(codes);
121
144
  setStatus("show_recovery_codes");
122
145
  }
123
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
+ }
124
153
  setError(err instanceof ApiRequestError ? err.message : "Could not set up encryption for this mailbox.");
125
154
  setStatus("setup_password");
126
155
  }
@@ -141,14 +170,22 @@ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxK
141
170
  }
142
171
  if ((status === "unlock" || status === "unlocking") && blocking) {
143
172
  const unlocking = status === "unlocking";
144
- 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
+ } }) })] }) }));
145
179
  }
146
180
  if (status === "setup_password" || status === "enrolling") {
147
181
  const enrolling = status === "enrolling";
148
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" })] })] }) }));
149
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
+ }
150
187
  if (status === "show_recovery_codes") {
151
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" })] }) }));
152
189
  }
153
- 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) })] }));
154
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
  }
@@ -0,0 +1,174 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ /**
7
+ * Unlocking with a recovery code, and what has to happen after it. A recovery code is single-use: once it has opened
8
+ * the keys, the user may set a new encryption password (`replacePasswordWrap()`), and then the used code is removed
9
+ * (`consumeRecoveryCode()`). The order matters - react-shared's `replacePasswordWrap()` refuses unless another own
10
+ * unlock method remains, and the not-yet-consumed code is that method.
11
+ *
12
+ * `UnlockPromptProvider` and `KeyEnrollmentGate` both unlock with `startRecoveryUnlock()`, settle whatever was waiting
13
+ * on the unlock straight away, and then show `RecoveryFollowUpModal` for the follow-up steps.
14
+ */
15
+ import { useState } from "react";
16
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
17
+ import { getUnlockedKeys, unlockWithRecoveryCode } from "@rapidmx/react-shared/crypto/keySession.js";
18
+ import { getKeyVault } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
19
+ import { KeysLockedError } from "@rapidmx/react-shared/crypto/masterKey.js";
20
+ import { PasswordWrapReplaceError, consumeRecoveryCode, replacePasswordWrap, } from "@rapidmx/react-shared/crypto/masterKeyWraps.js";
21
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
22
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
23
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
24
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
25
+ /** The same minimum every other "set an encryption password" form in this app uses. */
26
+ export const MIN_PASSWORD_LENGTH = 8;
27
+ /** Where recovery codes are regenerated. */
28
+ export const ENCRYPTION_SETTINGS_HREF = "/settings/encryption";
29
+ /** Deliberately generic: a wrong code and a vault with no recovery codes fail the same way. */
30
+ export const RECOVERY_CODE_ERROR = "That recovery code didn't work.";
31
+ export const REPLACE_ROTATED_MESSAGE = "Your encryption keys were changed on another device after you unlocked, so your password wasn't changed and your recovery code wasn't used. Reload the page and unlock again.";
32
+ export const REPLACE_MULTIPLE_PASSWORDS_MESSAGE = "This mailbox has more than one encryption password, so it can't be replaced here. Nothing was changed. Remove the extra password in Settings > Encryption first.";
33
+ export const REPLACE_NO_OTHER_METHOD_MESSAGE = "Your password wasn't changed, because this mailbox would be left with no other way to unlock. Your recovery code may already have been used in another window. Nothing was changed.";
34
+ export const REPLACE_ADD_FAILED_RESTORED_MESSAGE = "Your new password couldn't be saved, so your old password still works. Nothing was changed. Try again.";
35
+ export const REPLACE_ADD_FAILED_NOT_RESTORED_MESSAGE = "Your new password couldn't be saved, and your old password couldn't be put back, so this mailbox has no password right now. Your recovery code still works. Try again, or keep the code for now.";
36
+ export const REPLACE_LOCKED_MESSAGE = "Your encryption keys were locked before your new password could be set, so nothing was changed and your recovery code still works. Unlock with it again to set a new password.";
37
+ export const REPLACE_GENERIC_MESSAGE = "Your new password couldn't be saved. Nothing was changed. Try again.";
38
+ export const CONSUME_FAILED_MESSAGE = "The recovery code you used couldn't be removed, so it may still work. Regenerate your recovery codes in Settings > Encryption to make sure it can't be used again.";
39
+ /**
40
+ * Unlocks `mailboxUid` with a recovery code (normalized by react-shared, so spacing and case don't matter). The
41
+ * vault's `masterKeyGeneration` is read first: if another device rotates the master key at any point after that read,
42
+ * the generation passed to `replacePasswordWrap()` later is stale and the replacement is refused, never written with
43
+ * a dead master key. A failed read just means no generation check. Rejects exactly as `unlockWithRecoveryCode()` does.
44
+ */
45
+ export async function startRecoveryUnlock(mailboxUid, mailboxKeys, code) {
46
+ const expectedMasterKeyGeneration = await getKeyVault(mailboxUid).then((vault) => vault.masterKeyGeneration, () => undefined);
47
+ const result = await unlockWithRecoveryCode(mailboxUid, mailboxKeys, code);
48
+ return {
49
+ mailboxUid,
50
+ recoveryMethodId: result.recoveryMethodId,
51
+ remainingRecoveryCodes: result.remainingRecoveryCodes,
52
+ expectedMasterKeyGeneration,
53
+ unopenableKeys: result.unopenableKeys,
54
+ };
55
+ }
56
+ /** A "use a recovery code instead" / "use your password instead" switch for an unlock form. */
57
+ export function UnlockModeToggle({ mode, disabled, onChange, }) {
58
+ return (_jsx(Button, { type: "button", variant: "text", className: "!w-auto", disabled: disabled, onClick: () => onChange(mode === "password" ? "recovery" : "password"), children: mode === "password" ? "Use a recovery code instead" : "Use your password instead" }));
59
+ }
60
+ function replaceFailure(err) {
61
+ if (err instanceof PasswordWrapReplaceError) {
62
+ switch (err.reason) {
63
+ case "master_key_rotated":
64
+ return { message: REPLACE_ROTATED_MESSAGE, blocked: { message: REPLACE_ROTATED_MESSAGE, reload: true } };
65
+ case "multiple_password_wraps":
66
+ return {
67
+ message: REPLACE_MULTIPLE_PASSWORDS_MESSAGE,
68
+ blocked: { message: REPLACE_MULTIPLE_PASSWORDS_MESSAGE, allowSecondary: true },
69
+ };
70
+ case "no_other_unlock_method":
71
+ return { message: REPLACE_NO_OTHER_METHOD_MESSAGE, blocked: { message: REPLACE_NO_OTHER_METHOD_MESSAGE } };
72
+ default:
73
+ return { message: err.restored ? REPLACE_ADD_FAILED_RESTORED_MESSAGE : REPLACE_ADD_FAILED_NOT_RESTORED_MESSAGE };
74
+ }
75
+ }
76
+ if (err instanceof KeysLockedError) {
77
+ return { message: REPLACE_LOCKED_MESSAGE, blocked: { message: REPLACE_LOCKED_MESSAGE } };
78
+ }
79
+ return { message: err instanceof ApiRequestError ? `${REPLACE_GENERIC_MESSAGE} (${err.message})` : REPLACE_GENERIC_MESSAGE };
80
+ }
81
+ /**
82
+ * The steps after a recovery-code unlock, shown once the keys are already open:
83
+ *
84
+ * 1. Offer a new encryption password. Optional ("Skip") - unless this was the last code, where it's required before the
85
+ * code is used up (otherwise only a forgotten password would remain); then "Keep this code for now" closes without
86
+ * consuming anything.
87
+ * 2. Remove the used code. A failure is reported but never blocks anything - the keys are already open.
88
+ * 3. Say how many codes are left, nudging towards regenerating them at two or fewer.
89
+ *
90
+ * Mount it with a `key` per follow-up so each unlock starts from step 1.
91
+ */
92
+ export function RecoveryFollowUpModal({ followUp, open = true, onDone }) {
93
+ const { mailboxUid, recoveryMethodId, remainingRecoveryCodes, expectedMasterKeyGeneration } = followUp;
94
+ const required = remainingRecoveryCodes === 0;
95
+ const [step, setStep] = useState("password");
96
+ const [newPassword, setNewPassword] = useState("");
97
+ const [confirmPassword, setConfirmPassword] = useState("");
98
+ const [error, setError] = useState(null);
99
+ const [blocked, setBlocked] = useState(null);
100
+ const [busy, setBusy] = useState(false);
101
+ const [passwordSet, setPasswordSet] = useState(false);
102
+ const [consumeFailed, setConsumeFailed] = useState(false);
103
+ async function consume() {
104
+ let failed = !recoveryMethodId;
105
+ if (recoveryMethodId) {
106
+ try {
107
+ await consumeRecoveryCode(mailboxUid, recoveryMethodId);
108
+ }
109
+ catch (err) {
110
+ // 404: already gone (e.g. consumed from another tab) - that's the outcome wanted.
111
+ failed = !(err instanceof ApiRequestError && err.status === 404);
112
+ }
113
+ }
114
+ setConsumeFailed(failed);
115
+ setStep("done");
116
+ }
117
+ async function handleSetPassword(e) {
118
+ e.preventDefault();
119
+ if (newPassword.length < MIN_PASSWORD_LENGTH) {
120
+ setError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
121
+ return;
122
+ }
123
+ if (newPassword !== confirmPassword) {
124
+ setError("Passwords do not match.");
125
+ return;
126
+ }
127
+ setError(null);
128
+ setBusy(true);
129
+ try {
130
+ // Read now, not at unlock time: an idle timeout or a lock elsewhere may have destroyed the keys since.
131
+ const unlocked = getUnlockedKeys(mailboxUid);
132
+ if (!unlocked || unlocked.destroyed) {
133
+ throw new KeysLockedError();
134
+ }
135
+ // Before consuming - the unused code is the other unlock method that makes replacing safe.
136
+ await replacePasswordWrap(mailboxUid, unlocked, newPassword, expectedMasterKeyGeneration);
137
+ }
138
+ catch (err) {
139
+ const failure = replaceFailure(err);
140
+ setBlocked(failure.blocked ?? null);
141
+ setError(failure.blocked ? null : failure.message);
142
+ setBusy(false);
143
+ return;
144
+ }
145
+ setNewPassword("");
146
+ setConfirmPassword("");
147
+ setPasswordSet(true);
148
+ await consume();
149
+ setBusy(false);
150
+ }
151
+ async function handleSkip() {
152
+ setError(null);
153
+ setBusy(true);
154
+ await consume();
155
+ setBusy(false);
156
+ }
157
+ const title = step === "done" ? "Recovery code used" : "Set a new encryption password";
158
+ const secondary = !blocked || !!blocked.allowSecondary;
159
+ function handleClose() {
160
+ if (busy) {
161
+ return;
162
+ }
163
+ // Closing the password step means the same as its secondary action - never a silent password change, and the
164
+ // last code is never consumed without a new password.
165
+ if (step === "password" && !required && secondary) {
166
+ void handleSkip();
167
+ return;
168
+ }
169
+ onDone();
170
+ }
171
+ return (_jsx(Modal, { open: open, onClose: handleClose, title: title, children: step === "password" ? (_jsxs(_Fragment, { children: [required ? (_jsx("p", { className: "text-sm text-text-muted mb-4", children: "That was your last recovery code. Set a new encryption password before it\u2019s used up - otherwise a password you\u2019ve forgotten would be the only way left to unlock this mailbox." })) : (_jsx("p", { className: "text-sm text-text-muted mb-4", children: "Your mailbox is unlocked. If you\u2019ve forgotten your encryption password, set a new one now. The recovery code you used will then be used up." })), blocked ? (_jsxs(_Fragment, { children: [_jsx(Alert, { children: blocked.message }), _jsxs("div", { className: "flex gap-2", children: [blocked.reload && (_jsx(Button, { type: "button", className: "!w-auto", onClick: () => window.location.reload(), children: "Reload page" })), !secondary && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onDone, children: "Close" }))] })] })) : (_jsxs("form", { onSubmit: handleSetPassword, children: [error && _jsx(Alert, { children: error }), _jsx(FormField, { label: "New encryption password", htmlFor: "recovery-new-password", children: _jsx("input", { id: "recovery-new-password", type: "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: busy, autoComplete: "new-password", autoFocus: true }) }), _jsx(FormField, { label: "Confirm new password", htmlFor: "recovery-new-password-confirm", children: _jsx("input", { id: "recovery-new-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: busy, autoComplete: "new-password" }) }), _jsx(Button, { type: "submit", className: "!w-auto", loading: busy, disabled: busy, children: "Set password" })] })), secondary && (_jsx("div", { className: "mt-2", children: required ? (_jsx(Button, { type: "button", variant: "text", className: "!w-auto", disabled: busy, onClick: onDone, children: "Keep this code for now" })) : (_jsx(Button, { type: "button", variant: "text", className: "!w-auto", disabled: busy, onClick: handleSkip, children: "Skip" })) }))] })) : (_jsxs(_Fragment, { children: [passwordSet && _jsx("p", { className: "text-sm mb-3", children: "Your new encryption password is set." }), consumeFailed ? (_jsx("p", { role: "status", className: "mb-3 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: CONSUME_FAILED_MESSAGE })) : (_jsx("p", { className: "text-sm mb-3", children: "The recovery code you used has been used up and won\u2019t work again." })), _jsxs("p", { className: "text-sm mb-3", children: [remainingRecoveryCodes === 0
172
+ ? "You have no recovery codes left."
173
+ : `You have ${remainingRecoveryCodes} recovery ${remainingRecoveryCodes === 1 ? "code" : "codes"} left.`, remainingRecoveryCodes <= 2 && (_jsxs(_Fragment, { children: [" ", "Generate a new set in", " ", _jsx("a", { href: ENCRYPTION_SETTINGS_HREF, className: "text-primary-dark hover:underline", children: "Settings > Encryption" }), " ", "with Regenerate recovery codes."] }))] }), _jsx(Button, { type: "button", className: "!w-auto", onClick: onDone, children: "Done" })] })) }));
174
+ }