@rapidmx/web-client 0.35.0 → 0.37.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 (268) hide show
  1. package/apps/escrow/matters/[uid].tsx +4 -3
  2. package/apps/shared/calendar/calendarReminders.ts +35 -0
  3. package/apps/shared/calendar/sealedEvents.ts +113 -0
  4. package/apps/shared/calendar/useCalendarReminders.ts +28 -9
  5. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
  6. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +89 -3
  7. package/apps/shared/components/calendar/EventDetails.tsx +13 -2
  8. package/apps/shared/components/calendar/EventEditor.tsx +124 -17
  9. package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
  10. package/apps/shared/components/calendar/EventModal.tsx +19 -2
  11. package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
  12. package/apps/shared/components/calendar/eventForm.ts +2 -0
  13. package/apps/shared/components/contacts/ContactCard.tsx +13 -1
  14. package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
  15. package/apps/shared/components/contacts/ContactForm.tsx +56 -7
  16. package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
  17. package/apps/shared/components/contacts/contactCardData.ts +8 -4
  18. package/apps/shared/components/contacts/contactKeys.ts +3 -3
  19. package/apps/shared/components/layout/AppShell.tsx +34 -5
  20. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +30 -1
  21. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +122 -0
  22. package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
  23. package/apps/shared/components/mail/ConversationList.tsx +1 -1
  24. package/apps/shared/components/mail/InviteCard.tsx +11 -1
  25. package/apps/shared/components/mail/MessageDetailPane.tsx +59 -26
  26. package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
  27. package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
  28. package/apps/shared/components/mail/compose/draftResume.ts +28 -3
  29. package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
  30. package/apps/shared/components/mail/compose/quotedBody.ts +4 -3
  31. package/apps/shared/components/mail/invite/inviteStore.ts +17 -1
  32. package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
  33. package/apps/shared/components/mail/pinnedSigners.ts +14 -0
  34. package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
  35. package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
  36. package/apps/shared/components/settings/KeyExportSetting.tsx +173 -0
  37. package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
  38. package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
  39. package/apps/shared/components/settings/ZeroAccessSetting.tsx +128 -0
  40. package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
  41. package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
  42. package/apps/shared/mail/decryptedMessages.ts +11 -3
  43. package/apps/shared/mail/outbox/sendJob.ts +349 -50
  44. package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
  45. package/apps/shared/mail/reportNotices.ts +3 -1
  46. package/apps/shared/mail/zeroAccessSource.ts +27 -0
  47. package/apps/shared/search/LocalIndexLifecycle.tsx +84 -24
  48. package/apps/shared/search/localIndexBuilder.ts +57 -20
  49. package/apps/shared/search/localIndexRpcClient.ts +96 -12
  50. package/apps/shared/search/localIndexSchema.ts +20 -4
  51. package/apps/shared/search/localIndexTransport.ts +6 -2
  52. package/apps/shared/search/localIndexWorker.ts +37 -18
  53. package/apps/shared/search/searchTier2.ts +5 -0
  54. package/apps/shared/search/zeroAccessIndexSync.ts +679 -0
  55. package/apps/shared/search/zeroAccessSyncState.ts +70 -0
  56. package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
  57. package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
  58. package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
  59. package/apps/www/_shell.tsx +2 -0
  60. package/apps/www/calendar/index.tsx +225 -61
  61. package/apps/www/contacts/index.tsx +67 -11
  62. package/apps/www/index.tsx +19 -3
  63. package/apps/www/settings/encryption/index.tsx +21 -1
  64. package/apps/www/settings/privacy/index.tsx +89 -39
  65. package/apps/www/tasks/index.tsx +24 -6
  66. package/dist/apps/escrow/matters/[uid].js +2 -2
  67. package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
  68. package/dist/apps/shared/calendar/calendarReminders.js +26 -0
  69. package/dist/apps/shared/calendar/sealedEvents.d.ts +14 -0
  70. package/dist/apps/shared/calendar/sealedEvents.js +85 -0
  71. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
  72. package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
  73. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
  74. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +25 -2
  75. package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
  76. package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
  77. package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
  78. package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
  79. package/dist/apps/shared/components/calendar/EventModal.js +15 -2
  80. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
  81. package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
  82. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
  83. package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
  84. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
  85. package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
  86. package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
  87. package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
  88. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
  89. package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
  90. package/dist/apps/shared/components/contacts/contactCardData.js +8 -4
  91. package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
  92. package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
  93. package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
  94. package/dist/apps/shared/components/layout/AppShell.js +17 -6
  95. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +27 -2
  96. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +17 -0
  97. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +98 -0
  98. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
  99. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
  100. package/dist/apps/shared/components/mail/ConversationList.js +1 -1
  101. package/dist/apps/shared/components/mail/InviteCard.js +11 -1
  102. package/dist/apps/shared/components/mail/MessageDetailPane.js +46 -21
  103. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
  104. package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
  105. package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
  106. package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
  107. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
  108. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
  109. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +1 -1
  110. package/dist/apps/shared/components/mail/compose/quotedBody.js +4 -3
  111. package/dist/apps/shared/components/mail/invite/inviteStore.js +16 -1
  112. package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
  113. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
  114. package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
  115. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
  116. package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
  117. package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
  118. package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
  119. package/dist/apps/shared/components/settings/KeyExportSetting.js +90 -0
  120. package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
  121. package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
  122. package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
  123. package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
  124. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +16 -0
  125. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +80 -0
  126. package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
  127. package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
  128. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  129. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  130. package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
  131. package/dist/apps/shared/mail/decryptedMessages.js +11 -3
  132. package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
  133. package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
  134. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
  135. package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
  136. package/dist/apps/shared/mail/reportNotices.js +3 -1
  137. package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
  138. package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
  139. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
  140. package/dist/apps/shared/search/LocalIndexLifecycle.js +74 -24
  141. package/dist/apps/shared/search/localIndexBuilder.d.ts +9 -1
  142. package/dist/apps/shared/search/localIndexBuilder.js +50 -17
  143. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
  144. package/dist/apps/shared/search/localIndexRpcClient.js +129 -11
  145. package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
  146. package/dist/apps/shared/search/localIndexSchema.js +18 -4
  147. package/dist/apps/shared/search/localIndexTransport.d.ts +6 -2
  148. package/dist/apps/shared/search/localIndexWorker.d.ts +11 -0
  149. package/dist/apps/shared/search/localIndexWorker.js +22 -15
  150. package/dist/apps/shared/search/searchTier2.js +5 -0
  151. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +102 -0
  152. package/dist/apps/shared/search/zeroAccessIndexSync.js +544 -0
  153. package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
  154. package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
  155. package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
  156. package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
  157. package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
  158. package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
  159. package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
  160. package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
  161. package/dist/apps/www/_shell.d.ts +1 -1
  162. package/dist/apps/www/_shell.js +2 -2
  163. package/dist/apps/www/calendar/index.js +88 -20
  164. package/dist/apps/www/contacts/index.js +64 -11
  165. package/dist/apps/www/index.js +19 -3
  166. package/dist/apps/www/settings/encryption/index.js +16 -4
  167. package/dist/apps/www/settings/privacy/index.js +47 -6
  168. package/dist/apps/www/tasks/index.js +21 -7
  169. package/dist/lib/admin/domainsApi.d.ts +1 -1
  170. package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
  171. package/dist/lib/calendar/calendarApi.d.ts +52 -6
  172. package/dist/lib/calendar/calendarApi.js +110 -17
  173. package/dist/lib/calendar/calendarMutations.d.ts +20 -7
  174. package/dist/lib/calendar/calendarMutations.js +73 -14
  175. package/dist/lib/calendar/inviteApi.d.ts +17 -3
  176. package/dist/lib/calendar/inviteApi.js +86 -7
  177. package/dist/lib/calendar/protectedEvents.d.ts +31 -0
  178. package/dist/lib/calendar/protectedEvents.js +113 -0
  179. package/dist/lib/calendar/recurrence.d.ts +2 -0
  180. package/dist/lib/calendar/recurrence.js +16 -0
  181. package/dist/lib/calendar/sealedInvites.d.ts +9 -0
  182. package/dist/lib/calendar/sealedInvites.js +65 -0
  183. package/dist/lib/contacts/contactsApi.d.ts +29 -3
  184. package/dist/lib/contacts/contactsApi.js +23 -7
  185. package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
  186. package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
  187. package/dist/lib/crypto/composeSecurity.d.ts +11 -3
  188. package/dist/lib/crypto/composeSecurity.js +13 -5
  189. package/dist/lib/crypto/keyExport.d.ts +54 -0
  190. package/dist/lib/crypto/keyExport.js +161 -0
  191. package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
  192. package/dist/lib/crypto/keyPairMatch.js +65 -0
  193. package/dist/lib/crypto/keySession.d.ts +64 -3
  194. package/dist/lib/crypto/keySession.js +179 -12
  195. package/dist/lib/crypto/keyvaultApi.d.ts +129 -8
  196. package/dist/lib/crypto/keyvaultApi.js +45 -3
  197. package/dist/lib/crypto/meetingKeys.d.ts +25 -0
  198. package/dist/lib/crypto/meetingKeys.js +87 -0
  199. package/dist/lib/crypto/messageSecurity.d.ts +56 -3
  200. package/dist/lib/crypto/messageSecurity.js +201 -13
  201. package/dist/lib/crypto/mime.d.ts +5 -0
  202. package/dist/lib/crypto/mime.js +25 -0
  203. package/dist/lib/crypto/openPgpKey.d.ts +39 -0
  204. package/dist/lib/crypto/openPgpKey.js +89 -0
  205. package/dist/lib/crypto/openpgp.d.ts +47 -0
  206. package/dist/lib/crypto/openpgp.js +88 -0
  207. package/dist/lib/crypto/pgpMessage.d.ts +51 -0
  208. package/dist/lib/crypto/pgpMessage.js +271 -0
  209. package/dist/lib/crypto/sharedMailboxKeys.d.ts +62 -0
  210. package/dist/lib/crypto/sharedMailboxKeys.js +337 -0
  211. package/dist/lib/crypto/smimeMessage.d.ts +21 -0
  212. package/dist/lib/crypto/smimeMessage.js +11 -7
  213. package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
  214. package/dist/lib/crypto/zeroAccessKey.js +71 -0
  215. package/dist/lib/mail/conversationsApi.d.ts +6 -0
  216. package/dist/lib/mail/mailApi.d.ts +63 -2
  217. package/dist/lib/mail/mailApi.js +5 -1
  218. package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
  219. package/dist/lib/mail/mailboxImportApi.js +15 -0
  220. package/dist/lib/search/zeroAccessIndexApi.d.ts +85 -0
  221. package/dist/lib/search/zeroAccessIndexApi.js +35 -0
  222. package/dist/lib/tasks/tasksApi.d.ts +11 -1
  223. package/dist/lib/tasks/tasksApi.js +10 -4
  224. package/dist/lib/util/clientPlatform.d.ts +14 -0
  225. package/dist/lib/util/clientPlatform.js +26 -0
  226. package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
  227. package/dist/lib/videoconf/protectedMeetings.js +181 -0
  228. package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
  229. package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
  230. package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
  231. package/dist/lib/zeroAccess/sealedMoves.js +63 -0
  232. package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
  233. package/dist/lib/zeroAccess/sealedRecords.js +136 -0
  234. package/lib/admin/domainsApi.ts +1 -1
  235. package/lib/admin/escrowAccessRequestsApi.ts +6 -0
  236. package/lib/calendar/calendarApi.ts +143 -17
  237. package/lib/calendar/calendarMutations.ts +79 -12
  238. package/lib/calendar/inviteApi.ts +101 -8
  239. package/lib/calendar/protectedEvents.ts +143 -0
  240. package/lib/calendar/recurrence.ts +17 -0
  241. package/lib/calendar/sealedInvites.ts +73 -0
  242. package/lib/contacts/contactsApi.ts +43 -17
  243. package/lib/contacts/useContactPhotoSrc.ts +103 -13
  244. package/lib/crypto/composeSecurity.ts +20 -5
  245. package/lib/crypto/keyExport.ts +229 -0
  246. package/lib/crypto/keyPairMatch.ts +69 -0
  247. package/lib/crypto/keySession.ts +216 -12
  248. package/lib/crypto/keyvaultApi.ts +174 -10
  249. package/lib/crypto/meetingKeys.ts +118 -0
  250. package/lib/crypto/messageSecurity.ts +284 -16
  251. package/lib/crypto/mime.ts +26 -0
  252. package/lib/crypto/openPgpKey.ts +118 -0
  253. package/lib/crypto/openpgp.ts +115 -0
  254. package/lib/crypto/pgpMessage.ts +336 -0
  255. package/lib/crypto/sharedMailboxKeys.ts +402 -0
  256. package/lib/crypto/smimeMessage.ts +11 -7
  257. package/lib/crypto/zeroAccessKey.ts +102 -0
  258. package/lib/mail/conversationsApi.ts +3 -0
  259. package/lib/mail/mailApi.ts +60 -3
  260. package/lib/mail/mailboxImportApi.ts +24 -0
  261. package/lib/search/zeroAccessIndexApi.ts +123 -0
  262. package/lib/tasks/tasksApi.ts +19 -5
  263. package/lib/util/clientPlatform.ts +50 -0
  264. package/lib/videoconf/protectedMeetings.ts +221 -0
  265. package/lib/videoconf/videoMeetingsApi.ts +15 -4
  266. package/lib/zeroAccess/sealedMoves.ts +79 -0
  267. package/lib/zeroAccess/sealedRecords.ts +153 -0
  268. package/package.json +14 -5
