@rapidmx/web-client 0.36.0 → 0.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/apps/shared/calendar/calendarReminders.ts +35 -0
  2. package/apps/shared/calendar/sealedEvents.ts +42 -9
  3. package/apps/shared/calendar/useCalendarReminders.ts +28 -9
  4. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
  5. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +29 -1
  6. package/apps/shared/components/calendar/EventDetails.tsx +13 -2
  7. package/apps/shared/components/calendar/EventEditor.tsx +124 -17
  8. package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
  9. package/apps/shared/components/calendar/EventModal.tsx +19 -2
  10. package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
  11. package/apps/shared/components/calendar/eventForm.ts +2 -0
  12. package/apps/shared/components/contacts/ContactCard.tsx +13 -1
  13. package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
  14. package/apps/shared/components/contacts/ContactForm.tsx +56 -7
  15. package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
  16. package/apps/shared/components/contacts/contactCardData.ts +6 -3
  17. package/apps/shared/components/contacts/contactKeys.ts +3 -3
  18. package/apps/shared/components/layout/AppShell.tsx +34 -5
  19. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +11 -4
  20. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +37 -6
  21. package/apps/shared/components/mail/InviteCard.tsx +11 -1
  22. package/apps/shared/components/mail/MessageDetailPane.tsx +25 -14
  23. package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
  24. package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
  25. package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
  26. package/apps/shared/components/mail/invite/inviteStore.ts +11 -1
  27. package/apps/shared/components/mail/pinnedSigners.ts +14 -0
  28. package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
  29. package/apps/shared/components/settings/KeyExportSetting.tsx +4 -1
  30. package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
  31. package/apps/shared/components/settings/ZeroAccessSetting.tsx +13 -2
  32. package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
  33. package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
  34. package/apps/shared/mail/outbox/sendJob.ts +349 -50
  35. package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
  36. package/apps/shared/search/LocalIndexLifecycle.tsx +62 -33
  37. package/apps/shared/search/localIndexBuilder.ts +35 -24
  38. package/apps/shared/search/localIndexRpcClient.ts +91 -11
  39. package/apps/shared/search/localIndexSchema.ts +18 -2
  40. package/apps/shared/search/localIndexTransport.ts +6 -3
  41. package/apps/shared/search/localIndexWorker.ts +35 -18
  42. package/apps/shared/search/searchTier2.ts +5 -0
  43. package/apps/shared/search/zeroAccessIndexSync.ts +232 -40
  44. package/apps/shared/search/zeroAccessSyncState.ts +70 -0
  45. package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
  46. package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
  47. package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
  48. package/apps/www/_shell.tsx +2 -0
  49. package/apps/www/calendar/index.tsx +49 -5
  50. package/apps/www/contacts/index.tsx +67 -11
  51. package/apps/www/index.tsx +10 -1
  52. package/apps/www/settings/encryption/index.tsx +16 -1
  53. package/apps/www/settings/privacy/index.tsx +87 -38
  54. package/apps/www/tasks/index.tsx +24 -6
  55. package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
  56. package/dist/apps/shared/calendar/calendarReminders.js +26 -0
  57. package/dist/apps/shared/calendar/sealedEvents.d.ts +3 -2
  58. package/dist/apps/shared/calendar/sealedEvents.js +40 -9
  59. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
  60. package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
  61. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
  62. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +9 -2
  63. package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
  64. package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
  65. package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
  66. package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
  67. package/dist/apps/shared/components/calendar/EventModal.js +15 -2
  68. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
  69. package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
  70. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
  71. package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
  72. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
  73. package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
  74. package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
  75. package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
  76. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
  77. package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
  78. package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
  79. package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
  80. package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
  81. package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
  82. package/dist/apps/shared/components/layout/AppShell.js +17 -6
  83. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -4
  84. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +3 -1
  85. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +30 -4
  86. package/dist/apps/shared/components/mail/InviteCard.js +11 -1
  87. package/dist/apps/shared/components/mail/MessageDetailPane.js +15 -11
  88. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
  89. package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
  90. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
  91. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
  92. package/dist/apps/shared/components/mail/invite/inviteStore.js +11 -1
  93. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
  94. package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
  95. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
  96. package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
  97. package/dist/apps/shared/components/settings/KeyExportSetting.js +3 -1
  98. package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
  99. package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
  100. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +2 -0
  101. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +12 -3
  102. package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
  103. package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
  104. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  105. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  106. package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
  107. package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
  108. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
  109. package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
  110. package/dist/apps/shared/search/LocalIndexLifecycle.js +55 -33
  111. package/dist/apps/shared/search/localIndexBuilder.d.ts +4 -1
  112. package/dist/apps/shared/search/localIndexBuilder.js +31 -20
  113. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
  114. package/dist/apps/shared/search/localIndexRpcClient.js +126 -10
  115. package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
  116. package/dist/apps/shared/search/localIndexSchema.js +16 -2
  117. package/dist/apps/shared/search/localIndexTransport.d.ts +6 -3
  118. package/dist/apps/shared/search/localIndexWorker.d.ts +9 -0
  119. package/dist/apps/shared/search/localIndexWorker.js +22 -15
  120. package/dist/apps/shared/search/searchTier2.js +5 -0
  121. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +37 -6
  122. package/dist/apps/shared/search/zeroAccessIndexSync.js +199 -37
  123. package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
  124. package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
  125. package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
  126. package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
  127. package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
  128. package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
  129. package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
  130. package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
  131. package/dist/apps/www/_shell.d.ts +1 -1
  132. package/dist/apps/www/_shell.js +2 -2
  133. package/dist/apps/www/calendar/index.js +47 -5
  134. package/dist/apps/www/contacts/index.js +64 -11
  135. package/dist/apps/www/index.js +10 -1
  136. package/dist/apps/www/settings/encryption/index.js +14 -4
  137. package/dist/apps/www/settings/privacy/index.js +46 -5
  138. package/dist/apps/www/tasks/index.js +21 -7
  139. package/dist/lib/admin/domainsApi.d.ts +1 -1
  140. package/dist/lib/calendar/calendarApi.d.ts +46 -6
  141. package/dist/lib/calendar/calendarApi.js +110 -17
  142. package/dist/lib/calendar/calendarMutations.d.ts +20 -7
  143. package/dist/lib/calendar/calendarMutations.js +73 -14
  144. package/dist/lib/calendar/inviteApi.d.ts +17 -3
  145. package/dist/lib/calendar/inviteApi.js +61 -4
  146. package/dist/lib/calendar/protectedEvents.d.ts +31 -0
  147. package/dist/lib/calendar/protectedEvents.js +113 -0
  148. package/dist/lib/calendar/recurrence.d.ts +2 -0
  149. package/dist/lib/calendar/recurrence.js +16 -0
  150. package/dist/lib/calendar/sealedInvites.d.ts +6 -8
  151. package/dist/lib/calendar/sealedInvites.js +43 -6
  152. package/dist/lib/contacts/contactsApi.d.ts +29 -3
  153. package/dist/lib/contacts/contactsApi.js +23 -7
  154. package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
  155. package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
  156. package/dist/lib/crypto/composeSecurity.d.ts +11 -3
  157. package/dist/lib/crypto/composeSecurity.js +13 -5
  158. package/dist/lib/crypto/keyExport.js +2 -2
  159. package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
  160. package/dist/lib/crypto/keyPairMatch.js +65 -0
  161. package/dist/lib/crypto/keySession.d.ts +19 -2
  162. package/dist/lib/crypto/keySession.js +89 -13
  163. package/dist/lib/crypto/keyvaultApi.d.ts +41 -5
  164. package/dist/lib/crypto/keyvaultApi.js +13 -3
  165. package/dist/lib/crypto/meetingKeys.d.ts +25 -0
  166. package/dist/lib/crypto/meetingKeys.js +87 -0
  167. package/dist/lib/crypto/messageSecurity.d.ts +26 -2
  168. package/dist/lib/crypto/messageSecurity.js +122 -10
  169. package/dist/lib/crypto/openPgpKey.d.ts +39 -0
  170. package/dist/lib/crypto/openPgpKey.js +89 -0
  171. package/dist/lib/crypto/openpgp.d.ts +47 -0
  172. package/dist/lib/crypto/openpgp.js +88 -0
  173. package/dist/lib/crypto/pgpMessage.d.ts +51 -0
  174. package/dist/lib/crypto/pgpMessage.js +271 -0
  175. package/dist/lib/crypto/sharedMailboxKeys.d.ts +15 -3
  176. package/dist/lib/crypto/sharedMailboxKeys.js +102 -29
  177. package/dist/lib/crypto/smimeMessage.d.ts +21 -0
  178. package/dist/lib/crypto/smimeMessage.js +11 -7
  179. package/dist/lib/crypto/zeroAccessKey.js +5 -0
  180. package/dist/lib/mail/mailApi.d.ts +33 -0
  181. package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
  182. package/dist/lib/mail/mailboxImportApi.js +15 -0
  183. package/dist/lib/search/zeroAccessIndexApi.d.ts +4 -0
  184. package/dist/lib/tasks/tasksApi.d.ts +11 -1
  185. package/dist/lib/tasks/tasksApi.js +10 -4
  186. package/dist/lib/util/clientPlatform.d.ts +14 -0
  187. package/dist/lib/util/clientPlatform.js +26 -0
  188. package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
  189. package/dist/lib/videoconf/protectedMeetings.js +181 -0
  190. package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
  191. package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
  192. package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
  193. package/dist/lib/zeroAccess/sealedMoves.js +63 -0
  194. package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
  195. package/dist/lib/zeroAccess/sealedRecords.js +136 -0
  196. package/lib/admin/domainsApi.ts +1 -1
  197. package/lib/calendar/calendarApi.ts +140 -17
  198. package/lib/calendar/calendarMutations.ts +79 -12
  199. package/lib/calendar/inviteApi.ts +75 -5
  200. package/lib/calendar/protectedEvents.ts +143 -0
  201. package/lib/calendar/recurrence.ts +17 -0
  202. package/lib/calendar/sealedInvites.ts +47 -7
  203. package/lib/contacts/contactsApi.ts +43 -17
  204. package/lib/contacts/useContactPhotoSrc.ts +103 -13
  205. package/lib/crypto/composeSecurity.ts +20 -5
  206. package/lib/crypto/keyExport.ts +2 -2
  207. package/lib/crypto/keyPairMatch.ts +69 -0
  208. package/lib/crypto/keySession.ts +102 -12
  209. package/lib/crypto/keyvaultApi.ts +49 -7
  210. package/lib/crypto/meetingKeys.ts +118 -0
  211. package/lib/crypto/messageSecurity.ts +174 -13
  212. package/lib/crypto/openPgpKey.ts +118 -0
  213. package/lib/crypto/openpgp.ts +115 -0
  214. package/lib/crypto/pgpMessage.ts +336 -0
  215. package/lib/crypto/sharedMailboxKeys.ts +106 -28
  216. package/lib/crypto/smimeMessage.ts +11 -7
  217. package/lib/crypto/zeroAccessKey.ts +5 -0
  218. package/lib/mail/mailApi.ts +27 -0
  219. package/lib/mail/mailboxImportApi.ts +24 -0
  220. package/lib/search/zeroAccessIndexApi.ts +4 -0
  221. package/lib/tasks/tasksApi.ts +19 -5
  222. package/lib/util/clientPlatform.ts +50 -0
  223. package/lib/videoconf/protectedMeetings.ts +221 -0
  224. package/lib/videoconf/videoMeetingsApi.ts +15 -4
  225. package/lib/zeroAccess/sealedMoves.ts +79 -0
  226. package/lib/zeroAccess/sealedRecords.ts +153 -0
  227. package/package.json +12 -3
