@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
@@ -24,10 +24,13 @@ import {
24
24
  import { ApiRequestError } from "../../../lib/util/api.js";
25
25
  import { useApiClient } from "../../../lib/util/apiClientContext.js";
26
26
  import useIsMobile from "../../../lib/util/useIsMobile.js";
27
- import { CalendarEvent, getCalendarEvent, listCalendarEvents } from "../../../lib/calendar/calendarApi.js";
27
+ import { CalendarEvent, fullyOpenedEvent, getCalendarEvent, listCalendarEvents } from "../../../lib/calendar/calendarApi.js";
28
+ import { isOpenedRecord, isProtectedRecord } from "../../../lib/zeroAccess/sealedRecords.js";
29
+ import { isSealedRecord } from "../../../lib/zeroAccess/sealedMoves.js";
30
+ import { useKeysLocked } from "../../shared/zeroAccess/useKeysLocked.js";
28
31
  import { openSealedEvent, openSealedEvents } from "../../shared/calendar/sealedEvents.js";
29
32
  import { useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
30
- import { moveOccurrence, resizeOccurrenceEnd } from "../../../lib/calendar/calendarMutations.js";
33
+ import { SealedOccurrenceError, moveOccurrence, resizeOccurrenceEnd } from "../../../lib/calendar/calendarMutations.js";
31
34
  import { resolveDragAction } from "../../../lib/calendar/calendarDragIds.js";
32
35
  import { createFolder } from "../../../lib/mail/mailApi.js";
33
36
  import { CalendarOccurrence, expandAllOccurrences, expandOccurrences } from "../../../lib/calendar/recurrence.js";
@@ -77,6 +80,8 @@ import { SHORTCUTS, ShortcutDef } from "../../shared/keyboard/keymap.js";
77
80
  import { useShortcut } from "../../shared/keyboard/useShortcut.js";
78
81
  import { useShortcutProps } from "../../shared/keyboard/useShortcutProps.js";
79
82
  import { notifyApiError } from "../../shared/notifications/apiErrors.js";
83
+ import { notify } from "../../shared/notifications/store.js";
84
+ import { KeysLockedError } from "../../../lib/crypto/masterKey.js";
80
85
  import { bookingSettingsHref } from "../../shared/calendar/bookingPlugin.js";
81
86
  import { changedEventOf } from "../../shared/calendar/calendarLiveUpdates.js";
82
87
 
@@ -390,11 +395,44 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
390
395
  // Today moves on at midnight: the List's "from today on" is worked out again then (`listDay` is only a dependency).
391
396
  const listDay = useDayKey();
392
397
  const dayOfList = view === "list" ? listDay : "";
398
+ // A search matches descriptions too, which a list leaves sealed (`EVENT_LIST_FIELDS`): the protected events are opened in full once one
399
+ // runs, and searched as they are until then.
400
+ const [searchable, setSearchable] = useState<{ from: CalendarEvent[]; events: CalendarEvent[] } | null>(null);
401
+ useEffect(() => {
402
+ if (!searching || !events.some((event) => isProtectedRecord(event) && !isOpenedRecord(event))) {
403
+ return;
404
+ }
405
+ let cancelled = false;
406
+ void Promise.all(events.map((event) => fullyOpenedEvent(event))).then((opened) => {
407
+ if (!cancelled) {
408
+ setSearchable({ from: events, events: opened });
409
+ }
410
+ });
411
+ return () => {
412
+ cancelled = true;
413
+ };
414
+ }, [events, searching]);
415
+ const searchedEvents = searchable?.from === events ? searchable.events : events;
416
+
417
+ // The calendar holds protected events opened with their mailboxes' keys: a lock of one takes them off screen - read again with their
418
+ // placeholders, the search's fully opened copies dropped, and an open card or form of a protected event closed - its own mailbox's
419
+ // lock closes it too when it isn't one of the listed events (opened from a reminder, its calendar unchecked).
420
+ useKeysLocked((locked) => {
421
+ const listed: boolean = events.some((event) => event.mailboxUid === locked);
422
+ setModal((current) =>
423
+ current?.occurrence && isSealedRecord(current.occurrence) && (listed || current.occurrence.mailboxUid === locked) ? null : current,
424
+ );
425
+ if (!listed) {
426
+ return;
427
+ }
428
+ reload();
429
+ setSearchable(null);
430
+ });
393
431
  const matches = useMemo(() => {
394
- const found = searching ? searchOccurrences(events, query) : [];
432
+ const found = searching ? searchOccurrences(searchedEvents, query) : [];
395
433
  const today = new Date();
396
434
  return view === "list" ? found.filter((occurrence) => isUpcoming(occurrence, today)) : found;
397
- }, [events, query, searching, view, dayOfList]);
435
+ }, [searchedEvents, query, searching, view, dayOfList]);
398
436
  const activeIndex = matches.findIndex((m) => m.occurrenceKey === activeKey);