@@ -6,13 +6,17 @@ import { jsx as _jsx } from "react/jsx-runtime";
6
6
  import { useEffect, useMemo, useRef, useState } from "react";
7
7
  import { format } from "date-fns";
8
8
  import { ApiRequestError } from "../../../../lib/util/api.js";
9
+ import { KeysLockedError } from "../../../../lib/crypto/masterKey.js";
9
10
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
10
11
  import { createCalendarEvent, getCalendarEvent, guestPermissionsOf, updateCalendarEvent, visibilityOf, } from "../../../../lib/calendar/calendarApi.js";
11
12
  import { createVideoMeeting, getVideoMeeting, updateVideoMeeting, } from "../../../../lib/videoconf/videoMeetingsApi.js";
12
13
  import { detachOccurrence, saveEventSeries } from "../../../../lib/calendar/calendarMutations.js";
14
+ import { isProtectedRecord } from "../../../../lib/zeroAccess/sealedRecords.js";
15
+ import { followMeetingKeyExpiry, meetingKeyExpiry, organizerJoinLink, protectMeeting, rememberMeetingKey } from "../../../../lib/videoconf/protectedMeetings.js";
13
16
  import { toDatetimeLocal } from "../../../../lib/util/dateInput.js";
14
17
  import { deviceTimeZone, timeZoneOptions } from "../../../../lib/util/timeZone.js";
15
- import { fromEventWallClock, toEventWallClock } from "../../../../lib/calendar/recurrence.js";
18
+ import { useKeysLocked } from "../../zeroAccess/useKeysLocked.js";
19
+ import { fromEventWallClock, seriesEndDate, toEventWallClock } from "../../../../lib/calendar/recurrence.js";
16
20
  import { addDaysToKey, allDayDateKey, allDayInstant, recurrenceUntilDateKey, recurrenceUntilInstant, startWeekdayCode } from "./allDay.js";
17
21
  import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
18
22
  import EventExpandedForm from "./EventExpandedForm.js";
@@ -33,6 +37,22 @@ export const VIDEO_LOCATION_PLACEHOLDER = "Video call — link in this invitatio
33
37
  /** The plugin's own title bound (`BaseVideoMeetingRoute`'s `MAX_TITLE_LENGTH`) - a longer title is a 400, so
34
38
  * an over-long event title is trimmed to fit rather than failing the whole meeting. */
35
39
  const MAX_MEETING_TITLE_LENGTH = 200;
40
+ /** Who a video meeting minted for an event with `guests` invites: every attendee with an address but the organizer. */
41
+ function meetingInviteesOf(guests, organizerAddress) {
42
+ const organizerAddressLower = organizerAddress.toLowerCase();
43
+ return guests
44
+ .filter((a) => !a.isOrganizer && !!a.address.trim() && a.address.trim().toLowerCase() !== organizerAddressLower)
45
+ .map((a) => ({ email: a.address.trim(), displayName: a.displayName }));
46
+ }
47
+ /** The content a save of `fields` invites with, for the server to send a protected event's invitations with. */
48
+ function invitationContentOfFields(fields) {
49
+ return {
50
+ title: fields.title,
51
+ location: fields.location || undefined,
52
+ description: fields.description || undefined,
53
+ descriptionHtml: fields.descriptionHtml || undefined,
54
+ };
55
+ }
36
56
  const MS_PER_MINUTE = 60000;