@@ -17,4 +17,7 @@ export interface ContactFormProps {
17
17
  * mobile detail route's own edit mode (`apps/www/contacts/[uid].tsx`) — creation itself stays
18
18
  * desktop-and-mobile-inline (see that route's own doc comment on why "new" never gets a dedicated route).
19
19
  */
20
- export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes, onSaved, onCancel }: ContactFormProps): React.JSX.Element;
20
+ /** The form for `props.contact` opened in full - its fields are read once, when it mounts, so it waits for a protected contact to open. One
21
+ * that can't be opened here (the mailbox is locked, or this device has no key for it) is never edited: its form would hold placeholders,
22
+ * and saving them would replace - or clear for good - the real details. */
23
+ export default function ContactForm(props: ContactFormProps): React.JSX.Element;
@@ -15,6 +15,8 @@ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
15
15
  import { useContactPhotoSrc } from "../../../../lib/contacts/useContactPhotoSrc.js";
16
16
  import { findWellKnownFolderUid } from "../../mail/findWellKnownFolderUid.js";
17
17
  import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
18
+ import { useOpenedRecord } from "../../zeroAccess/useOpenedRecord.js";
19
+ import { isOpenedRecord, isProtectedRecord, openSealedRecord, protectedRecordWrite } from "../../../../lib/zeroAccess/sealedRecords.js";
18
20
  const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
19
21
  const SELECT_CLASS = "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
20
22
  const ADDRESS_KINDS = ["home", "work", "other"];