399
437
 
400
438
  // What an open event card steps through, in order: the matches while a search runs, else every event of the checked calendars (bounded as a
@@ -684,7 +722,13 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
684
722
  }
685
723
  reload();
686
724
  } catch (err) {
687
- notifyApiError(err, action.type === "move" ? "Couldn't move the event" : "Couldn't resize the event");
725
+ const context = action.type === "move" ? "Couldn't move the event" : "Couldn't resize the event";
726
+ if (err instanceof KeysLockedError || err instanceof SealedOccurrenceError) {
727
+ // A protected occurrence while the mailbox is locked, or one of a zero-access invitation: nothing was written.
728
+ notify({ kind: "warning", title: context, message: err.message });
729
+ } else {
730
+ notifyApiError(err, context);
731
+ }
688
732
  // A refused change (a 409 most of all) means the page holds a stale version: load again, or every later drag of this event fails the same way.
689
733
  reload();
690
734
  }
@@ -11,6 +11,8 @@ import {
11
11
  createContact,
12
12
  deleteContact,
13
13
  getContact,
14
+ CONTACT_LIST_FIELDS,
15
+ CONTACT_MATCH_FIELDS,
14
16
  listContacts,
15
17
  ensureSuggestedContacts,
16
18
  listContactsInFolders,
@@ -44,6 +46,8 @@ import { useShortcut } from "../../shared/keyboard/useShortcut.js";
44
46
  import { notifyApiError } from "../../shared/notifications/apiErrors.js";
45
47
  import { notify } from "../../shared/notifications/store.js";
46
48
  import { changedItemOf, useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
49
+ import { isOpenedRecord, isProtectedRecord, openSealedRecord, openSealedRecords } from "../../../lib/zeroAccess/sealedRecords.js";
50
+ import { useKeysLocked } from "../../shared/zeroAccess/useKeysLocked.js";
47
51
 
48
52
  /** The published model class name of a contact on either database (`ContactMongo`, `ContactSQL`) - not a `ContactList`'s. */
49
53
  const CONTACT_MODEL = /^Contact(Mongo|SQL)$/;
@@ -142,6 +146,7 @@ function ContactsContent({ userUid }: { userUid?: string }) {
142
146
  void loadSuggested(suggestedFolderUid);
143
147
  }
144
148
  return listAllPages((page) => listContacts(folderUid, { limit: LIST_PAGE_SIZE, page }, client))
149
+ .then(async (result) => ({ ...result, items: await openSealedRecords(result.items, CONTACT_LIST_FIELDS) }))
145
150
  .then((result) => {
146
151
  setContacts(result.items);
147
152
  setTruncated(result.truncated);
@@ -154,10 +159,25 @@ function ContactsContent({ userUid }: { userUid?: string }) {
154
159
  void reload();
155
160
  }, [folderUid]);
156
161
 
162
+ // The lists hold protected contacts opened with the mailbox's keys: its lock takes them off screen, read again with their placeholders (the
163
+ // contacts, the suggested ones and the Deleted view). They leave the lists first, so a reload that fails can't leave them shown. An open
164
+ // contact's pane and form drop what they opened themselves (`useOpenedRecord`).
165
+ const [lockCount, setLockCount] = useState(0);
166
+ useKeysLocked((locked) => {
167
+ if (locked === mailboxUid) {
168
+ const readable = (list: Contact[]) => list.filter((contact) => !isProtectedRecord(contact));
169
+ setContacts(readable);
170
+ setSuggestedContacts(readable);
171
+ setDeletedContacts(readable);
172
+ void reload();
173
+ setLockCount((count) => count + 1);
174
+ }
175
+ });
176
+
157
177
  /** Reads the Suggested Contacts folder's contacts. A failure leaves the list as it was. */
158
178
  function loadSuggested(suggestedUid: string): Promise<void> {
159
179
  return listAllPages((page) => listContacts(suggestedUid, { limit: LIST_PAGE_SIZE, page }, client))
160
- .then((result) => setSuggestedContacts(result.items))
180
+ .then(async (result) => setSuggestedContacts(await openSealedRecords(result.items, CONTACT_LIST_FIELDS)))
161
181
  .catch(() => undefined);
162
182
  }
163
183
 
@@ -195,7 +215,9 @@ function ContactsContent({ userUid }: { userUid?: string }) {
195
215
  remove(change.uid);
196
216
  return;
197
217
  }
198
- void getContact(change.uid, client).then(
218
+ void getContact(change.uid, client)
219
+ .then((fresh) => openSealedRecord(fresh, CONTACT_LIST_FIELDS))
220
+ .then(
199
221
  (fresh) => {
200
222
  if (fresh.deleted === true) {
201
223
  remove(fresh.uid);
@@ -256,6 +278,7 @@ function ContactsContent({ userUid }: { userUid?: string }) {
256
278
  setDeletedLoading(true);
257
279
  setDeletedError(null);
258
280
  listAllPages((page) => listDeletedContacts(folderUid, { limit: LIST_PAGE_SIZE, page }, client))
281
+ .then(async (result) => ({ ...result, items: await openSealedRecords(result.items, CONTACT_LIST_FIELDS) }))
259
282
  .then((result) => {
260
283
  // A server that dropped the `deleted` filter (no delete+update right on this folder) answers with live
261
284
  // contacts, which must never be listed as deleted.
@@ -271,8 +294,8 @@ function ContactsContent({ userUid }: { userUid?: string }) {
271
294
  .catch((err) => setDeletedError(err instanceof ApiRequestError ? err.message : "Could not load deleted contacts."))
272
295
  .finally(() => setDeletedLoading(false));
273
296
  // Re-fetches every time the Deleted view is (re-)selected, matching this page's own `reload()`
274
- // convention elsewhere rather than caching a possibly-stale list across visits.
275
- }, [view, folderUid]);
297
+ // convention elsewhere rather than caching a possibly-stale list across visits - and when the mailbox locks.
298
+ }, [view, folderUid, lockCount]);
276
299
 
277
300
  const viewFiltered = useMemo(() => {
278
301
  switch (view.type) {
@@ -467,9 +490,17 @@ function ContactsContent({ userUid }: { userUid?: string }) {
467
490
  if (!category?.trim()) {
468
491
  return;
469
492
  }
470
- for (const contact of checkedContacts) {
471
- const categories = Array.from(new Set([...(contact.categories ?? []), category.trim()]));
493
+ let locked = 0;
494
+ for (const listed of checkedContacts) {
472
495
  try {
496
+ // A protected one's categories are added to as opened: one that won't open here (the mailbox is locked) shows a placeholder
497
+ // (none), and saving that plus the new one would replace its real categories for good. It is left as it is.
498
+ const contact = await openSealedRecord(listed);
499
+ if (isProtectedRecord(contact) && !isOpenedRecord(contact)) {
500
+ locked++;
501
+ continue;
502
+ }
503
+ const categories = Array.from(new Set([...(contact.categories ?? []), category.trim()]));
473
504
  // Only the changed field - sending the whole fetched contact back includes server-managed
474
505
  // fields restapi rejects, and would overwrite any concurrent edit to the other fields.
475
506
  await updateContact({ uid: contact.uid, version: contact.version, categories }, client);
@@ -477,6 +508,13 @@ function ContactsContent({ userUid }: { userUid?: string }) {
477
508
  notifyApiError(err, "Couldn't update some of the contacts");
478
509
  }
479
510
  }
511
+ if (locked > 0) {
512
+ notify({
513
+ kind: "warning",
514
+ title: "Some contacts weren't categorized",
515
+ message: `${locked === 1 ? "A contact is" : `${locked} contacts are`} protected and can't be changed while your mailbox is locked. Unlock it, then add the category again.`,
516
+ });
517
+ }
480
518
  clearPinnedSignerCache();
481
519
  await reload();
482
520
  });
@@ -484,8 +522,17 @@ function ContactsContent({ userUid }: { userUid?: string }) {
484
522
  // A suggested contact becomes one of the mailbox's own: a copy in its contacts folder, and the suggestion removed (the person is not suggested again).
485
523
  const handleAddToContacts = singleFlight(async () => {
486
524
  // In the All contacts view the selection can hold contacts that already are the mailbox's own: only the suggested ones are added.
487
- for (const contact of checkedContacts.filter((checked) => checked.folderUid === suggestedFolderUid)) {
525
+ let locked = 0;
526
+ for (const listed of checkedContacts.filter((checked) => checked.folderUid === suggestedFolderUid)) {
488
527
  try {
528
+ // Copied in full: the list opened only what it shows.
529
+ const contact = await openSealedRecord(listed);
530
+ // A protected one that won't open here (the mailbox is locked) holds only placeholders: copying them would replace its real
531
+ // name for good once the original is deleted. It is left as it is.
532
+ if (isProtectedRecord(contact) && !isOpenedRecord(contact)) {
533
+ locked++;
534
+ continue;
535
+ }
489
536
  await createContact(
490
537
  {
491
538
  mailboxUid: contact.mailboxUid,
@@ -508,14 +555,23 @@ function ContactsContent({ userUid }: { userUid?: string }) {
508
555
  notifyApiError(err, "Couldn't add some of the contacts");
509
556
  }
510
557
  }
558
+ if (locked > 0) {
559
+ notify({
560
+ kind: "warning",
561
+ title: "Some contacts weren't added",
562
+ message: `${locked === 1 ? "A suggested contact is" : `${locked} suggested contacts are`} protected and can't be opened while your mailbox is locked. Unlock it, then add ${locked === 1 ? "it" : "them"} again.`,
563
+ });
564
+ }
511
565
  setCheckedUids(new Set());
512
566
  await reload();
513
567
  });
514
568
 
515
- function handleExportVCard() {
569
+ async function handleExportVCard() {
570
+ // Exported in full: the list opened only what it shows.
571
+ const contacts = await openSealedRecords(checkedContacts);
516
572
  downloadTextFile(
517
- checkedContacts.length === 1 ? `${checkedContacts[0].displayName}.vcf` : "contacts.vcf",
518
- checkedContacts.length === 1 ? contactToVCard(checkedContacts[0]) : contactsToVCardFile(checkedContacts),
573
+ contacts.length === 1 ? `${contacts[0].displayName}.vcf` : "contacts.vcf",
574
+ contacts.length === 1 ? contactToVCard(contacts[0]) : contactsToVCardFile(contacts),
519
575
  );
520
576
  }
521
577
 
@@ -543,7 +599,7 @@ function ContactsContent({ userUid }: { userUid?: string }) {
543
599
  // earlier card's in the file - is left alone. The folder is read in full for that, not just the page of it the list holds.
544
600
  let fresh: ParsedVCardContact[];
545
601
  try {
546
- fresh = withoutDuplicates(parsed, await listContactsInFolders([folderUid], client)).fresh;
602
+ fresh = withoutDuplicates(parsed, await openSealedRecords(await listContactsInFolders([folderUid], client), CONTACT_MATCH_FIELDS)).fresh;
547
603
  } catch (err) {
548
604
  notifyApiError(err, "Couldn't check the file against your contacts");
549
605
  return;
@@ -159,6 +159,9 @@ async function decryptEncryptedRows(messages: Message[], unlocked: UnlockedKeys)
159
159
  encrypted,
160
160
  DECRYPT_CONCURRENCY,
161
161
  async (message): Promise<[string, DecryptedRow] | null> => {
162
+ // A lock while a row was opened has already cleared the shared store: what it opened must not go back in (`decryptForDisplay()`
163
+ // asks the same). The caller's lock generation keeps it off this page.
164
+ const stillUnlocked = (): boolean => getUnlockedKeys(message.mailboxUid) === unlocked;
162
165
  try {
163
166
  // Loaded here, on first use: the S/MIME code (PKI.js, ASN.1, X.509) is over half a megabyte and an inbox with
164
167
  // no encrypted mail in it never needs it.
@@ -167,11 +170,17 @@ async function decryptEncryptedRows(messages: Message[], unlocked: UnlockedKeys)
167
170
  const summary = message.zeroAccess ? await securityApi.openZeroAccessSummary(message.zeroAccess.sealedSummary, unlocked.zeroAccessKeys) : undefined;
168
171
  if (summary) {
169
172
  const preview = summary.preview.slice(0, LIST_PREVIEW_MAX_LENGTH);
170
- rememberDecrypted(message.uid, message.mailboxUid, { subject: summary.subject, preview });
173
+ if (stillUnlocked()) {
174
+ rememberDecrypted(message.uid, message.mailboxUid, { subject: summary.subject, preview });
175
+ }
171
176
  return [message.uid, { subject: summary.subject, preview }];
172
177
  }
173
178
  const rawMime = await getMessageRawContent(message.uid);
174
179
  const security = await securityApi.evaluateMessageSecurity(rawMime, unlocked);
180
+ // Nor is a lock a failure to decrypt.
181
+ if (!stillUnlocked()) {
182
+ return null;
183
+ }
175
184
  if (!security.subject && !security.html) {
176
185
  markDecryptFailed(message.uid);
177
186
  return null;
@@ -17,14 +17,17 @@ import {
17
17
  getCurrentSignEnrollment,
18
18
  getEscrowInfo,
19
19
  getKeyVault,
20
+ isOpenPgpKey,
20
21
  rekey,
21
22
  removeMasterKeyWrap,
22
23
  startSignEnrollment,
23
24
  } from "../../../../lib/crypto/keyvaultApi.js";
24
25
  import {
25
26
  ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE,
27
+ OPENPGP_PRIVATE_KEY_AAD_PURPOSE,
26
28
  SIGNING_PRIVATE_KEY_AAD_PURPOSE,
27
29
  UnlockedKeys,
30
+ ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE,
28
31
  destroyUnlockedKeys,
29
32
  getUnlockedKeys,
30
33
  unlockWithPassword,
@@ -53,6 +56,7 @@ import Button from "../../../../lib/components/buttons/Button.js";
53
56
  import Modal from "../../../../lib/components/overlays/Modal.js";
54
57
  import ZeroAccessSetting from "../../../shared/components/settings/ZeroAccessSetting.js";
55
58
  import KeyExportSetting from "../../../shared/components/settings/KeyExportSetting.js";
59
+ import OpenPgpKeySetting from "../../../shared/components/settings/OpenPgpKeySetting.js";
56
60
  import SigningCertificateCard, { SigningCertificateMode } from "../../../shared/components/settings/SigningCertificateCard.js";
57
61
  import { readStoredSignEnrollment, storeSignEnrollment } from "../../../shared/signing/enrollmentStorage.js";
58
62
  import {
@@ -103,7 +107,14 @@ class StaleSessionKeysError extends Error {
103
107
  const STALE_KEYS_MESSAGE =
104
108
  "Your encryption keys were changed on another device (for example, rotated), so the copy unlocked in this session no longer works. Nothing was changed. Unlock again with your current password, then try again.";
105
109
 
110
+ /** The AAD a vault key is sealed with - by what it is: an OpenPGP secret key, a zero-access key, a signing key or an encryption key. */
106
111
  function wrappedKeyAad(mailboxUid: string, entry: WrappedPrivateKey): Uint8Array {
112
+ if (isOpenPgpKey(entry)) {
113
+ return buildAad(mailboxUid, OPENPGP_PRIVATE_KEY_AAD_PURPOSE);
114
+ }
115
+ if (entry.useType === "zero-access") {
116
+ return buildAad(mailboxUid, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE);
117
+ }
107
118
  return buildAad(mailboxUid, entry.useType === "sign" ? SIGNING_PRIVATE_KEY_AAD_PURPOSE : ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE);
108
119
  }
109
120
 
@@ -169,6 +180,8 @@ async function rewrapVaultPrivateKeys(
169
180
  algorithm: "AES-256-GCM",
170
181
  fingerprint: entry.fingerprint,
171
182
  useType: entry.useType,
183
+ // An OpenPGP secret key stays one: without its type it would read back as an S/MIME key.
184
+ ...(isOpenPgpKey(entry) ? { type: entry.type } : {}),
172
185
  });
173
186
  }
174
187
  return { mk, wrappedKeys: rewrapped };
@@ -229,7 +242,8 @@ function EncryptionGate({ userUid, impersonating }: { userUid?: string; imperson
229
242
  mailboxKeys={mailbox.keys}
230
243
  canProvision={isOwner}
231
244
  >
232
- <EncryptionContent canManageKeys={isOwner} />
245
+ {/* Mounted anew for each mailbox: switching mailbox keeps the page, and its settings seed their state from the mailbox. */}
246
+ <EncryptionContent key={mailboxUid} canManageKeys={isOwner} />
233
247
  </KeyEnrollmentGate>
234
248
  );
235
249
  }
@@ -979,6 +993,7 @@ function EncryptionContent({ canManageKeys }: { canManageKeys: boolean }) {
979
993
 
980
994
  <ZeroAccessSetting mailbox={mailbox} canManage={canManageKeys} />
981
995
  <KeyExportSetting mailbox={mailbox} canManage={canManageKeys} />
996
+ <OpenPgpKeySetting mailbox={mailbox} canManage={canManageKeys} />
982
997
 
983
998
  <div>
984
999
  <h2 className="text-sm font-semibold mb-2">Digital signatures</h2>
@@ -15,21 +15,25 @@ import {
15
15
  import {
16
16
  MailboxImportFormat,
17
17
  MailboxImportRequest,
18
+ ZERO_ACCESS_IMPORT_MESSAGE,
18
19
  listImportRequests,
20
+ mailIsZeroAccess,
19
21
  uploadMailboxImport,
20
22
  } from "../../../../lib/mail/mailboxImportApi.js";
23
+ import { getEncryptionPolicy } from "../../../../lib/crypto/keyvaultApi.js";
21
24
  import {
22
25
  DataSubjectErasureRequest,
23
26
  createErasureRequest,
24
27
  listErasureRequests,
25
28
  } from "../../../../lib/mail/erasureRequestApi.js";
26
- import { Folder, listFolders } from "../../../../lib/mail/mailApi.js";
29
+ import { Folder, Mailbox, listFolders } from "../../../../lib/mail/mailApi.js";
27
30
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
28
31
  import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
29
32
  import Alert from "../../../../lib/components/feedback/Alert.js";
30
33
  import Button from "../../../../lib/components/buttons/Button.js";
31
34
  import Modal from "../../../../lib/components/overlays/Modal.js";
32
35
  import { notifyApiError } from "../../../shared/notifications/apiErrors.js";
36
+ import { INVALID_REQUEST_CODE } from "../../../../lib/zeroAccess/sealedMoves.js";
33
37
  import GravatarSetting from "../../../shared/components/settings/GravatarSetting.js";
34
38
 
35
39
  const NON_MAIL_FOLDER_TYPES = new Set(["calendar", "contacts", "tasks", "notes"]);
@@ -89,7 +93,7 @@ function PrivacyContent({ userUid }: { userUid?: string }) {
89
93
  {selectedIsOwn ? (
90
94
  <>
91
95
  <ExportSection mailboxUid={mailboxUid} mailboxLabel={`${selected.displayName} (${selected.primarySmtpAddress})`} />
92
- <ImportSection mailboxUid={mailboxUid} />
96
+ <ImportSection mailboxUid={mailboxUid} mailbox={selected} />
93
97
  <ErasureSection mailboxLabel={`${selected.displayName} (${selected.primarySmtpAddress})`} />
94
98
  </>
95
99
  ) : (
@@ -238,8 +242,36 @@ function ExportSection({ mailboxUid, mailboxLabel }: { mailboxUid?: string; mail
238
242
  );
239
243
  }
240
244
 
241
- function ImportSection({ mailboxUid }: { mailboxUid?: string }) {
245
+ /**
246
+ * Uploads a mail archive for restapi's server-side import. Not offered for a mailbox whose mail is zero-access (`mailIsZeroAccess()`):
247
+ * restapi refuses it (409), since the archive would wait readable in its blob store until the import ran, and the reason is shown
248
+ * instead. The server's zero-access refusal given anyway (zero-access turned on since the page loaded) is shown in its place too; its other
249
+ * 409, an import already in progress, is not.
250
+ */
251
+ function ImportSection({ mailboxUid, mailbox }: { mailboxUid?: string; mailbox: Mailbox }) {
242
252
  const client = useApiClient();
253
+ // Only when the mailbox's own fields say its mail is zero-access is the administrator's policy asked: `undefined` until it answers
254
+ // (counted as allowed), and a failed read counts as allowed too, so the upload is never offered only to be refused.
255
+ const zeroAccessCandidate = mailIsZeroAccess(mailbox, undefined);
256
+ const [allowZeroAccess, setAllowZeroAccess] = useState<boolean | undefined>(undefined);
257
+ const [refused, setRefused] = useState<string | null>(null);
258
+ useEffect(() => {
259
+ if (!zeroAccessCandidate) {
260
+ return;
261
+ }
262
+ let cancelled = false;
263
+ void getEncryptionPolicy(client)
264
+ .then((policy) => {
265
+ if (!cancelled) {
266
+ setAllowZeroAccess(policy.allowZeroAccess);
267
+ }
268
+ })
269
+ .catch(() => undefined);
270
+ return () => {
271
+ cancelled = true;
272
+ };
273
+ }, [zeroAccessCandidate, client]);
274
+ const zeroAccess = mailIsZeroAccess(mailbox, allowZeroAccess);
243
275
  const [folders, setFolders] = useState<Folder[]>([]);
244
276
  const [targetFolderUid, setTargetFolderUid] = useState("");
245
277
  const [foldersError, setFoldersError] = useState<string | null>(null);
@@ -294,7 +326,18 @@ function ImportSection({ mailboxUid }: { mailboxUid?: string }) {
294
326
  setUploading(true);
295
327
  uploadMailboxImport(file, { format: importFormatFromFilename(file.name), targetFolderUid, mailboxUid: mailboxUid! }, client)
296
328
  .then(() => loadRequests())
297
- .catch((err) => notifyApiError(err, "Couldn't upload the file"))
329
+ .catch((err) => {
330
+ if (err instanceof ApiRequestError && err.status === 409 && err.code === INVALID_REQUEST_CODE) {
331
+ // The server refuses an import into this mailbox (its mail is zero-access): say why, in place of the upload.
332
+ setRefused(err.message);
333
+ return;
334
+ }
335
+ // Any other refusal - an import already in progress is a 409 too - leaves the upload offered, and shows that import.
336
+ notifyApiError(err, "Couldn't upload the file");
337
+ if (err instanceof ApiRequestError && err.status === 409) {
338
+ void loadRequests();
339
+ }
340
+ })
298
341
  .finally(() => setUploading(false));
299
342
  }
300
343
 
@@ -306,43 +349,49 @@ function ImportSection({ mailboxUid }: { mailboxUid?: string }) {
306
349
  choose below. This runs in the background — check back here for its progress.
307
350
  </p>
308
351
 
309
- {folders.length > 0 && (
310
- <label className="flex flex-col gap-1.5 text-sm mb-3 max-w-xs">
311
- <span className="font-semibold">Destination folder</span>
312
- <select
313
- aria-label="Destination folder"
314
- className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
315
- value={targetFolderUid}
316
- onChange={(e) => setTargetFolderUid(e.target.value)}
352
+ {zeroAccess || refused ? (
353
+ <Alert>{refused ?? ZERO_ACCESS_IMPORT_MESSAGE}</Alert>
354
+ ) : (
355
+ <>
356
+ {folders.length > 0 && (
357
+ <label className="flex flex-col gap-1.5 text-sm mb-3 max-w-xs">
358
+ <span className="font-semibold">Destination folder</span>
359
+ <select
360
+ aria-label="Destination folder"
361
+ className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
362
+ value={targetFolderUid}
363
+ onChange={(e) => setTargetFolderUid(e.target.value)}
364
+ >
365
+ {folders.map((folder) => (
366
+ <option key={folder.uid} value={folder.uid}>
367
+ {folder.name}
368
+ </option>
369
+ ))}
370
+ </select>
371
+ </label>
372
+ )}
373
+
374
+ <Button
375
+ type="button"
376
+ variant="secondary"
377
+ className="!w-auto mb-3"
378
+ loading={uploading}
379
+ disabled={uploading || !targetFolderUid}
380
+ onClick={() => fileInputRef.current?.click()}
317
381
  >
318
- {folders.map((folder) => (
319
- <option key={folder.uid} value={folder.uid}>
320
- {folder.name}
321
- </option>
322
- ))}
323
- </select>
324
- </label>
382
+ Upload Mbox or PST file
383
+ </Button>
384
+ <input
385
+ ref={fileInputRef}
386
+ type="file"
387
+ accept=".mbox,.pst"
388
+ aria-label="Upload mail archive"
389
+ className="hidden"
390
+ onChange={handleFileChange}
391
+ />
392
+ </>
325
393
  )}
326
394
 
327
- <Button
328
- type="button"
329
- variant="secondary"
330
- className="!w-auto mb-3"
331
- loading={uploading}
332
- disabled={uploading || !targetFolderUid}
333
- onClick={() => fileInputRef.current?.click()}
334
- >
335
- Upload Mbox or PST file
336
- </Button>
337
- <input
338
- ref={fileInputRef}
339
- type="file"
340
- accept=".mbox,.pst"
341
- aria-label="Upload mail archive"
342
- className="hidden"
343
- onChange={handleFileChange}
344
- />
345
-
346
395
  {foldersError && <Alert>{foldersError}</Alert>}
347
396
  {loadError && <Alert>{loadError}</Alert>}
348
397
 
@@ -36,6 +36,9 @@ import { useShortcut } from "../../shared/keyboard/useShortcut.js";
36
36
  import { useShortcutProps } from "../../shared/keyboard/useShortcutProps.js";
37
37
  import { notifyApiError } from "../../shared/notifications/apiErrors.js";
38
38
  import { changedItemOf, useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
39
+ import ProtectedMark from "../../shared/zeroAccess/ProtectedMark.js";
40
+ import { useKeysLocked } from "../../shared/zeroAccess/useKeysLocked.js";
41
+ import { isProtectedRecord, openSealedRecord, openSealedRecords } from "../../../lib/zeroAccess/sealedRecords.js";
39
42
 
40
43
  /** The published model class name of a task on either database (`TaskMongo`, `TaskSQL`) - not a `TaskList`'s. */
41
44
  const TASK_MODEL = /^Task(Mongo|SQL)$/;
@@ -132,6 +135,7 @@ function TasksContent() {
132
135
  setLoading(true);
133
136
  setError(null);
134
137
  return listAllPages((page) => listTasks(folderUid, { limit: LIST_PAGE_SIZE, page }, client))
138
+ .then(async (result) => ({ ...result, items: await openSealedRecords(result.items) }))
135
139
  .then((result) => {
136
140
  if (run === loadRun.current) {
137
141
  setTasks(result.items);
@@ -154,6 +158,15 @@ function TasksContent() {
154
158
  void reload();
155
159
  }, [folderUid]);
156
160
 
161
+ // The list holds protected tasks opened with the mailbox's keys: its lock takes them off screen, read again with their placeholders. They
162
+ // leave the list first, so a reload that fails can't leave them shown.
163
+ useKeysLocked((locked) => {
164
+ if (locked === mailboxUid) {
165
+ setTasks((list) => list.filter((task) => !isProtectedRecord(task)));
166
+ void reload();
167
+ }
168
+ });
169
+
157
170
  // A task created, changed or deleted anywhere - another tab, a phone over ActiveSync - is announced on its folder's channel. It is fetched again by its
158
171
  // uid and replaces (or joins) the list while it is still in this folder; one that is gone (a delete, a 404, or moved to another folder) leaves it. A
159
172
  // failed fetch changes nothing: the next load shows it as it is.
@@ -167,7 +180,7 @@ function TasksContent() {
167
180
  remove();
168
181
  return;
169
182
  }
170
- void getTask(change.uid, client).then(
183
+ void getTask(change.uid, client).then(openSealedRecord).then(
171
184
  (fresh) => {
172
185
  if (fresh.folderUid !== folderUid) {
173
186
  remove();
@@ -240,7 +253,7 @@ function TasksContent() {
240
253
  setCreateError("That mailbox has no Tasks folder.");
241
254
  return;
242
255
  }
243
- const created = await createTask(
256
+ const created = await openSealedRecord(await createTask(
244
257
  {
245
258
  mailboxUid: target,
246
259
  folderUid: targetFolderUid,
@@ -250,7 +263,7 @@ function TasksContent() {
250
263
  priority,
251
264
  },
252
265
  client,
253
- );
266
+ ));
254
267
  if (target === mailboxUid) {
255
268
  setAddedElsewhere(null);
256
269
  setTasks((prev) => [...prev, created]);
@@ -289,10 +302,12 @@ function TasksContent() {
289
302
  const nextCompleted = !task.completed;
290
303
  setTasks((prev) => prev.map((t) => (t.uid === task.uid ? { ...t, completed: nextCompleted } : t)));
291
304
  try {
292
- const updated = await setTaskCompleted(task, nextCompleted, client);
305
+ const updated = await openSealedRecord(await setTaskCompleted(task, nextCompleted, client));
293
306
  setTasks((prev) => prev.map((t) => (t.uid === task.uid ? updated : t)));
294
307
  } catch (err) {
295
- setTasks((prev) => prev.map((t) => (t.uid === task.uid ? task : t)));
308
+ // Only the flag goes back, on the row there now: `task` is the row as it was shown, opened, and a lock since may have
309
+ // reloaded it with its placeholders (#224), which putting it back whole would undo.
310
+ setTasks((prev) => prev.map((t) => (t.uid === task.uid ? { ...t, completed: task.completed } : t)));
296
311
  notifyApiError(err, "Couldn't update the task");
297
312
  } finally {
298
313
  setTogglePending(task.uid, false);
@@ -660,6 +675,7 @@ function TaskGroup({ label, tasks, checkedUids, onToggleChecked, onToggle, toggl
660
675
  <CompletionToggle task={task} onToggle={onToggle} pending={toggling.has(task.uid)} />
661
676
  <span className={["flex-1 text-sm", task.completed ? "line-through text-text-muted" : ""].join(" ")}>
662
677
  {task.title}
678
+ <ProtectedMark record={task} />
663
679
  </span>
664
680
  {task.priority !== "normal" && (
665
681
  <span className={["text-xs font-medium", PRIORITY_CLASS[task.priority]].join(" ")}>
@@ -742,7 +758,9 @@ function TaskTable({ tasks, checkedUids, onToggleChecked, onToggle, toggling, on
742
758
  <td className="px-3 py-2">
743
759
  <CompletionToggle task={task} onToggle={onToggle} pending={toggling.has(task.uid)} />
744
760
  </td>
745
- <td className={["px-3 py-2", task.completed ? "line-through text-text-muted" : ""].join(" ")}>{task.title}</td>
761
+ <td className={["px-3 py-2", task.completed ? "line-through text-text-muted" : ""].join(" ")}>{task.title}
762
+ <ProtectedMark record={task} />
763
+ </td>
746
764
  <td className="px-3 py-2 text-text-muted">{task.dueDate ? parseDueDate(task.dueDate).toLocaleDateString() : ""}</td>
747
765
  <td className={["px-3 py-2", PRIORITY_CLASS[task.priority]].join(" ")}>{PRIORITY_LABEL[task.priority]}</td>
748
766
  <td className="px-3 py-2">
@@ -12,7 +12,21 @@ export interface CalendarReminderNotice {
12
12
  /** The event's own `location`, verbatim - `undefined`/`null` when the event has none. Not necessarily a URL;
13
13
  * see `joinMeetingUrl()` for when it's shown as a join link. */
14
14
  location?: string | null;
15
+ /** The event is of a calendar zero-access encryption protects, or was made from a zero-access invitation: `title` and `location` are
16
+ * placeholders (`openedReminder()`). */
17
+ protected?: boolean;
18
+ /** Set by `openedReminder()` on a protected notice it opened: the placeholder title the push carried, for anything recorded beyond
19
+ * this tab's memory (the notification history is kept in `sessionStorage` and outlives a mailbox lock). */
20
+ sealedTitle?: string;
21
+ /** Set with `sealedTitle`: the placeholder location the push carried, which `sealedReminder()` puts back. */
22
+ sealedLocation?: string | null;
15
23
  }
24
+ /** `notice` with a protected event's real title and location, read from the event itself (opened with the mailbox's keys); `notice` as it
25
+ * came when the event can't be read or opened - the alarm still goes off, with the placeholder. Never rejects. */
26
+ export declare function openedReminder(notice: CalendarReminderNotice): Promise<CalendarReminderNotice>;
27
+ /** `notice` as the push carried it, when `openedReminder()` opened it - for an alarm still shown, or snoozed, when the mailbox locks: its real
28
+ * title and location go with the keys that opened them. `openedReminder()` opens it again (it is still `protected`). */
29
+ export declare function sealedReminder(notice: CalendarReminderNotice): CalendarReminderNotice;
16
30
  /** A `notice.location` worth offering as a one-click "Join Meeting" link - a plain `http(s)://` URL and nothing
17
31
  * else (a room name, an address, free-text instructions are all real, common `location` values this must not
18
32
  * treat as a link). Trimmed first, so incidental whitespace never hides a real link. */