37
57
  const MS_PER_HOUR = 3600000;
38
58
  const MS_PER_DAY = 86400000;
@@ -183,16 +203,26 @@ export default function EventEditor({ layout, onExpand, onCancel, onSaved, onSyn
183
203
  // `undefined` while the link is still being fetched, `null` once it is known there is none to open.
184
204
  const [organizerJoinUrl, setOrganizerJoinUrl] = useState(undefined);
185
205
  const [videoError, setVideoError] = useState(null);
206
+ /** Why the meeting just made isn't protected end to end - shown beside the toggle, not as an error. */
207
+ const [videoNotice, setVideoNotice] = useState(null);
186
208
  // The event as this modal last persisted it. Set only when a save got the event stored but could not
187
209
  // finish the video-conferencing step after it: the modal then stays open on that error, and the retry
188
210
  // must update *that* record (its uid and its new version), not re-create it or reuse the stale prop.
189
211
  const [savedEvent, setSavedEvent] = useState(null);
212
+ // `savedEvent` as of the last render, for sending deferred invitations when the dialog goes away (`flushDeferredInvitations()`).
213
+ const savedEventRef = useRef(null);
214
+ savedEventRef.current = savedEvent;
190
215
  // Whether that stored record is a series saved with `toSeriesFields()`: its master's dates are not the form's (the occurrence's), so a retry
191
216
  // must not send them - the first save already applied any change of time.
192
217
  const savedAsSeries = useRef(false);
193
218
  // A meeting minted for this event whose link onto the event could not be saved. A retry reuses it rather than minting a second one,
194
219
  // which would send the invitees a second set of join links and leave this one orphaned.
195
220
  const mintedMeeting = useRef(null);
221
+ // The content of a new event whose invitations its create deferred (`invitationContent: { defer: true }`) to the save that links its
222
+ // video meeting, so attendees get one invitation, with the link - until a save has sent them. That save carries this content too, for
223
+ // a protected event this device couldn't open; and should the meeting never be linked, a retry without video, or closing the dialog
224
+ // (`flushDeferredInvitations()`), sends them without it - best effort, since the dialog can also be left in ways that don't pass here.
225
+ const deferredInvitations = useRef(null);
196
226
  const [editScope, setEditScope] = useState("occurrence");
197
227
  const [error, setError] = useState(null);
198
228
  const [saving, setSaving] = useState(false);
@@ -209,7 +239,7 @@ export default function EventEditor({ layout, onExpand, onCancel, onSaved, onSyn
209
239
  }
210
240
  let cancelled = false;
211
241
  void getVideoMeeting(uid, client)
212
- .then((meeting) => meeting.organizerJoinUrl ?? null)
242
+ .then(organizerJoinLink)
213
243
  .catch(() => null)
214
244
  .then((url) => {
215
245
  if (!cancelled) {
@@ -220,6 +250,15 @@ export default function EventEditor({ layout, onExpand, onCancel, onSaved, onSyn
220
250
  cancelled = true;
221
251
  };
222
252
  }, [occurrence?.videoMeetingUid, client]);
253
+ // A protected meeting's links carry its key after `#`, opened (or made) with the organizer's mailbox keys: they go when any mailbox locks
254
+ // - this dialog can be saving to another mailbox than the event's. A meeting made here but not yet linked keeps its uid, so a retry
255
+ // doesn't make a second one; it is linked without a link to show.
256
+ useKeysLocked(() => {
257
+ setOrganizerJoinUrl((url) => (url?.includes("#") ? null : url));
258
+ if (mintedMeeting.current?.organizerJoinUrl?.includes("#")) {
259
+ mintedMeeting.current = { uid: mintedMeeting.current.uid, organizerJoinUrl: null };
260
+ }
261
+ });
223
262
  // "This event only" detaches a standalone, non-repeating copy - the series' rule doesn't apply to it.
224
263
  const editingSingleOccurrence = !!occurrence?.isRecurringOccurrence && editScope === "occurrence";
225
264
  dirtyRef.current = occurrence
@@ -335,10 +374,16 @@ export default function EventEditor({ layout, onExpand, onCancel, onSaved, onSyn
335
374
  *
336
375
  * Never throws: the event is already stored by the time this runs, so a failure here is reported inline
337
376
  * next to the checkbox and left retryable, with `videoMeetingUid` still whatever it was before.
377
+ *
378
+ * `rescheduled`: the save changed the event's times or recurrence. A protected meeting's key is discarded an hour after the end it
379
+ * was made for, so a linked meeting's key expiry then follows the saved event's (series') new end - best effort.
338
380
  */
339
- async function applyVideoConferencing(saved, guests) {
340
- // Off and never on, or on and already linked (see the limitation above) - nothing to do either way.
381
+ async function applyVideoConferencing(saved, guests, rescheduled) {
382
+ // Off and never on, or on and already linked (see the limitation above) - nothing to do either way, but for the key expiry.
341
383
  if (videoEnabled === !!videoMeetingUid) {
384
+ if (videoEnabled && rescheduled && saved.videoMeetingUid) {
385
+ await followMeetingKeyExpiry(saved.videoMeetingUid, seriesEndDate(saved), client);
386
+ }
342
387
  return { event: saved, failed: false };
343
388
  }
344
389
  // Detaching a single occurrence (`detachOccurrence()`, react-shared's `calendarMutations.ts`)
@@ -357,25 +402,30 @@ export default function EventEditor({ layout, onExpand, onCancel, onSaved, onSyn
357
402
  }
358
403
  try {
359
404
  if (videoEnabled) {
360
- const organizerAddressLower = saved.organizer.address.toLowerCase();
361
- const invitees = guests
362
- .filter((a) => !a.isOrganizer && !!a.address.trim() && a.address.trim().toLowerCase() !== organizerAddressLower)
363
- .map((a) => ({ email: a.address.trim(), displayName: a.displayName }));
405
+ const invitees = meetingInviteesOf(guests, saved.organizer.address);
364
406
  if (invitees.length === 0) {
365
407
  setVideoError("Add at least one attendee other than yourself to add video conferencing.");
366
408
  return { event: saved, failed: true };
367
409
  }
368
410
  if (!mintedMeeting.current) {
411
+ // Protected end to end when the organizer's mailbox has zero-access: its title and names sealed, its key in every link.
412
+ const title = saved.title.slice(0, MAX_MEETING_TITLE_LENGTH);
413
+ const secured = await protectMeeting(saved.mailboxUid, title, invitees, meetingKeyExpiry(seriesEndDate(saved)));
369
414
  const result = await createVideoMeeting({
370
415
  mailboxUid: saved.mailboxUid,
371
- title: saved.title.slice(0, MAX_MEETING_TITLE_LENGTH),
416
+ ...(secured
417
+ ? { protection: secured.protection, invitees: secured.invitees }
418
+ : // Stored readable: a protected event's real title never goes there.
419
+ { title: isProtectedRecord(saved) ? "Meeting" : title, invitees }),
372
420
  visibility: "private",
373
421
  calendarEventUid: saved.uid,
374
422
  startTime: saved.startDate,
375
423
  endTime: saved.endDate,
376
- invitees,
377
424
  }, client);
378
- mintedMeeting.current = { uid: result.meeting.uid, organizerJoinUrl: result.organizerJoinUrl ?? null };
425
+ rememberMeetingKey(result.meeting.uid, secured?.fragment ?? null, saved.mailboxUid);
426
+ setVideoNotice(secured ? null : "This meeting isn't end-to-end protected because zero-access encryption is off (or locked) for your mailbox.");
427
+ const organizerUrl = result.organizerJoinUrl ?? null;
428
+ mintedMeeting.current = { uid: result.meeting.uid, organizerJoinUrl: organizerUrl && secured ? `${organizerUrl}#${secured.fragment}` : organizerUrl };
379
429
  }
380
430
  const meeting = mintedMeeting.current;
381
431
  const patched = await updateCalendarEvent({
@@ -383,8 +433,11 @@ export default function EventEditor({ layout, onExpand, onCancel, onSaved, onSyn
383
433
  version: saved.version,
384
434
  location: VIDEO_LOCATION_PLACEHOLDER,
385
435
  videoMeetingUid: meeting.uid,
386
- }, client);
436
+ // The invitations the create deferred go out with this save.
437
+ ...(deferredInvitations.current ? { invitationContent: { ...deferredInvitations.current, location: VIDEO_LOCATION_PLACEHOLDER } } : {}),
438
+ }, client, saved);
387
439
  mintedMeeting.current = null;
440
+ deferredInvitations.current = null;
388
441
  setVideoMeetingUid(meeting.uid);
389
442
  setOrganizerJoinUrl(meeting.organizerJoinUrl);
390
443
  update({ location: VIDEO_LOCATION_PLACEHOLDER });
@@ -395,7 +448,7 @@ export default function EventEditor({ layout, onExpand, onCancel, onSaved, onSyn
395
448
  // A stored event clears a field with an explicit `null` (see `EventFields`) - an omitted one would
396
449
  // leave the placeholder (and the link to the now-cancelled meeting) in place.
397
450
  const patch = { location: restoredLocation || null, videoMeetingUid: null };
398
- const patched = await updateCalendarEvent({ uid: saved.uid, version: saved.version, ...patch }, client);
451
+ const patched = await updateCalendarEvent({ uid: saved.uid, version: saved.version, ...patch }, client, saved);
399
452
  setVideoMeetingUid(undefined);
400
453
  setOrganizerJoinUrl(undefined);
401
454
  update({ location: restoredLocation });
@@ -486,6 +539,9 @@ export default function EventEditor({ layout, onExpand, onCancel, onSaved, onSyn
486
539
  autoReplyEnabled: values.autoReplyEnabled,
487
540
  autoReplyMessage: values.autoReplyEnabled ? values.autoReplyMessage : cleared,
488
541
  };
542
+ // A save that will link a new video meeting to the event right after. A new event's invitations wait for that save (restapi sends a
543
+ // protected one's at save time), so they go out once, with the link and its keys.
544
+ const willLinkMeeting = videoEnabled && !videoMeetingUid && meetingInviteesOf(merged.attendees, effectiveOrganizerAddress).length > 0;
489
545
  setSaving(true);
490
546
  try {
491
547
  let saved;
@@ -504,7 +560,16 @@ export default function EventEditor({ layout, onExpand, onCancel, onSaved, onSyn
504
560
  delete retryFields.timezone;
505
561
  delete retryFields.allDay;
506
562
  }
507
- saved = await updateCalendarEvent({ uid: savedEvent.uid, version: savedEvent.version, ...retryFields }, client);
563
+ // A create's deferred invitations still wait for the meeting's link; without one to link, this save sends them.
564
+ const deferred = deferredInvitations.current;
565
+ if (deferred) {
566
+ deferredInvitations.current = invitationContentOfFields(fields);
567
+ retryFields.invitationContent = willLinkMeeting ? { defer: true } : deferredInvitations.current;
568
+ }
569
+ saved = await updateCalendarEvent({ uid: savedEvent.uid, version: savedEvent.version, ...retryFields }, client, savedEvent);
570
+ if (deferred && !willLinkMeeting) {
571
+ deferredInvitations.current = null;
572
+ }
508
573
  }
509
574
  else if (!occurrence) {
510
575
  // The organizer is only ever set on create - an edit keeps the event's own organizer.
@@ -513,7 +578,9 @@ export default function EventEditor({ layout, onExpand, onCancel, onSaved, onSyn
513
578
  folderUid: values.targetFolderUid,
514
579
  ...fields,
515
580
  organizer: { address: effectiveOrganizerAddress, type: "to" },
581
+ ...(willLinkMeeting ? { invitationContent: { defer: true } } : {}),
516
582
  }, client);
583
+ deferredInvitations.current = willLinkMeeting ? invitationContentOfFields(fields) : null;
517
584
  }
518
585
  else if (editingSingleOccurrence) {
519
586
  saved = await detachOccurrence(occurrence, fields, client);
@@ -525,9 +592,15 @@ export default function EventEditor({ layout, onExpand, onCancel, onSaved, onSyn
525
592
  detachedSyncFailed = !!series.detachedOccurrenceSyncFailed;
526
593
  }
527
594
  else {
528
- saved = await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...fields }, client);
595
+ saved = await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...fields }, client, occurrence);
529
596
  }
530
- const video = await applyVideoConferencing(saved, merged.attendees);
597
+ // Against the record this save started from: a false positive only costs a look at the meeting.
598
+ const before = savedEvent ?? occurrence;
599
+ const rescheduled = !!before &&
600
+ (!sameMinute(before.startDate, startDate) ||
601
+ !sameMinute(before.endDate, endDate) ||
602
+ JSON.stringify(before.recurrenceRule ?? null) !== JSON.stringify(fields.recurrenceRule ?? null));
603
+ const video = await applyVideoConferencing(saved, merged.attendees, rescheduled);
531
604
  if (video.failed) {
532
605
  // The event itself is saved - keep the modal open on the inline error so the video meeting
533
606
  // can be retried (or the toggle put back) rather than closing over it.
@@ -542,12 +615,35 @@ export default function EventEditor({ layout, onExpand, onCancel, onSaved, onSyn
542
615
  onSaved();
543
616
  }
544
617
  catch (err) {
545
- setError(err instanceof ApiRequestError ? err.message : "Could not save this event.");
618
+ // `KeysLockedError`: one occurrence of a protected event, detached while the mailbox is locked (nothing was written).
619
+ setError(err instanceof ApiRequestError || err instanceof KeysLockedError ? err.message : "Could not save this event.");
546
620
  }
547
621
  finally {
548
622
  setSaving(false);
549
623
  }
550
624
  }
625
+ /** Sends the invitations a create deferred when the dialog is closed before a save sent them (the video meeting couldn't be linked):
626
+ * a save of no change, carrying their content. Best effort - the event is stored either way, and a failure leaves them pending until
627
+ * the event is saved again. Only a protected event's are sent at save time; restapi's scheduling job sends any other's. */
628
+ async function flushDeferredInvitations() {
629
+ const content = deferredInvitations.current;
630
+ const stored = savedEventRef.current;
631
+ deferredInvitations.current = null;
632
+ if (!content || !stored || !isProtectedRecord(stored)) {
633
+ return;
634
+ }
635
+ try {
636
+ await updateCalendarEvent({ uid: stored.uid, version: stored.version, invitationContent: content }, client, stored);
637
+ }
638
+ catch {
639
+ // Left pending: the next save of the event sends them.
640
+ }
641
+ }
642
+ // However the dialog goes away - its own close button waits for this, an Escape or a click outside a new event's popover doesn't pass
643
+ // through it - deferred invitations still pending are sent.
644
+ const flushOnUnmount = useRef(flushDeferredInvitations);
645
+ flushOnUnmount.current = flushDeferredInvitations;
646
+ useEffect(() => () => void flushOnUnmount.current(), []);
551
647
  // Same scheme allow-list `calendarReminders.ts#joinMeetingUrl()` already applies to a reminder's own
552
648
  // `location` field - reused here rather than duplicated so a video-conferencing plugin (or a stray
553
649
  // manual edit of `videoMeetingUid`'s otherwise-plugin-owned join link) can't smuggle a `javascript:`/
@@ -583,6 +679,7 @@ export default function EventEditor({ layout, onExpand, onCancel, onSaved, onSyn
583
679
  joinUrl,
584
680
  organizerJoinUrl,
585
681
  videoError,
682
+ videoNotice,
586
683
  editScope,
587
684
  setEditScope,
588
685
  showEditScope: !!occurrence?.isRecurringOccurrence,
@@ -590,7 +687,7 @@ export default function EventEditor({ layout, onExpand, onCancel, onSaved, onSyn
590
687
  saving,
591
688
  onSubmit: (event) => void handleSubmit(event),
592
689
  // Once something was stored, leaving is a save as far as the calendar behind is concerned: it has to reload.
593
- onCancel: () => (savedEvent ? onSaved() : onCancel()),
690
+ onCancel: () => (savedEvent ? void flushDeferredInvitations().then(onSaved) : onCancel()),
594
691
  };
595
692
  // A new event has the tabs, whose Event face is the quick popover or the full card; an existing event is always the full card.
596
693
  return quickCreate ? (_jsx(QuickCreateFaces, { c: controller, layout: layout, onExpand: onExpand, config: quickCreate, dirtyRef: dirtyRef, organizerAddress: effectiveOrganizerAddress })) : (_jsx(EventExpandedForm, { c: controller }));
@@ -33,7 +33,7 @@ export function LocationRow({ c }) {
33
33
  /** "Add video conferencing" and, once the event has a meeting, the button that joins it. */
34
34
  export function VideoConferencingRow({ c }) {
35
35
  const { values } = c;
36
- return (_jsxs(IconRow, { icon: _jsx(HiOutlineVideoCamera, { size: 20 }), children: [_jsxs("label", { className: "flex items-center gap-2 text-sm py-1.5", children: [_jsx("input", { type: "checkbox", checked: values.videoEnabled, onChange: (e) => c.update({ videoEnabled: e.target.checked }) }), "Add video conferencing"] }), values.videoEnabled && (_jsx("p", { className: "text-xs text-text-muted mb-1", children: "Changing attendees after enabling video conferencing won\u2019t update meeting invitees \u2014 turn this off and back on to reissue links to the current attendee list." })), c.videoError && (_jsx("p", { role: "alert", className: "text-xs text-danger mb-1", children: c.videoError })), c.videoMeetingUid && (_jsxs("div", { className: "flex items-center gap-3 mb-1", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: !c.joinUrl, onClick: () => window.open(c.joinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !c.joinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: c.organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available." }))] })), c.videoMeetingUid && c.joinUrl && (_jsxs("div", { className: "mb-1 flex items-center gap-1 min-w-0 text-xs text-text-muted", children: [_jsx("a", { href: c.joinUrl, target: "_blank", rel: "noopener noreferrer", className: "break-all underline hover:text-text", children: c.joinUrl }), _jsx(CopyIconButton, { value: c.joinUrl, label: "Copy the video call link", className: "shrink-0" })] }))] }));
36
+ return (_jsxs(IconRow, { icon: _jsx(HiOutlineVideoCamera, { size: 20 }), children: [_jsxs("label", { className: "flex items-center gap-2 text-sm py-1.5", children: [_jsx("input", { type: "checkbox", checked: values.videoEnabled, onChange: (e) => c.update({ videoEnabled: e.target.checked }) }), "Add video conferencing"] }), values.videoEnabled && (_jsx("p", { className: "text-xs text-text-muted mb-1", children: "Changing attendees after enabling video conferencing won\u2019t update meeting invitees \u2014 turn this off and back on to reissue links to the current attendee list." })), c.videoError && (_jsx("p", { role: "alert", className: "text-xs text-danger mb-1", children: c.videoError })), c.videoNotice && _jsx("p", { className: "text-xs text-text-muted mb-1", children: c.videoNotice }), c.videoMeetingUid && (_jsxs("div", { className: "flex items-center gap-3 mb-1", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: !c.joinUrl, onClick: () => window.open(c.joinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !c.joinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: c.organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available." }))] })), c.videoMeetingUid && c.joinUrl && (_jsxs("div", { className: "mb-1 flex items-center gap-1 min-w-0 text-xs text-text-muted", children: [_jsx("a", { href: c.joinUrl, target: "_blank", rel: "noopener noreferrer", className: "break-all underline hover:text-text", children: c.joinUrl }), _jsx(CopyIconButton, { value: c.joinUrl, label: "Copy the video call link", className: "shrink-0" })] }))] }));
37
37
  }
38
38
  /**
39
39
  * The guests field: compose's recipient field (`GuestInput`), which looks a name up among the contacts and the server's mailboxes and lists and
@@ -82,4 +82,4 @@ export interface EventModalProps {
82
82
  * Editing or deleting a recurring event's occurrence offers a choice between "this event" and "the entire series" (see
83
83
  * `calendarMutations.ts`) — Outlook's own convention for the same ambiguity.
84
84
  */
85
- export default function EventModal({ open, onClose, mailboxUid, folderUid, calendars, mailboxOptions, folderColors, organizerAddress, organizerAliases, occurrence, initialStart, initialEnd, initialAllDay, anchor, bookingHref, matchNav, onSaved, onDeleted, }: EventModalProps): React.JSX.Element | null;
85
+ export default function EventModal({ open, onClose, mailboxUid, folderUid, calendars, mailboxOptions, folderColors, organizerAddress, organizerAliases, occurrence: listedOccurrence, initialStart, initialEnd, initialAllDay, anchor, bookingHref, matchNav, onSaved, onDeleted, }: EventModalProps): React.JSX.Element | null;
@@ -13,6 +13,9 @@ import EventEditor from "./EventEditor.js";
13
13
  import EventMatchNav from "./EventMatchNav.js";
14
14
  import EventShell from "./EventShell.js";
15
15
  import SlideIn from "./SlideIn.js";
16
+ import { isOpenedRecord, isProtectedRecord } from "../../../../lib/zeroAccess/sealedRecords.js";
17
+ import { useOpenedRecord } from "../../zeroAccess/useOpenedRecord.js";
18
+ import { fullyOpenedEvent } from "../../../../lib/calendar/calendarApi.js";
16
19
  export { VIDEO_LOCATION_PLACEHOLDER } from "./EventEditor.js";
17
20
  /** How wide each face of the dialog gets, in px. */
18
21
  const QUICK_WIDTH = 450;
@@ -34,7 +37,7 @@ const TASK_FORM_WIDTH = 560;
34
37
  * Editing or deleting a recurring event's occurrence offers a choice between "this event" and "the entire series" (see
35
38
  * `calendarMutations.ts`) — Outlook's own convention for the same ambiguity.
36
39
  */
37
- export default function EventModal({ open, onClose, mailboxUid, folderUid, calendars, mailboxOptions, folderColors, organizerAddress, organizerAliases, occurrence, initialStart, initialEnd, initialAllDay, anchor, bookingHref, matchNav, onSaved, onDeleted, }) {
40
+ export default function EventModal({ open, onClose, mailboxUid, folderUid, calendars, mailboxOptions, folderColors, organizerAddress, organizerAliases, occurrence: listedOccurrence, initialStart, initialEnd, initialAllDay, anchor, bookingHref, matchNav, onSaved, onDeleted, }) {
38
41
  const isMobile = useIsMobile();
39
42
  // An existing event: Modify was pressed. A new event: More options was.
40
43
  const [editing, setEditing] = useState(false);
@@ -47,10 +50,20 @@ export default function EventModal({ open, onClose, mailboxUid, folderUid, calen
47
50
  // Escape or the close button on a form that holds something asks before throwing it away.
48
51
  const [confirmingDiscard, setConfirmingDiscard] = useState(false);
49
52
  // An event on a calendar shared with the reader view-only cannot be changed, deleted or answered.
50
- const canWrite = useMailboxUpdateAccess(mailboxOptions?.find((option) => option.mailbox.uid === occurrence?.mailboxUid)?.mailbox);
53
+ const canWrite = useMailboxUpdateAccess(mailboxOptions?.find((option) => option.mailbox.uid === listedOccurrence?.mailboxUid)?.mailbox);
54
+ // A protected event from a list has only what the views draw opened (`EVENT_LIST_FIELDS`): the card shows and the form edits all of it, so
55
+ // the rest is opened first - a form seeded from a placeholder description would save it over the real one. Opened as a write opens it
56
+ // (`fullyOpenedEvent()`): the title opened from its sealed content stays, never replaced by an older per-field one the card, a reply and
57
+ // a save would then carry.
58
+ const partlyOpened = !!listedOccurrence && isProtectedRecord(listedOccurrence) && !isOpenedRecord(listedOccurrence);
59
+ const opening = useOpenedRecord(partlyOpened ? listedOccurrence : undefined, fullyOpenedEvent);
60
+ const occurrence = partlyOpened ? opening.record : listedOccurrence;
51
61
  if (!open) {
52
62
  return null;
53
63
  }
64
+ if (partlyOpened && !opening.ready) {
65
+ return (_jsx(EventShell, { variant: "card", label: "Event details", focusKey: "opening", anchor: anchor, width: DETAILS_WIDTH, onClose: onClose, children: _jsx("p", { className: "text-sm text-text-muted p-4", children: "Opening this event\u2026" }) }));
66
+ }
54
67
  if (syncWarning) {
55
68
  return (_jsxs(Modal, { open: open, onClose: onSaved, title: "Series saved", children: [_jsx("p", { role: "alert", className: "text-sm mb-4", children: "The series was saved, but some occurrences you had changed individually couldn\u2019t be moved with it. They may now appear twice - check the calendar and delete any duplicate." }), _jsx(Button, { type: "button", className: "!w-auto", onClick: onSaved, children: "OK" })] }));
56
69
  }
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import type { InvitationContent } from "../../../../lib/calendar/protectedEvents.js";
2
3
  /** The event a guest asks about: what it says now (the form starts from it and sends only what differs) and which row of it the request is for. */
3
4
  export interface ChangeSubject {
4
5
  /** The event's uid on this mailbox (`CalendarEvent.uid`, or `MessageInvite.calendarEventUid`): the row the organizer's answer will come back to. */
@@ -15,6 +16,8 @@ export interface ChangeSubject {
15
16
  descriptionHtml?: string | null;
16
17
  /** Everyone already invited, whatever the case, so a guest is not added twice. */
17
18
  guestAddresses: string[];
19
+ /** The event's real title, location and description, for the server to name it by when its stored copy is protected (`invitationContentOf()`). */
20
+ invitationContent?: InvitationContent;
18
21
  }
19
22
  export interface RequestChangeFormProps {
20
23
  subject: ChangeSubject;
@@ -112,7 +112,7 @@ export default function RequestChangeForm({ subject, canChange, canInvite, onSen
112
112
  }
113
113
  setSending(true);
114
114
  try {
115
- await requestEventChange(subject.uid, request, client);
115
+ await requestEventChange(subject.uid, request, client, subject.invitationContent);
116
116
  notify({
117
117
  kind: "success",
118
118
  title: "Your change was sent to the organizer",
@@ -85,6 +85,8 @@ export interface EventFormController {
85
85
  /** `undefined` while it is being fetched, `null` once it is known there is none. */
86
86
  organizerJoinUrl: string | null | undefined;
87
87
  videoError: string | null;
88
+ /** Why the video meeting isn't protected end to end, when it isn't. */
89
+ videoNotice?: string | null;
88
90
  editScope: EditScope;
89
91
  setEditScope: (scope: EditScope) => void;
90
92
  /** Whether the "Apply changes to" choice is offered (editing an occurrence of a recurring event). */
@@ -27,6 +27,9 @@ import { useCompose } from "../mail/compose/ComposeContext.js";
27
27
  import { formatInviteWhen } from "../mail/invite/inviteFormat.js";
28
28
  import { contactScope, createContactFor, findContact, loadFolders, loadParticipantCard, loadRecentMessages, loadUpcomingEvents, scopeMailboxes, } from "./contactCardData.js";
29
29
  import { contactInputFor, isExternalAddress, ownDomains, participantName } from "./participantDetails.js";
30
+ import { openSealedRecord } from "../../../../lib/zeroAccess/sealedRecords.js";
31
+ import { isSealedRecord } from "../../../../lib/zeroAccess/sealedMoves.js";
32
+ import { useKeysLocked } from "../../zeroAccess/useKeysLocked.js";
30
33
  /**
31
34
  * One piece of the card's data. `load` is `undefined` while what it needs is still coming (it stays loading), and runs once when it is there; a
32
35
  * failure is this section's error and nothing else's. `upstream` is the section this one needs: its error is this one's too.
@@ -134,11 +137,19 @@ export default function ContactCard({ participant, context = {}, onClose, userUi
134
137
  }
135
138
  }
136
139
  }
140
+ // The contact and the upcoming events are shown opened: a lock of the mailbox whose sealed content they hold takes the card off screen,
141
+ // as the calendar page closes a protected event's card - its state would keep what the lock cleared everywhere else.
142
+ useKeysLocked((locked) => {
143
+ const shown = [stored, ...(events.status === "ready" ? events.data : [])];
144
+ if (shown.some((record) => record?.mailboxUid === locked && isSealedRecord(record))) {
145
+ onClose();
146
+ }
147
+ });
137
148
  // Not while a picture is being changed: that goes against the version the star would replace.
138
149
  const [photoBusy, setPhotoBusy] = useState(false);
139
150
  async function handleToggleFavorite(current) {
140
151
  try {
141
- const next = await setContactFavorite(current, !current.favorite, client);
152
+ const next = await openSealedRecord(await setContactFavorite(current, !current.favorite, client));
142
153
  if (mounted.current) {
143
154
  setChanged(next);
144
155
  }
@@ -24,4 +24,5 @@ export interface ContactDetailPaneProps {
24
24
  * always visible alongside the contact list) and the mobile detail route
25
25
  * (`apps/www/contacts/[uid].tsx`, a full page on its own reached by tapping a contact row).
26
26
  */
27
- export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite, backHref, onKeysChanged, onChanged, canResolveKeys }: ContactDetailPaneProps): React.JSX.Element;
27
+ /** The pane for `props.contact` with all its protected details opened (a list hands over a contact with only what it shows opened). */
28
+ export default function ContactDetailPane(props: ContactDetailPaneProps): React.JSX.Element;
@@ -4,17 +4,23 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useState } from "react";
7
- import { setContactFavorite } from "../../../../lib/contacts/contactsApi.js";
7
+ import { setContactFavorite, updateContact } from "../../../../lib/contacts/contactsApi.js";
8
8
  import CopyIconButton from "../../../../lib/components/buttons/CopyIconButton.js";
9
9
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
10
10
  import { notifyApiError } from "../../notifications/apiErrors.js";
11
11
  import FavoriteStarButton from "./FavoriteStarButton.js";
12
12
  import Button from "../../../../lib/components/buttons/Button.js";
13
+ import Alert from "../../../../lib/components/feedback/Alert.js";
13
14
  import ContactPhotoAvatar from "../../../../lib/components/avatar/ContactPhotoAvatar.js";
14
15
  import ContactPhotoEditor from "./ContactPhotoEditor.js";
15
16
  import KeyChangeReview from "./KeyChangeReview.js";
16
17
  import { KEY_CHANGE_STALE_MESSAGE, formatDate, keyPinnedSince, revocationLabel } from "./contactKeys.js";
17
18
  import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
19
+ import { isOpenPgpKey, keyFormat, OPENPGP_KEY_TYPE, trustSigner } from "../../../../lib/crypto/keyvaultApi.js";
20
+ import { ApiRequestError } from "../../../../lib/util/api.js";
21
+ import ProtectedMark from "../../zeroAccess/ProtectedMark.js";
22
+ import { useOpenedRecord } from "../../zeroAccess/useOpenedRecord.js";
23
+ import { isOpenedRecord, isProtectedRecord, openSealedRecord } from "../../../../lib/zeroAccess/sealedRecords.js";
18
24
  /** Groups a hex fingerprint into 4-character blocks (`ab12 cd34 ...`) for out-of-band verification -
19
25
  * the spec's own use case ("compare this over the phone") is materially easier with a grouped string
20
26
  * than one 64-character run. */
@@ -33,7 +39,17 @@ function RevocationBadge({ label }) {
33
39
  * always visible alongside the contact list) and the mobile detail route
34
40
  * (`apps/www/contacts/[uid].tsx`, a full page on its own reached by tapping a contact row).
35
41
  */
36
- export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite = true, backHref, onKeysChanged, onChanged, canResolveKeys }) {
42
+ /** The pane for `props.contact` with all its protected details opened (a list hands over a contact with only what it shows opened). */
43
+ export default function ContactDetailPane(props) {
44
+ const { record, ready } = useOpenedRecord(props.contact);
45
+ const { onChanged } = props;
46
+ // The server answers a change with the protected details sealed: what goes back to the list is opened again.
47
+ const openedOnChanged = onChanged && ((changed) => void openSealedRecord(changed).then(onChanged));
48
+ // A protected contact that can't be opened here shows placeholders: an edit would save them over the real details.
49
+ const editLocked = ready && isProtectedRecord(record) && !isOpenedRecord(record);
50
+ return _jsx(ContactDetailPaneView, { ...props, contact: record, onChanged: openedOnChanged, editLocked: editLocked });
51
+ }
52
+ function ContactDetailPaneView({ contact, onEdit, onDelete, canWrite = true, backHref, onKeysChanged, onChanged, canResolveKeys, editLocked, }) {
37
53
  const client = useApiClient();
38
54
  // The outcome of the last key decision, kept per contact so selecting another contact doesn't carry it over.
39
55
  const [keyNotice, setKeyNotice] = useState(null);
@@ -60,20 +76,98 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite
60
76
  clearPinnedSignerCache();
61
77
  onKeysChanged?.();
62
78
  }
63
- return (_jsxs("div", { role: "region", "aria-label": "Contact details", className: "max-w-xl flex flex-col gap-5", children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline", children: "\u2190 Back to contacts" })), _jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "flex items-center gap-3", children: [canResolveKeys === false ? (_jsx(ContactPhotoAvatar, { displayName: contact.displayName, size: 48, contact: contact, email: contact.emails[0]?.address })) : (_jsx(ContactPhotoEditor, { contact: contact, displayName: contact.displayName, size: 48, badgeSize: "sm", email: contact.emails[0]?.address, onChanged: (changed) => onChanged?.(changed), onBusyChange: setPhotoBusy })), _jsxs("div", { children: [_jsxs("h1", { className: "text-xl font-bold tracking-tight", children: [contact.displayName, _jsx(FavoriteStarButton, { favorite: !!contact.favorite, disabled: photoBusy, onToggle: () => void handleToggleFavorite(), className: "ml-1 align-middle" })] }), contact.jobTitle && contact.company && (_jsxs("p", { className: "text-sm text-text-muted mt-0.5", children: [contact.jobTitle, " at ", contact.company] }))] })] }), canWrite && (_jsx("div", { className: "flex flex-wrap justify-end gap-2 shrink-0", children: confirmingDelete ? (_jsxs(_Fragment, { children: [_jsx("span", { role: "alert", className: "self-center text-sm", children: "Delete this contact?" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", onClick: onDelete, children: "Yes, delete" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingDeleteUid(null), children: "Keep" })] })) : (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onEdit, children: "Edit" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", onClick: () => setConfirmingDeleteUid(contact.uid), children: "Delete" })] })) }))] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6 flex flex-col gap-4 text-sm", children: [contact.emails.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Email" }), contact.emails.map((e, i) => (_jsxs("div", { children: [e.address, _jsx(CopyIconButton, { value: e.address, label: "Copy email address", className: "mx-0.5" }), " ", _jsxs("span", { className: "text-text-muted", children: ["(", e.type, ")"] })] }, i)))] })), contact.phones.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Phone" }), contact.phones.map((p, i) => (_jsxs("div", { children: [p.phoneNumber, " ", _jsxs("span", { className: "text-text-muted", children: ["(", p.type, ")"] })] }, i)))] })), contact.addresses.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Address" }), contact.addresses.map((a, i) => (_jsx("div", { children: [a.street, a.city, a.state, a.postalCode, a.country].filter(Boolean).join(", ") }, i)))] })), contact.categories && contact.categories.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Categories" }), _jsx("div", { children: contact.categories.join(", ") })] })), contact.notes && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Notes" }), _jsx("div", { children: contact.notes })] })), (contact.keys && contact.keys.length > 0) ||
79
+ return (_jsxs("div", { role: "region", "aria-label": "Contact details", className: "max-w-xl flex flex-col gap-5", children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline", children: "\u2190 Back to contacts" })), _jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "flex items-center gap-3", children: [canResolveKeys === false ? (_jsx(ContactPhotoAvatar, { displayName: contact.displayName, size: 48, contact: contact, email: contact.emails[0]?.address })) : (_jsx(ContactPhotoEditor, { contact: contact, displayName: contact.displayName, size: 48, badgeSize: "sm", email: contact.emails[0]?.address, onChanged: (changed) => onChanged?.(changed), onBusyChange: setPhotoBusy })), _jsxs("div", { children: [_jsxs("h1", { className: "text-xl font-bold tracking-tight", children: [contact.displayName, _jsx(ProtectedMark, { record: contact }), _jsx(FavoriteStarButton, { favorite: !!contact.favorite, disabled: photoBusy, onToggle: () => void handleToggleFavorite(), className: "ml-1 align-middle" })] }), contact.jobTitle && contact.company && (_jsxs("p", { className: "text-sm text-text-muted mt-0.5", children: [contact.jobTitle, " at ", contact.company] }))] })] }), canWrite && (_jsx("div", { className: "flex flex-wrap justify-end gap-2 shrink-0", children: confirmingDelete ? (_jsxs(_Fragment, { children: [_jsx("span", { role: "alert", className: "self-center text-sm", children: "Delete this contact?" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", onClick: onDelete, children: "Yes, delete" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingDeleteUid(null), children: "Keep" })] })) : (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onEdit, disabled: editLocked, title: editLocked ? "This contact is protected: unlock your mailbox to edit it" : undefined, children: "Edit" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", onClick: () => setConfirmingDeleteUid(contact.uid), children: "Delete" })] })) }))] }), editLocked && canWrite && (_jsx("p", { role: "status", className: "text-sm text-text-muted", children: "This contact is protected and can\u2019t be edited while your mailbox is locked. Unlock it to edit." })), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6 flex flex-col gap-4 text-sm", children: [contact.emails.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Email" }), contact.emails.map((e, i) => (_jsxs("div", { children: [e.address, _jsx(CopyIconButton, { value: e.address, label: "Copy email address", className: "mx-0.5" }), " ", _jsxs("span", { className: "text-text-muted", children: ["(", e.type, ")"] })] }, i)))] })), contact.phones.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Phone" }), contact.phones.map((p, i) => (_jsxs("div", { children: [p.phoneNumber, " ", _jsxs("span", { className: "text-text-muted", children: ["(", p.type, ")"] })] }, i)))] })), contact.addresses.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Address" }), contact.addresses.map((a, i) => (_jsx("div", { children: [a.street, a.city, a.state, a.postalCode, a.country].filter(Boolean).join(", ") }, i)))] })), contact.categories && contact.categories.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Categories" }), _jsx("div", { children: contact.categories.join(", ") })] })), contact.notes && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Notes" }), _jsx("div", { children: contact.notes })] })), (contact.keys && contact.keys.length > 0) ||
64
80
  contact.encryptPreference ||
65
81
  keyConflicts.length > 0 ||
66
- previousKeys.length > 0 ? (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Encryption" }), notice && (_jsx("p", { role: "status", className: "mb-2 py-2 px-3 rounded-sm bg-surface-alt text-text", children: notice })), keyConflicts.map((conflict) => {
67
- const pinned = contact.keys?.find((key) => key.useType === conflict.useType);
68
- const kind = conflict.useType === "sign" ? "signing" : "encryption";
69
- return (_jsxs("section", { "aria-label": `${conflict.useType === "sign" ? "Signing" : "Encryption"} key change`, className: "mb-3 py-3 px-3 rounded-sm bg-warning/15 text-text flex flex-col gap-2", children: [_jsxs("h2", { className: "font-semibold", children: ["A different ", kind, " key was seen for ", contact.displayName] }), _jsx("p", { children: "The current key stays in use until you decide what to do with the new one." }), _jsx(KeyChangeReview, { mailboxUid: contact.mailboxUid, address: contact.emails[0]?.address, useType: conflict.useType, ownerName: contact.displayName, current: pinned && { fingerprint: pinned.fingerprint, since: keyPinnedSince(contact, pinned) }, proposed: {
82
+ previousKeys.length > 0 ? (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Encryption" }), isProtectedRecord(contact) && (_jsx("p", { className: "mb-2 text-xs text-text-muted", children: "This contact\u2019s details are protected by zero-access encryption. Its email addresses aren\u2019t - the server needs them to deliver mail and to find this contact\u2019s encryption keys." })), notice && (_jsx("p", { role: "status", className: "mb-2 py-2 px-3 rounded-sm bg-surface-alt text-text", children: notice })), keyConflicts.map((conflict) => {
83
+ // Each format's key is pinned on its own: an OpenPGP key's conflict is with the pinned OpenPGP key.
84
+ const openPgp = isOpenPgpKey(conflict.observedKey);
85
+ const pinned = contact.keys?.find((key) => key.useType === conflict.useType && keyFormat(key) === keyFormat(conflict.observedKey));
86
+ const kind = openPgp ? "OpenPGP" : conflict.useType === "sign" ? "signing" : "encryption";
87
+ return (_jsxs("section", { "aria-label": `${openPgp ? "OpenPGP" : conflict.useType === "sign" ? "Signing" : "Encryption"} key change`, className: "mb-3 py-3 px-3 rounded-sm bg-warning/15 text-text flex flex-col gap-2", children: [_jsxs("h2", { className: "font-semibold", children: ["A different ", kind, " key was seen for ", contact.displayName] }), _jsx("p", { children: "The current key stays in use until you decide what to do with the new one." }), _jsx(KeyChangeReview, { mailboxUid: contact.mailboxUid, address: contact.emails[0]?.address, useType: conflict.useType, ...(openPgp ? { keyType: "openpgp" } : {}), ownerName: contact.displayName, current: pinned && { fingerprint: pinned.fingerprint, since: keyPinnedSince(contact, pinned) }, proposed: {
70
88
  fingerprint: conflict.observedKey.fingerprint,
71
89
  observedAt: conflict.observedAt,
72
90
  source: conflict.source,
73
91
  }, canReject: true, canResolve: canResolveKeys, onResolved: (action) => refresh(action === "accept"
74
92
  ? `The new ${kind} key is now trusted for ${contact.displayName}.`
75
- : `You kept the current ${kind} key for ${contact.displayName}.`), onPinnedKeyChanged: () => refresh(KEY_CHANGE_STALE_MESSAGE) })] }, conflict.useType));
93
+ : `You kept the current ${kind} key for ${contact.displayName}.`), onPinnedKeyChanged: () => refresh(KEY_CHANGE_STALE_MESSAGE) })] }, `${conflict.useType}:${keyFormat(conflict.observedKey)}`));
76
94
  }), contact.encryptPreference && (_jsx("div", { className: "mb-1", children: contact.encryptPreference.preferEncrypt === "mutual"
77
95
  ? "This contact also encrypts to you — messages to them can be encrypted."
78
- : "This contact has not indicated a mutual encryption preference." })), contact.keys?.map((key) => (_jsxs("div", { className: "font-mono text-xs py-0.5", children: [key.useType === "sign" ? "Signing" : "Encryption", " key: ", formatFingerprint(key.fingerprint), _jsx(RevocationBadge, { label: revocationLabel(key) })] }, key.fingerprint))), previousKeys.length > 0 && (_jsxs("div", { className: "mt-3", children: [_jsx("div", { className: "text-text-muted text-xs font-bold mb-1", children: "Key history" }), _jsx("ul", { "aria-label": "Key history", children: previousKeys.map((key) => (_jsxs("li", { className: "text-xs py-0.5", children: [_jsxs("span", { className: "font-mono", children: [key.useType === "sign" ? "Signing" : "Encryption", " key: ", formatFingerprint(key.fingerprint)] }), _jsx(RevocationBadge, { label: revocationLabel(key) }), _jsxs("span", { className: "text-text-muted", children: [" ", "\u00B7", " ", key.replacement === "automatic" ? "Renewed automatically" : "Replaced by you", " on", " ", formatDate(key.replacedAt)] })] }, `${key.useType}:${key.fingerprint}:${key.replacedAt}`))) })] }))] })) : null] })] }));
96
+ : "This contact has not indicated a mutual encryption preference." })), canWrite && contact.keys?.some((key) => isOpenPgpKey(key)) && (_jsx(PreferredFormatControl, { contact: contact, onChanged: onChanged })), contact.keys?.map((key) => (_jsxs("div", { className: "font-mono text-xs py-0.5", children: [keyLabel(key), " key: ", formatFingerprint(key.fingerprint), _jsx(RevocationBadge, { label: revocationLabel(key) })] }, key.fingerprint))), previousKeys.length > 0 && (_jsxs("div", { className: "mt-3", children: [_jsx("div", { className: "text-text-muted text-xs font-bold mb-1", children: "Key history" }), _jsx("ul", { "aria-label": "Key history", children: previousKeys.map((key) => (_jsxs("li", { className: "text-xs py-0.5", children: [_jsxs("span", { className: "font-mono", children: [keyLabel(key), " key: ", formatFingerprint(key.fingerprint)] }), _jsx(RevocationBadge, { label: revocationLabel(key) }), _jsxs("span", { className: "text-text-muted", children: [" ", "\u00B7", " ", key.replacement === "automatic" ? "Renewed automatically" : "Replaced by you", " on", " ", formatDate(key.replacedAt)] })] }, `${key.useType}:${key.fingerprint}:${key.replacedAt}`))) })] }))] })) : null, canWrite && contact.emails[0]?.address && !contact.keys?.some((key) => isOpenPgpKey(key)) && (_jsx(OpenPgpKeyImport, { contact: contact, onImported: () => refresh("The OpenPGP key is now trusted for this contact.") }))] })] }));
97
+ }
98
+ const FORMAT_NAMES = { smime: "S/MIME", openpgp: "OpenPGP" };
99
+ /** What "Automatic" picks for `contact` and why (restapi *Choosing the format*, after the user's own choice): what the address
100
+ * advertises, else what it was last seen using, else S/MIME. */
101
+ function automaticFormat(contact) {
102
+ const advertised = contact.encryptPreference?.preferFormat;
103
+ if (advertised) {
104
+ return `${FORMAT_NAMES[advertised]}, which they asked for`;
105
+ }
106
+ const observed = contact.observedFormat?.format;
107
+ return observed ? `${FORMAT_NAMES[observed]}, which they used last` : "S/MIME when they have a certificate";
108
+ }
109
+ /**
110
+ * "Preferred format" for a contact who has an OpenPGP key: which format mail to them is encrypted in when they can receive both.
111
+ * Automatic follows what they advertise or last used; S/MIME or OpenPGP overrides that - e.g. for someone who reads OpenPGP mail
112
+ * but still has an S/MIME certificate from a former employer.
113
+ */
114
+ function PreferredFormatControl({ contact, onChanged }) {
115
+ const client = useApiClient();
116
+ const [saving, setSaving] = useState(false);
117
+ async function save(value) {
118
+ setSaving(true);
119
+ try {
120
+ const changed = await updateContact({ uid: contact.uid, version: contact.version, preferredFormat: value === "smime" || value === "openpgp" ? value : null }, client);
121
+ onChanged?.(changed);
122
+ }
123
+ catch (err) {
124
+ notifyApiError(err, "Couldn't update this contact");
125
+ }
126
+ finally {
127
+ setSaving(false);
128
+ }
129
+ }
130
+ return (_jsxs("label", { className: "flex flex-wrap items-center gap-2 text-sm my-1", children: [_jsx("span", { children: "Preferred format" }), _jsxs("select", { className: "border border-border rounded-sm py-1 px-2 bg-surface text-sm", value: contact.preferredFormat ?? "auto", disabled: saving, onChange: (e) => void save(e.target.value), children: [_jsxs("option", { value: "auto", children: ["Automatic (", automaticFormat(contact), ")"] }), _jsx("option", { value: "smime", children: "S/MIME" }), _jsx("option", { value: "openpgp", children: "OpenPGP" })] })] }));
131
+ }
132
+ /** What a pinned key is called: an OpenPGP certificate by its format (it signs and encrypts), an S/MIME one by its use. */
133
+ function keyLabel(key) {
134
+ return isOpenPgpKey(key) ? "OpenPGP" : key.useType === "sign" ? "Signing" : "Encryption";
135
+ }
136
+ /**
137
+ * "Add OpenPGP key": pins an OpenPGP public key the user pasted (from the contact's website, a key they sent, a key server they
138
+ * trust) for the contact's first address - `trustSigner()` with `type: "openpgp"`, which restapi checks names that address. For an
139
+ * OpenPGP user whose domain publishes no Web Key Directory, this is how RapidMX can encrypt to them.
140
+ */
141
+ function OpenPgpKeyImport({ contact, onImported }) {
142
+ const client = useApiClient();
143
+ const [open, setOpen] = useState(false);
144
+ const [text, setText] = useState("");
145
+ const [busy, setBusy] = useState(false);
146
+ const [error, setError] = useState(null);
147
+ if (!open) {
148
+ return (_jsx("div", { children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setOpen(true), children: "Add OpenPGP key\u2026" }) }));
149
+ }
150
+ async function handleImport(e) {
151
+ e.preventDefault();
152
+ setBusy(true);
153
+ setError(null);
154
+ try {
155
+ await trustSigner(contact.mailboxUid, { address: contact.emails[0].address, certificate: text.trim(), type: OPENPGP_KEY_TYPE }, client);
156
+ clearPinnedSignerCache();
157
+ setOpen(false);
158
+ setText("");
159
+ onImported();
160
+ }
161
+ catch (err) {
162
+ setError(err instanceof ApiRequestError && err.status === 400
163
+ ? `That isn't an OpenPGP public key for ${contact.emails[0].address} that can be used now.`
164
+ : err instanceof ApiRequestError && err.status === 409
165
+ ? "A different OpenPGP key is already trusted for this contact."
166
+ : "Couldn't add the key. Try again.");
167
+ }
168
+ finally {
169
+ setBusy(false);
170
+ }
171
+ }
172
+ return (_jsxs("form", { onSubmit: (e) => void handleImport(e), className: "flex flex-col gap-2", children: [_jsxs("label", { className: "text-sm font-semibold", htmlFor: `openpgp-key-${contact.uid}`, children: ["OpenPGP public key for ", contact.emails[0].address] }), _jsx("textarea", { id: `openpgp-key-${contact.uid}`, className: "w-full text-xs font-mono border border-border rounded-sm py-1.5 px-2 bg-surface min-h-[8rem]", placeholder: "-----BEGIN PGP PUBLIC KEY BLOCK-----", value: text, onChange: (e) => setText(e.target.value), disabled: busy }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "submit", className: "!w-auto", loading: busy, disabled: busy || !text.trim(), children: "Add key" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setOpen(false), disabled: busy, children: "Cancel" })] })] }));
79
173
  }