@@ -23,8 +25,30 @@ const ADDRESS_KINDS = ["home", "work", "other"];
23
25
  * mobile detail route's own edit mode (`apps/www/contacts/[uid].tsx`) — creation itself stays
24
26
  * desktop-and-mobile-inline (see that route's own doc comment on why "new" never gets a dedicated route).
25
27
  */
26
- export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes, onSaved, onCancel }) {
28
+ /** The form for `props.contact` opened in full - its fields are read once, when it mounts, so it waits for a protected contact to open. One
29
+ * that can't be opened here (the mailbox is locked, or this device has no key for it) is never edited: its form would hold placeholders,
30
+ * and saving them would replace - or clear for good - the real details. */
31
+ export default function ContactForm(props) {
32
+ const { record, ready } = useOpenedRecord(props.contact);
33
+ // Bumped by the view's "Reload" to fill it again from the contact as it is now.
34
+ const [reloads, setReloads] = useState(0);
35
+ if (!ready) {
36
+ return _jsx("p", { className: "text-sm text-text-muted", children: "Opening this contact\u2026" });
37
+ }
38
+ if (isProtectedRecord(record) && !isOpenedRecord(record)) {
39
+ return (_jsxs("div", { className: "flex max-w-xl flex-col items-start gap-3 p-6", children: [_jsx("p", { role: "status", className: "text-sm", children: "This contact is protected and can\u2019t be edited while your mailbox is locked. Unlock it to edit." }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: props.onCancel, children: "Back" })] }));
40
+ }
41
+ // The server answers a save with the protected details sealed: what goes back is opened again.
42
+ const onSaved = (saved) => void openSealedRecord(saved).then(props.onSaved);
43
+ // Another contact is another form: its fields are filled from it.
44
+ return _jsx(ContactFormView, { ...props, contact: record, onSaved: onSaved, onReload: () => setReloads((n) => n + 1) }, `${record?.uid ?? ""}:${reloads}`);
45
+ }
46
+ function ContactFormView({ contact, mailboxUid, folderUid, mailboxes, onSaved, onCancel, onReload }) {
27
47
  const client = useApiClient();
48
+ // The contact as the form was filled from. A live update hands the open form the contact as changed elsewhere (`contact`), but the fields
49
+ // still hold this one's values: an update is sent at this version, and compared with it, so that change is refused (409) - never
50
+ // overwritten with what the form still shows.
51
+ const [seeded] = useState(contact);
28
52
  const currentPhotoUrl = useContactPhotoSrc(contact, client);
29
53
  const [targetMailboxUid, setTargetMailboxUid] = useState(mailboxUid);
30
54
  const [displayName, setDisplayName] = useState(contact?.displayName ?? "");
@@ -48,7 +72,7 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
48
72
  const [error, setError] = useState(null);
49
73
  const [saving, setSaving] = useState(false);
50
74
  // The contact as stored, once it has been saved and only the picture is left to do (that is a second request, which can fail on its own):
51
- // a retry must not create the contact again or send the version it started from. `key` is the form's content that was saved.
75
+ // a retry must not create the contact again or send the version it started from. `key` is the form's content that was saved, `input` its fields.
52
76
  const [stored, setStored] = useState(null);
53
77
  function updateEmail(index, patch) {
54
78
  setEmails((prev) => prev.map((e, i) => (i === index ? { ...e, ...patch } : e)));
@@ -83,7 +107,7 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
83
107
  .filter(Boolean);
84
108
  // An update merges only the fields in its body, so a field the user cleared is sent as an
85
109
  // explicit `null` (an omitted/undefined one would keep its stored value); a create just omits it.
86
- const existing = stored?.contact ?? contact;
110
+ const existing = stored?.contact ?? seeded;
87
111
  const cleared = existing ? null : undefined;
88
112
  const input = {
89
113
  displayName: displayName.trim(),
@@ -106,10 +130,13 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
106
130
  saved = stored.contact;
107
131
  }
108
132
  else if (existing) {
133
+ // A protected contact's details go only when they changed from what the form was shown (or last saved): the server stores
134
+ // them sealed and can't tell an unchanged value from a new one.
135
+ const shown = seeded && (stored?.input ? { ...seeded, ...stored.input } : seeded);
109
136
  saved = await updateContact({
110
137
  uid: existing.uid,
111
138
  version: existing.version,
112
- ...input,
139
+ ...protectedRecordWrite(shown, input, isOpenedRecord(seeded)),
113
140
  }, client);
114
141
  }
115
142
  else {
@@ -126,7 +153,7 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
126
153
  ...input,
127
154
  }, client);
128
155
  }
129
- setStored({ contact: saved, key });
156
+ setStored({ contact: saved, key, input });
130
157
  const changePhoto = async (current) => {
131
158
  if (pickedPhoto) {
132
159
  return uploadContactPhoto(current.uid, current.version, pickedPhoto, client);
@@ -143,7 +170,7 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
143
170
  }
144
171
  // A version that is stale means an earlier try had been applied after all (its answer never arrived): read the contact as it is and go again, once.
145
172
  const current = await getContact(saved.uid, client);
146
- setStored({ contact: current, key });
173
+ setStored({ contact: current, key, input });
147
174
  saved = await changePhoto(current);
148
175
  }
149
176
  }
@@ -162,12 +189,15 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
162
189
  setSaving(false);
163
190
  }
164
191
  }
192
+ // Changed elsewhere since the form was filled (or last saved): a save would be refused.
193
+ const baseline = stored?.contact ?? seeded;
194
+ const changedElsewhere = !!contact && !!baseline && contact.version > baseline.version;
165
195
  function finish(saved) {
166
196
  // A message opened later in this page load must see the changed contact's signing keys.
167
197
  clearPinnedSignerCache();
168
198
  onSaved(saved);
169
199
  }
170
- return (_jsxs("form", { onSubmit: handleSubmit, className: "@container flex min-h-0 flex-1 flex-col", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-6", children: _jsxs("div", { className: "flex max-w-xl flex-col gap-1", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-3", children: contact ? "Edit contact" : "New contact" }), error && _jsx(Alert, { children: error }), !contact && mailboxes && mailboxes.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "contact-mailbox", children: _jsx("select", { id: "contact-mailbox", className: `${SELECT_CLASS} w-full`, value: targetMailboxUid, onChange: (e) => setTargetMailboxUid(e.target.value), children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) }) })), _jsx(FormField, { label: "Display name", htmlFor: "contact-displayName", children: _jsx("input", { id: "contact-displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value) }) }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("div", { className: "min-w-0 flex-1", children: _jsx(ContactPhotoField, { displayName: displayName, email: emails[0]?.address, currentUrl: currentPhotoUrl, picked: pickedPhoto, removed: removePhoto, onPick: (file) => {
200
+ return (_jsxs("form", { onSubmit: handleSubmit, className: "@container flex min-h-0 flex-1 flex-col", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-6", children: _jsxs("div", { className: "flex max-w-xl flex-col gap-1", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-3", children: contact ? "Edit contact" : "New contact" }), error && _jsx(Alert, { children: error }), changedElsewhere && (_jsxs("div", { role: "status", className: "mb-3 flex flex-wrap items-center gap-3 text-sm", children: [_jsx("span", { children: "This contact was changed elsewhere while you were editing it, so saving would be refused. Reload it to edit the changed contact - what you typed here is lost." }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: saving, onClick: onReload, children: "Reload" })] })), !contact && mailboxes && mailboxes.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "contact-mailbox", children: _jsx("select", { id: "contact-mailbox", className: `${SELECT_CLASS} w-full`, value: targetMailboxUid, onChange: (e) => setTargetMailboxUid(e.target.value), children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) }) })), _jsx(FormField, { label: "Display name", htmlFor: "contact-displayName", children: _jsx("input", { id: "contact-displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value) }) }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("div", { className: "min-w-0 flex-1", children: _jsx(ContactPhotoField, { displayName: displayName, email: emails[0]?.address, currentUrl: currentPhotoUrl, picked: pickedPhoto, removed: removePhoto, onPick: (file) => {
171
201
  setPickedPhoto(file);
172
202
  setRemovePhoto(false);
173
203
  setError(null);
@@ -6,6 +6,8 @@ export interface KeyChangeReviewProps {
6
6
  /** The contact's address `resolveKeyConflict()` looks the contact up by; absent hides the actions. */
7
7
  address?: string;
8
8
  useType: "sign" | "encrypt";
9
+ /** `"openpgp"` for an OpenPGP certificate (pinned as `useType: "encrypt"`; it signs too); omitted for an S/MIME certificate. */
10
+ keyType?: "openpgp";
9
11
  /** Who the key belongs to, for the advice ("confirm with <name>"). */
10
12
  ownerName: string;
11
13
  /** The currently pinned key and when it was first trusted; absent (couldn't be loaded) hides the actions. */
@@ -37,4 +39,4 @@ export interface KeyChangeReviewProps {
37
39
  * key, calling `resolveKeyConflict()` against the pinned fingerprint shown. Shared by a message's "signing key changed"
38
40
  * notice and a contact's recorded key conflicts.
39
41
  */
40
- export default function KeyChangeReview({ mailboxUid, address, useType, ownerName, current, proposed, certificate, canReject, canResolve, onResolved, onPinnedKeyChanged, }: KeyChangeReviewProps): React.JSX.Element;
42
+ export default function KeyChangeReview({ mailboxUid, address, useType, keyType, ownerName, current, proposed, certificate, canReject, canResolve, onResolved, onPinnedKeyChanged, }: KeyChangeReviewProps): React.JSX.Element;
@@ -15,12 +15,12 @@ import { conflictSourceLabel, formatDate, groupFingerprint, keyChangeErrorMessag
15
15
  * key, calling `resolveKeyConflict()` against the pinned fingerprint shown. Shared by a message's "signing key changed"
16
16
  * notice and a contact's recorded key conflicts.
17
17
  */
18
- export default function KeyChangeReview({ mailboxUid, address, useType, ownerName, current, proposed, certificate, canReject, canResolve, onResolved, onPinnedKeyChanged, }) {
18
+ export default function KeyChangeReview({ mailboxUid, address, useType, keyType, ownerName, current, proposed, certificate, canReject, canResolve, onResolved, onPinnedKeyChanged, }) {
19
19
  const [confirmOpen, setConfirmOpen] = useState(false);
20
20
  const [busy, setBusy] = useState(null);
21
21
  const [error, setError] = useState(null);
22
22
  const [forbidden, setForbidden] = useState(false);
23
- const kind = useType === "sign" ? "signing" : "encryption";
23
+ const kind = keyType === "openpgp" ? "OpenPGP" : useType === "sign" ? "signing" : "encryption";
24
24
  const actionable = !!address && !!current && canResolve !== false && !forbidden;
25
25
  async function resolve(action) {
26
26
  setBusy(action);
@@ -32,6 +32,7 @@ export default function KeyChangeReview({ mailboxUid, address, useType, ownerNam
32
32
  action,
33
33
  expectedPinnedFingerprint: current.fingerprint,
34
34
  ...(action === "accept" && certificate !== undefined ? { certificate } : {}),
35
+ ...(keyType ? { type: keyType } : {}),
35
36
  });
36
37
  setConfirmOpen(false);
37
38
  onResolved(action);
@@ -11,13 +11,14 @@ import { addDays } from "date-fns";
11
11
  import { listCalendarEvents } from "../../../../lib/calendar/calendarApi.js";
12
12
  import { openSealedEvents } from "../../calendar/sealedEvents.js";
13
13
  import { expandAllOccurrences } from "../../../../lib/calendar/recurrence.js";
14
- import { createContact, listContactsInFolders } from "../../../../lib/contacts/contactsApi.js";
14
+ import { CONTACT_MATCH_FIELDS, createContact, listContactsInFolders } from "../../../../lib/contacts/contactsApi.js";
15
15
  import { parseVCards } from "../../../../lib/contacts/vcard.js";
16
16
  import { attachmentContentUrl, getMessage, listAttachments, listFolders, } from "../../../../lib/mail/mailApi.js";
17
17
  import { search } from "../../../../lib/search/searchApi.js";
18
18
  import { ApiRequestError } from "../../../../lib/util/api.js";
19
19
  import { ownMailboxes, primaryMailbox } from "../../mail/primaryMailbox.js";
20
20
  import { cardForAddress, contactInputFor, contactInputFromCard, findContactByAddress, isVCardAttachment, normalizeAddress, ownContactCard, withoutDuplicates, } from "./participantDetails.js";
21
+ import { openSealedRecord, openSealedRecords } from "../../../../lib/zeroAccess/sealedRecords.js";
21
22
  /** How many recent messages and upcoming events the card lists. */
22
23
  export const CARD_LIST_SIZE = 5;
23
24
  /** How far ahead "upcoming" looks, in days. */
@@ -40,7 +41,9 @@ export async function loadFolders(mailboxes, client) {
40
41
  /** The contact the address belongs to in `folders`' contacts folders, if any. */
41
42
  export async function findContact(folders, address, client) {
42
43
  const uids = folders.filter((folder) => folder.type === "contacts").map((folder) => folder.uid);
43
- return findContactByAddress(await listContactsInFolders(uids, client), address);
44
+ const found = findContactByAddress(await listContactsInFolders(uids, client), address);
45
+ // The card shows the one contact in full.
46
+ return found && (await openSealedRecord(found));
44
47
  }
45
48
  /** The last `CARD_LIST_SIZE` messages to or from `address` in `mailboxes`, newest first. */
46
49
  export async function loadRecentMessages(mailboxes, address, client) {
@@ -139,7 +142,7 @@ export async function buildOwnCardFile(mailbox, mailboxes, userUid, client) {
139
142
  * card's in the same file). A card that cannot be stored fails the whole call after the earlier ones were added - `AddCardsError` says how many.
140
143
  */
141
144
  export async function addCardsToAddressBook(cards, scope, client) {
142
- const { fresh, skipped } = withoutDuplicates(cards, await listContactsInFolders([scope.folderUid], client));
145
+ const { fresh, skipped } = withoutDuplicates(cards, await openSealedRecords(await listContactsInFolders([scope.folderUid], client), CONTACT_MATCH_FIELDS));
143
146
  let added = 0;
144
147
  for (const card of fresh) {
145
148
  try {
@@ -23,7 +23,7 @@ type ContactWithFirstSeen = Pick<Contact, "previousKeys"> & {
23
23
  keysFirstSeen?: number;
24
24
  };
25
25
  /** When `key` (one of `contact`'s pinned keys) started being trusted: when it replaced the newest previous key of its
26
- * use, else the contact's `keysFirstSeen`, else the key's own `notBefore`. */
26
+ * use and format, else the contact's `keysFirstSeen`, else the key's own `notBefore`. */
27
27
  export declare function keyPinnedSince(contact: ContactWithFirstSeen, key: PublicKey): number;
28
28
  export declare function formatDate(ms: number): string;
29
29
  export {};
@@ -5,7 +5,7 @@
5
5
  /** Display and error helpers for a contact's pinned keys and key changes (`KeyChangeReview`, `ContactDetailPane`,
6
6
  * `MessageDetailPane`). */
7
7
  import { ApiRequestError } from "../../../../lib/util/api.js";
8
- import { PinnedKeyChangedError } from "../../../../lib/crypto/keyvaultApi.js";
8
+ import { keyFormat, PinnedKeyChangedError } from "../../../../lib/crypto/keyvaultApi.js";
9
9
  export const KEY_CHANGE_STALE_MESSAGE = "This contact's keys changed while you were looking; reload to see the latest.";
10
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
11
  export const KEY_CHANGE_FORBIDDEN_MESSAGE = "You don't have permission to change this contact's keys.";
@@ -51,9 +51,9 @@ export function conflictSourceLabel(source) {
51
51
  return source === "header" ? "from an incoming message" : "by key discovery";
52
52
  }
53
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`. */
54
+ * use and format, else the contact's `keysFirstSeen`, else the key's own `notBefore`. */
55
55
  export function keyPinnedSince(contact, key) {
56
- const replaced = (contact.previousKeys ?? []).filter((previous) => previous.useType === key.useType).map((previous) => previous.replacedAt);
56
+ const replaced = (contact.previousKeys ?? []).filter((previous) => previous.useType === key.useType && keyFormat(previous) === keyFormat(key)).map((previous) => previous.replacedAt);
57
57
  if (replaced.length > 0) {
58
58
  return Math.max(...replaced);
59
59
  }
@@ -40,6 +40,23 @@ export interface AppShellProps extends PluginNavProps {
40
40
  branding?: Branding;
41
41
  /** The signed-in user's stored appearance preferences as the server rendered the page (its `appearance` prop), so the first paint already wears the theme and background - see `AppearanceProvider`. */
42
42
  appearance?: unknown;
43
+ /** A host that signs its account out itself - see `ShellSignOutHost`. Absent (the webmail), "Sign out" ends the one session this
44
+ * origin holds. */
45
+ signOutHost?: ShellSignOutHost;
46
+ }
47
+ /**
48
+ * How a host that keeps several accounts in one page (`tauri-client`: one webview, one key session, one local-index storage for all of
49
+ * them) signs out the account whose shell is mounted. The shell's own sign-out assumes one user per origin: it destroys every
50
+ * mailbox's unlocked keys and every local index on the device, posts auth-server's cookie logout and navigates to auth-server. With a
51
+ * host, "Sign out" still marks compose as signing out, clears this session's caches and notifications and saves open compose windows,
52
+ * but leaves the keys, the indexes, the logout and where to go next to the host.
53
+ */
54
+ export interface ShellSignOutHost {
55
+ /** Called first, where the shell would destroy every mailbox's unlocked keys: destroys the signing-out account's. */
56
+ destroyKeys(): void;
57
+ /** Called once open compose windows are saved: destroys the account's local indexes, ends its session and leaves the page. Should
58
+ * not reject; a rejection is logged. */
59
+ finish(): Promise<void>;
43
60
  }
44
61
  export interface AppDef {
45
62
  id: AppShellApp;
@@ -67,7 +84,7 @@ export interface AppChromeProps extends AppShellProps {
67
84
  * Rendered by the webmail's app shell (`apps/www/_shell.tsx`, the router's persistent client layout), once, for the life of the
68
85
  * page, so that moving between apps replaces only `children` - see `AppShell` below for what a page's own shell renders instead.
69
86
  */
70
- export declare function AppChrome({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, branding: initialBranding, appearance, pluginNav, busy, hideChrome, children, }: PropsWithChildren<AppChromeProps>): React.JSX.Element;
87
+ export declare function AppChrome({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, branding: initialBranding, appearance, pluginNav, busy, hideChrome, signOutHost, children, }: PropsWithChildren<AppChromeProps>): React.JSX.Element;
71
88
  /**
72
89
  * What a page's own shell (`MailShell`, `CalendarShell`, ...) renders around its content. Outside the client-side router it
73
90
  * is the whole `AppChrome`, as it always was. Inside it (the router's app shell, `apps/www/_shell.tsx`) the chrome is already mounted above the
@@ -100,7 +100,7 @@ export function appRailItems(pluginNav, resolved = {}) {
100
100
  * Rendered by the webmail's app shell (`apps/www/_shell.tsx`, the router's persistent client layout), once, for the life of the
101
101
  * page, so that moving between apps replaces only `children` - see `AppShell` below for what a page's own shell renders instead.
102
102
  */
103
- export function AppChrome({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, branding: initialBranding, appearance, pluginNav, busy, hideChrome, children, }) {
103
+ export function AppChrome({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, branding: initialBranding, appearance, pluginNav, busy, hideChrome, signOutHost, children, }) {
104
104
  const client = useApiClient();
105
105
  const [stoppingImpersonation, setStoppingImpersonation] = useState(false);
106
106
  // The keyboard shortcuts dialog: opened by `?`/Ctrl+/ (`GlobalShortcuts`) and by the account menu's item.
@@ -168,9 +168,10 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
168
168
  // own, so this is what actually removes the indexes after a console sign-out (the console also records a
169
169
  // pending deletion, retried on the next mail load, in case no mail tab is open). `destroyAllLocalIndexes()`
170
170
  // announces the sign-out on this same channel, which this tab then hears itself, so the ref is set first:
171
- // each tab reacts once, and the tab that started the sign-out ignores its own announcement.
171
+ // each tab reacts once, and the tab that started the sign-out ignores its own announcement. A host that signs out itself
172
+ // (`signOutHost`) keeps other accounts in this origin, so another tab's sign-out isn't this one's.
172
173
  useEffect(() => {
173
- if (!userUid || typeof BroadcastChannel === "undefined") {
174
+ if (!userUid || signOutHost || typeof BroadcastChannel === "undefined") {
174
175
  return;
175
176
  }
176
177
  const channel = new BroadcastChannel(SIGN_OUT_CHANNEL);
@@ -193,14 +194,19 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
193
194
  });
194
195
  });
195
196
  return () => channel.close();
196
- }, [userUid, authServerUrl]);
197
+ }, [userUid, authServerUrl, signOutHost]);
197
198
  async function handleSignOut() {
198
199
  signingOutRef.current = true;
199
200
  // Before flushing and navigating: compose windows then skip their "Leave site?" prompt, which could
200
201
  // otherwise cancel the sign-out's own navigation.
201
202
  markSigningOut();
202
- // Unlocked private keys never outlive an explicit sign-out.
203
- destroyUnlockedKeys();
203
+ // Unlocked private keys never outlive an explicit sign-out - only the signing-out account's where a host keeps others.
204
+ if (signOutHost) {
205
+ signOutHost.destroyKeys();
206
+ }
207
+ else {
208
+ destroyUnlockedKeys();
209
+ }
204
210
  // Trusted signer pins read from contacts don't outlive the session either.
205
211
  clearPinnedSignerCache();
206
212
  clearAppearanceCache();
@@ -209,6 +215,11 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
209
215
  // Open compose windows save edits still waiting on their autosave debounce while the session is still
210
216
  // valid - logout invalidates it. Bounded the same way as logout itself, and never rejects.
211
217
  await flushComposeDrafts(LOGOUT_TIMEOUT_MS);
218
+ if (signOutHost) {
219
+ // The host destroys its account's indexes (spec §11) and ends its session; this page can't tell its accounts apart.
220
+ await signOutHost.finish().catch((err) => console.warn("The host's sign-out failed:", err));
221
+ return;
222
+ }
212
223
  // The Tier 2 local index MUST be destroyed on explicit logout, the same as unlocked keys themselves
213
224
  // (spec §11). Destroys every index on this device (not only mailboxes opened this page load) and is
214
225
  // awaited before navigating - a navigation tears down the Worker mid-delete otherwise.
@@ -14,6 +14,7 @@ import { FrameTakeover } from "../../navigation/frameContext.js";
14
14
  import { MIN_PASSWORD_LENGTH, RecoveryFollowUpModal, UnlockModeToggle, startRecoveryUnlock, } from "./RecoveryCodeUnlock.js";
15
15
  import { ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, getUnlockedKeys, subscribeKeySession, unlockWithPassword, } from "../../../../lib/crypto/keySession.js";
16
16
  import { ensureZeroAccessKey, provisionZeroAccessKey } from "../../../../lib/crypto/zeroAccessKey.js";
17
+ import { ensureOpenPgpKey } from "../../../../lib/crypto/openPgpKey.js";
17
18
  import { buildAad, generateMasterKey, sealWithKey } from "../../../../lib/crypto/masterKey.js";
18
19
  import { buildPasswordWrap, buildRecoveryWraps } from "../../../../lib/crypto/masterKeyWraps.js";
19
20
  import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "../../../../lib/crypto/keys.js";
@@ -94,14 +95,19 @@ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxK
94
95
  cancelled = true;
95
96
  };
96
97
  }, [mailboxUid, canProvision]);
97
- // A mailbox set up before zero-access encryption existed has no zero-access key: give it one whenever its owner has
98
- // it unlocked - now, or on a later unlock from anywhere (this gate, an unlock prompt, recovery). Best effort: a
99
- // failure is retried on the next unlock.
98
+ // A mailbox set up before zero-access encryption existed has no zero-access key, and one set up before OpenPGP was supported
99
+ // has no OpenPGP key (restapi specs/end-to-end_encryption.md *OpenPGP Interoperability*): give it each whenever its owner has
100
+ // it unlocked - now, or on a later unlock from anywhere (this gate, an unlock prompt, recovery, first-time setup). Best
101
+ // effort: a failure is retried on the next unlock.
100
102
  useEffect(() => {
101
103
  if (!mailboxUid || !mailboxAddress || !canProvision) {
102
104
  return;
103
105
  }
104
- const ensure = () => void ensureZeroAccessKey(mailboxUid, mailboxAddress).catch(() => undefined);
106
+ // One after the other: both enrollments update the vault, which a concurrent write would refuse (409).
107
+ const ensure = () => void ensureZeroAccessKey(mailboxUid, mailboxAddress)
108
+ .catch(() => undefined)
109
+ .then(() => ensureOpenPgpKey(mailboxUid, mailboxAddress))
110
+ .catch(() => undefined);
105
111
  ensure();
106
112
  return subscribeKeySession((event) => {
107
113
  if (event.mailboxUid === mailboxUid && event.state === "unlocked") {
@@ -1,6 +1,8 @@
1
1
  import type { Mailbox } from "../../../../lib/mail/mailApi.js";
2
2
  /** How often the shared mailboxes are looked after again while some own mailbox stays unlocked. */
3
3
  export declare const SHARED_MAILBOX_CHECK_INTERVAL_MS: number;
4
+ /** Forgets which refused rotations were shown (tests). */
5
+ export declare function resetSharedMailboxKeyManager(): void;
4
6
  export interface SharedMailboxKeyManagerProps {
5
7
  /** Every mailbox the user can open. */
6
8
  mailboxes: Mailbox[];
@@ -10,6 +12,6 @@ export interface SharedMailboxKeyManagerProps {
10
12
  * Looks after the zero-access keys of the user's shared mailboxes (`lib/crypto/sharedMailboxKeys.ts`) whenever a mailbox of their own is
11
13
  * unlocked, and every `SHARED_MAILBOX_CHECK_INTERVAL_MS` after that: opens each mailbox shared with them through their member key wraps (no
12
14
  * password), and - for a mailbox they own or manage - provisions, rotates and shares its key with members still waiting. Best effort and
13
- * silent: a pass that fails is tried again on the next. Renders nothing; mounted by `MailShell`.
15
+ * silent (a pass that fails is tried again on the next), except for a rotation the server refuses, shown once as a warning. Renders nothing; mounted by `MailShell`.
14
16
  */
15
17
  export default function SharedMailboxKeyManager({ mailboxes, userUid }: SharedMailboxKeyManagerProps): null;
@@ -5,6 +5,7 @@
5
5
  import { useEffect, useRef } from "react";
6
6
  import { getUnlockedKeys, personallyUnlockedKeys, subscribeKeySession } from "../../../../lib/crypto/keySession.js";
7
7
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
8
+ import { notify } from "../../notifications/store.js";
8
9
  /** How often the shared mailboxes are looked after again while some own mailbox stays unlocked. */
9
10
  export const SHARED_MAILBOX_CHECK_INTERVAL_MS = 5 * 60 * 1000;
10
11
  /** The shared mailbox key code (pkijs and friends), loaded only once an own mailbox is unlocked. */
@@ -13,11 +14,17 @@ const loadSharedKeys = () => (loaded ?? (loaded = import("../../../../lib/crypto
13
14
  loaded = undefined;
14
15
  throw err;
15
16
  })));
17
+ /** The refused rotations already shown, per mailbox (the reason): each is shown once a session, not on every pass that retries it. */
18
+ const shownRefusals = new Map();
19
+ /** Forgets which refused rotations were shown (tests). */
20
+ export function resetSharedMailboxKeyManager() {
21
+ shownRefusals.clear();
22
+ }
16
23
  /**
17
24
  * Looks after the zero-access keys of the user's shared mailboxes (`lib/crypto/sharedMailboxKeys.ts`) whenever a mailbox of their own is
18
25
  * unlocked, and every `SHARED_MAILBOX_CHECK_INTERVAL_MS` after that: opens each mailbox shared with them through their member key wraps (no
19
26
  * password), and - for a mailbox they own or manage - provisions, rotates and shares its key with members still waiting. Best effort and
20
- * silent: a pass that fails is tried again on the next. Renders nothing; mounted by `MailShell`.
27
+ * silent (a pass that fails is tried again on the next), except for a rotation the server refuses, shown once as a warning. Renders nothing; mounted by `MailShell`.
21
28
  */
22
29
  export default function SharedMailboxKeyManager({ mailboxes, userUid }) {
23
30
  const client = useApiClient();
@@ -60,13 +67,32 @@ export default function SharedMailboxKeyManager({ mailboxes, userUid }) {
60
67
  }
61
68
  async function look(mailbox, me, client, unlockSharedMailbox, maintainSharedMailbox) {
62
69
  const owned = !!mailbox.ownerUserUid && mailbox.ownerUserUid === me;
63
- if (!owned && !getUnlockedKeys(mailbox.uid)) {
64
- await unlockSharedMailbox(mailbox.uid, client);
70
+ if (!owned) {
71
+ // Opened, or - once open - opened again when a rotation left it without the active key.
72
+ await unlockSharedMailbox(mailbox.uid, client, true);
65
73
  }
66
74
  const unlocked = getUnlockedKeys(mailbox.uid);
67
75
  // An owner looks after their mailbox with its own keys; a member, with full access, whether or not it is open yet (a mailbox
68
76
  // without an owner may have no key to open).
69
77
  if (owned ? unlocked && !unlocked.shared : true) {
70
- await maintainSharedMailbox(mailbox.uid, mailbox.primarySmtpAddress, client);
78
+ const done = await maintainSharedMailbox(mailbox.uid, mailbox.primarySmtpAddress, client);
79
+ // The server kept the current key (its vault can't take the new key's member wraps): say so once, members keep reading.
80
+ if (done.rotationRefused && shownRefusals.get(mailbox.uid) !== done.rotationRefused) {
81
+ shownRefusals.set(mailbox.uid, done.rotationRefused);
82
+ notify({
83
+ kind: "warning",
84
+ title: `${mailbox.displayName || mailbox.primarySmtpAddress}: the zero-access key wasn't replaced`,
85
+ message: "A member has left, but the server refused a new key for this shared mailbox. The current key stays active.",
86
+ details: done.rotationRefused,
87
+ dedupeKey: `shared-rotation-refused:${mailbox.uid}`,
88
+ });
89
+ }
90
+ else if (!done.rotationRefused) {
91
+ shownRefusals.delete(mailbox.uid);
92
+ }
93
+ // A key this member just made (a mailbox without an owner) goes into their session now, not on the next pass.
94
+ if (!owned && (done.provisioned || done.rotated)) {
95
+ await unlockSharedMailbox(mailbox.uid, client, true);
96
+ }
71
97
  }
72
98
  }
@@ -7,6 +7,7 @@ import React, { useId, useState } from "react";
7
7
  import { HiOutlineCalendarDays, HiOutlineCheck } from "react-icons/hi2";
8
8
  import { acceptProposal, proposeNewTime, removeMessageInvite, respondToMessageInvite, } from "../../../../lib/calendar/inviteApi.js";
9
9
  import { guestPermissionsOf } from "../../../../lib/calendar/calendarApi.js";
10
+ import { openSealedEvent } from "../../calendar/sealedEvents.js";
10
11
  import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
11
12
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
12
13
  import Button from "../../../../lib/components/buttons/Button.js";
@@ -134,7 +135,7 @@ export default function InviteCard({ messageUid, headingLevel = 2 }) {
134
135
  return (_jsxs("section", { "aria-labelledby": headingId, className: "rounded-sm border border-border bg-surface-alt px-3 py-3 flex flex-col gap-2 text-sm text-text", children: [_jsxs(Heading, { id: headingId, className: "flex items-center gap-2 text-sm font-semibold", children: [_jsx(HiOutlineCalendarDays, { size: 16, "aria-hidden": "true", className: "shrink-0" }), changeRequest ? CHANGE_REQUEST_HEADING : (HEADINGS[method] ?? "Calendar event")] }), isReply && (_jsxs("p", { className: "break-words", children: [senderNode, " ", (sender?.responseStatus && REPLY_VERBS[sender.responseStatus]) ?? "responded", "."] })), isCounter && !changeRequest && (_jsxs("p", { className: "break-words", children: [senderNode, " proposed a new time", when ? _jsxs(_Fragment, { children: [": ", when] }) : "."] })), changeRequest && (_jsx("p", { className: "break-words", children: changeRequest.applied ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "font-semibold", children: senderName }), "\u2019s change was applied."] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "font-semibold", children: senderName }), " requested a change."] })) })), _jsx("p", { className: "font-medium break-words", children: title }), !isReply && (_jsxs("dl", { className: "grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1", children: [when && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "When" }), _jsxs("dd", { className: "break-words", children: [when, invite.recurring && _jsx("span", { className: "text-text-muted", children: " (Repeats)" })] })] })), invite.location && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Where" }), _jsx("dd", { className: "break-words", children: invite.location })] })), hasDescription && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Description" }), _jsx("dd", { className: "break-words", children: _jsx(EventDescriptionView, { html: invite.descriptionHtml, text: invite.description }) })] })), invite.organizer && !isCounter && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Organizer" }), _jsx("dd", { className: "break-words", children: _jsx(ParticipantLink, { participant: invite.organizer, children: formatMailAddress(invite.organizer) }) })] })), attendees && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Attendees" }), _jsx("dd", { className: "break-words", children: attendees })] })), visibility && !isCounter && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Visibility" }), _jsx("dd", { className: "break-words", children: visibility })] }))] })), isReply && when && _jsx("p", { className: "text-text-muted break-words", children: when }), conflicts && (_jsxs("p", { className: "text-danger break-words", children: [_jsx("span", { className: "font-medium", children: "Conflicts with:" }), " ", conflicts] })), invite.guestPermissions && !isReply && !isCounter && method !== "CANCEL" && (_jsx("p", { className: "text-xs text-text-muted break-words", children: describeGuestPermissions(permissions) })), invite.isOrganizer && !isReply && !isCounter && _jsx("p", { className: "text-text-muted", children: "You are the organizer of this meeting." }), method === "CANCEL" && _jsx("p", { className: "font-medium text-danger", children: "This meeting was canceled." }), invite.response && !invite.isOrganizer && !isReply && !isCounter && (_jsx("p", { role: "status", className: `font-medium ${ANSWER_CLASS[invite.response]}`, children: ANSWER_TEXT[invite.response] })), isCounter && invite.response === "accepted" && !changeRequest?.applied && (_jsx("p", { role: "status", className: "font-medium text-success", children: "You accepted the proposed time." })), proposed && (_jsx("p", { role: "status", className: "font-medium text-text", children: "Your proposed time was sent to the organizer." })), requested && (_jsx("p", { role: "status", className: "font-medium text-text", children: "Your change was sent to the organizer." })), invite.outdated && _jsx("p", { className: "text-xs text-text-muted", children: "A newer version of this meeting is already on your calendar." }), hasActions && (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [showResponses && (_jsx("div", { role: "group", "aria-label": "Respond to this meeting", "aria-busy": busy || undefined, className: "flex flex-wrap gap-2", children: RESPONSES.map(({ response, label, failure }) => {
135
136
  const current = invite.response === response;
136
137
  return (_jsxs(Button, { type: "button", variant: "secondary", className: ["!w-auto", current ? "!bg-primary/10 !border-primary disabled:!opacity-100" : ""].join(" "), "aria-pressed": current, "aria-busy": pending === response || undefined, loading: pending === response, disabled: busy || current, onClick: () => void run(response, failure, () => respondToMessageInvite(messageUid, response, client)), children: [current && _jsx(HiOutlineCheck, { size: 14, "aria-hidden": "true" }), label] }, response));
137
- }) })), invite.canPropose && !proposing && (_jsx(Button, { type: "button", variant: "text", disabled: busy, onClick: () => setProposing(true), children: "Propose new time" })), canRequest && !requesting && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: busy, onClick: () => setRequesting(true), children: invite.canRequestChange ? "Request a change" : "Add guests" })), invite.canAcceptProposal && invite.response !== "accepted" && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "acceptProposal" || undefined, loading: pending === "acceptProposal", disabled: busy, onClick: () => void run("acceptProposal", "Couldn't accept the proposed time", () => acceptProposal(messageUid, client)), children: "Accept proposal" })), invite.canAdd && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "add" || undefined, loading: pending === "add", disabled: busy,
138
+ }) })), invite.canPropose && !proposing && (_jsx(Button, { type: "button", variant: "text", disabled: busy, onClick: () => setProposing(true), children: "Propose new time" })), canRequest && !requesting && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: busy, onClick: () => setRequesting(true), children: invite.canRequestChange ? "Request a change" : "Add guests" })), invite.canAcceptProposal && invite.response !== "accepted" && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "acceptProposal" || undefined, loading: pending === "acceptProposal", disabled: busy, onClick: () => void run("acceptProposal", "Couldn't accept the proposed time", () => acceptProposal(messageUid, client, invite.calendarEventUid, openSealedEvent)), children: "Accept proposal" })), invite.canAdd && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "add" || undefined, loading: pending === "add", disabled: busy,
138
139
  // A published event has no organizer to answer, so adding it is accepting it: the server sends no reply.
139
140
  onClick: () => void run("add", "Couldn't add this event to your calendar", () => respondToMessageInvite(messageUid, "accepted", client)), children: "Add to calendar" })), invite.canRemove && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "remove" || undefined, loading: pending === "remove", disabled: busy, onClick: () => void run("remove", "Couldn't remove this meeting from your calendar", () => removeMessageInvite(messageUid, client)), children: "Remove from calendar" })), invite.onCalendar && invite.calendarEventUid && (_jsx("a", { href: calendarHref(invite), className: "text-sm font-medium text-primary-dark hover:underline", children: "Open in Calendar" }))] })), requesting && (_jsx("div", { className: "rounded-md bg-surface p-3", children: _jsx(RequestChangeForm, { subject: {
140
141
  uid: invite.calendarEventUid,
@@ -147,6 +148,15 @@ export default function InviteCard({ messageUid, headingLevel = 2 }) {
147
148
  description: invite.description,
148
149
  descriptionHtml: invite.descriptionHtml,
149
150
  guestAddresses: invite.attendees.map((attendee) => attendee.address),
151
+ // The invitation's own content: what a protected copy of the event is named by.
152
+ invitationContent: invite.summary
153
+ ? {
154
+ title: invite.summary,
155
+ location: invite.location ?? undefined,
156
+ description: invite.description ?? undefined,
157
+ descriptionHtml: invite.descriptionHtml ?? undefined,
158
+ }
159
+ : undefined,
150
160
  }, canChange: !!invite.canRequestChange, canInvite: !!invite.canRequestInvite, onSent: () => {
151
161
  setRequesting(false);
152
162
  setRequested(true);