@rapidmx/web-client 0.35.0 → 0.36.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 (119) hide show
  1. package/apps/escrow/matters/[uid].tsx +4 -3
  2. package/apps/shared/calendar/sealedEvents.ts +80 -0
  3. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +61 -3
  4. package/apps/shared/components/contacts/contactCardData.ts +2 -1
  5. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +23 -1
  6. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +91 -0
  7. package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
  8. package/apps/shared/components/mail/ConversationList.tsx +1 -1
  9. package/apps/shared/components/mail/MessageDetailPane.tsx +35 -13
  10. package/apps/shared/components/mail/compose/draftResume.ts +28 -3
  11. package/apps/shared/components/mail/compose/quotedBody.ts +4 -3
  12. package/apps/shared/components/mail/invite/inviteStore.ts +7 -1
  13. package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
  14. package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
  15. package/apps/shared/components/settings/KeyExportSetting.tsx +170 -0
  16. package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
  17. package/apps/shared/components/settings/ZeroAccessSetting.tsx +117 -0
  18. package/apps/shared/mail/decryptedMessages.ts +11 -3
  19. package/apps/shared/mail/outbox/sendJob.ts +2 -2
  20. package/apps/shared/mail/reportNotices.ts +3 -1
  21. package/apps/shared/mail/zeroAccessSource.ts +27 -0
  22. package/apps/shared/search/LocalIndexLifecycle.tsx +31 -0
  23. package/apps/shared/search/localIndexBuilder.ts +27 -1
  24. package/apps/shared/search/localIndexRpcClient.ts +8 -4
  25. package/apps/shared/search/localIndexSchema.ts +2 -2
  26. package/apps/shared/search/localIndexTransport.ts +2 -1
  27. package/apps/shared/search/localIndexWorker.ts +6 -4
  28. package/apps/shared/search/zeroAccessIndexSync.ts +487 -0
  29. package/apps/www/calendar/index.tsx +176 -56
  30. package/apps/www/index.tsx +10 -3
  31. package/apps/www/settings/encryption/index.tsx +5 -0
  32. package/apps/www/settings/privacy/index.tsx +2 -1
  33. package/dist/apps/escrow/matters/[uid].js +2 -2
  34. package/dist/apps/shared/calendar/sealedEvents.d.ts +13 -0
  35. package/dist/apps/shared/calendar/sealedEvents.js +54 -0
  36. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +18 -2
  37. package/dist/apps/shared/components/contacts/contactCardData.js +2 -1
  38. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +21 -2
  39. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +15 -0
  40. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +72 -0
  41. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
  42. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
  43. package/dist/apps/shared/components/mail/ConversationList.js +1 -1
  44. package/dist/apps/shared/components/mail/MessageDetailPane.js +34 -13
  45. package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
  46. package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
  47. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +1 -1
  48. package/dist/apps/shared/components/mail/compose/quotedBody.js +4 -3
  49. package/dist/apps/shared/components/mail/invite/inviteStore.js +6 -1
  50. package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
  51. package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
  52. package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
  53. package/dist/apps/shared/components/settings/KeyExportSetting.js +88 -0
  54. package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
  55. package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
  56. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +14 -0
  57. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +71 -0
  58. package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
  59. package/dist/apps/shared/mail/decryptedMessages.js +11 -3
  60. package/dist/apps/shared/mail/outbox/sendJob.js +2 -2
  61. package/dist/apps/shared/mail/reportNotices.js +3 -1
  62. package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
  63. package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
  64. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
  65. package/dist/apps/shared/search/LocalIndexLifecycle.js +28 -0
  66. package/dist/apps/shared/search/localIndexBuilder.d.ts +5 -0
  67. package/dist/apps/shared/search/localIndexBuilder.js +24 -2
  68. package/dist/apps/shared/search/localIndexRpcClient.d.ts +1 -1
  69. package/dist/apps/shared/search/localIndexRpcClient.js +6 -4
  70. package/dist/apps/shared/search/localIndexSchema.d.ts +1 -1
  71. package/dist/apps/shared/search/localIndexSchema.js +2 -2
  72. package/dist/apps/shared/search/localIndexTransport.d.ts +2 -1
  73. package/dist/apps/shared/search/localIndexWorker.d.ts +2 -0
  74. package/dist/apps/shared/search/localIndexWorker.js +4 -4
  75. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +71 -0
  76. package/dist/apps/shared/search/zeroAccessIndexSync.js +382 -0
  77. package/dist/apps/www/calendar/index.js +41 -15
  78. package/dist/apps/www/index.js +10 -3
  79. package/dist/apps/www/settings/encryption/index.js +3 -1
  80. package/dist/apps/www/settings/privacy/index.js +1 -1
  81. package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
  82. package/dist/lib/calendar/calendarApi.d.ts +6 -0
  83. package/dist/lib/calendar/inviteApi.js +27 -5
  84. package/dist/lib/calendar/sealedInvites.d.ts +11 -0
  85. package/dist/lib/calendar/sealedInvites.js +28 -0
  86. package/dist/lib/crypto/keyExport.d.ts +54 -0
  87. package/dist/lib/crypto/keyExport.js +161 -0
  88. package/dist/lib/crypto/keySession.d.ts +45 -1
  89. package/dist/lib/crypto/keySession.js +93 -2
  90. package/dist/lib/crypto/keyvaultApi.d.ts +88 -3
  91. package/dist/lib/crypto/keyvaultApi.js +32 -0
  92. package/dist/lib/crypto/messageSecurity.d.ts +30 -1
  93. package/dist/lib/crypto/messageSecurity.js +80 -4
  94. package/dist/lib/crypto/mime.d.ts +5 -0
  95. package/dist/lib/crypto/mime.js +25 -0
  96. package/dist/lib/crypto/sharedMailboxKeys.d.ts +50 -0
  97. package/dist/lib/crypto/sharedMailboxKeys.js +264 -0
  98. package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
  99. package/dist/lib/crypto/zeroAccessKey.js +66 -0
  100. package/dist/lib/mail/conversationsApi.d.ts +6 -0
  101. package/dist/lib/mail/mailApi.d.ts +30 -2
  102. package/dist/lib/mail/mailApi.js +5 -1
  103. package/dist/lib/search/zeroAccessIndexApi.d.ts +81 -0
  104. package/dist/lib/search/zeroAccessIndexApi.js +35 -0
  105. package/lib/admin/escrowAccessRequestsApi.ts +6 -0
  106. package/lib/calendar/calendarApi.ts +3 -0
  107. package/lib/calendar/inviteApi.ts +28 -5
  108. package/lib/calendar/sealedInvites.ts +33 -0
  109. package/lib/crypto/keyExport.ts +229 -0
  110. package/lib/crypto/keySession.ts +117 -3
  111. package/lib/crypto/keyvaultApi.ts +125 -3
  112. package/lib/crypto/messageSecurity.ts +110 -3
  113. package/lib/crypto/mime.ts +26 -0
  114. package/lib/crypto/sharedMailboxKeys.ts +324 -0
  115. package/lib/crypto/zeroAccessKey.ts +97 -0
  116. package/lib/mail/conversationsApi.ts +3 -0
  117. package/lib/mail/mailApi.ts +33 -3
  118. package/lib/search/zeroAccessIndexApi.ts +119 -0
  119. package/package.json +3 -3
@@ -25,15 +25,26 @@ 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
27
  import { CalendarEvent, getCalendarEvent, listCalendarEvents } from "../../../lib/calendar/calendarApi.js";
28
+ import { openSealedEvent, openSealedEvents } from "../../shared/calendar/sealedEvents.js";
28
29
  import { useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
29
30
  import { moveOccurrence, resizeOccurrenceEnd } from "../../../lib/calendar/calendarMutations.js";
30
31
  import { resolveDragAction } from "../../../lib/calendar/calendarDragIds.js";
31
32
  import { createFolder } from "../../../lib/mail/mailApi.js";
32
33
  import { CalendarOccurrence, expandAllOccurrences, expandOccurrences } from "../../../lib/calendar/recurrence.js";
33
34
  import { useDayKey } from "../../../lib/calendar/useDayKey.js";
34
- import { allOccurrences, initialMatchIndex, occurrencesInRange, searchOccurrences, searchTerms, stepSequenceIndex } from "../../../lib/calendar/eventSearch.js";
35
+ import {
36
+ allOccurrences,
37
+ initialMatchIndex,
38
+ occurrencesInRange,
39
+ searchOccurrences,
40
+ searchTerms,
41
+ stepSequenceIndex,
42
+ } from "../../../lib/calendar/eventSearch.js";
35
43
  import Drawer from "../../../lib/components/overlays/Drawer.js";
36
- import CalendarShell, { CalendarShellProps, useCalendarShell } from "../../shared/components/calendar/layout/CalendarShell.js";
44
+ import CalendarShell, {
45
+ CalendarShellProps,
46
+ useCalendarShell,
47
+ } from "../../shared/components/calendar/layout/CalendarShell.js";
37
48
  import CalendarListSidebar from "../../shared/components/calendar/CalendarListSidebar.js";
38
49
  import CalendarSearchBox from "../../shared/components/calendar/CalendarSearchBox.js";
39
50
  import { ActiveOccurrenceContext } from "../../shared/components/calendar/activeOccurrence.js";
@@ -49,7 +60,13 @@ import SplitDayView from "../../shared/components/calendar/SplitDayView.js";
49
60
  import TimeGridView from "../../shared/components/calendar/TimeGridView.js";
50
61
  import Alert from "../../../lib/components/feedback/Alert.js";
51
62
  import Button from "../../../lib/components/buttons/Button.js";
52
- import { CALENDAR_VIEWS, CalendarView, getStoredCalendarView, isCalendarView, storeCalendarView } from "../../shared/components/calendar/calendarView.js";
63
+ import {
64
+ CALENDAR_VIEWS,
65
+ CalendarView,
66
+ getStoredCalendarView,
67
+ isCalendarView,
68
+ storeCalendarView,
69
+ } from "../../shared/components/calendar/calendarView.js";
53
70
  import { SlideFrom } from "../../shared/components/calendar/SlideIn.js";
54
71
  import { SWIPE_PERIOD_SHIFT } from "../../shared/components/calendar/swipeNavigation.js";
55
72
  import { nextHalfHour } from "../../shared/components/calendar/timePicker.js";
@@ -78,7 +95,14 @@ function CalendarPage(props: CalendarShellProps) {
78
95
  }
79
96
 
80
97
  type ViewType = CalendarView;
81
- const VIEW_LABELS: Record<ViewType, string> = { list: "List", month: "Month", week: "Week", workWeek: "Work Week", day: "Day", split: "Split" };
98
+ const VIEW_LABELS: Record<ViewType, string> = {
99
+ list: "List",
100
+ month: "Month",
101
+ week: "Week",
102
+ workWeek: "Work Week",
103
+ day: "Day",
104
+ split: "Split",
105
+ };
82
106
  /** The shortcut that switches to a view (Outlook's Ctrl+Alt+1-4, the List one more); the split view has none. */
83
107
  const VIEW_SHORTCUTS: Partial<Record<ViewType, ShortcutDef>> = {
84
108
  list: SHORTCUTS.calendar.list,
@@ -89,7 +113,15 @@ const VIEW_SHORTCUTS: Partial<Record<ViewType, ShortcutDef>> = {
89
113
  };
90
114
 
91
115
  /** One of the view switcher's buttons, with its shortcut in the tooltip. */
92
- function ViewButton({ view, current, onSelect }: { view: ViewType; current: ViewType; onSelect: (view: ViewType) => void }) {
116
+ function ViewButton({
117
+ view,
118
+ current,
119
+ onSelect,
120
+ }: {
121
+ view: ViewType;
122
+ current: ViewType;
123
+ onSelect: (view: ViewType) => void;
124
+ }) {
93
125
  const shortcut = VIEW_SHORTCUTS[view];
94
126
  const hint = useShortcutProps(VIEW_LABELS[view], shortcut ?? SHORTCUTS.calendar.day, !!shortcut);
95
127
  return (
@@ -124,7 +156,8 @@ interface ModalState {
124
156
  }
125
157
 
126
158
  function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHref?: string }) {
127
- const { mailboxUid, folderUid, calendarFolders, mailboxCalendars, mailboxes, reloadFolders, colorFor } = useCalendarShell();
159
+ const { mailboxUid, folderUid, calendarFolders, mailboxCalendars, mailboxes, reloadFolders, colorFor } =
160
+ useCalendarShell();
128
161
  const client = useApiClient();
129
162
  // `PointerSensor` alone activates a drag on the very first touch-move, indistinguishable from a
130
163
  // scroll gesture on a touch device. `MouseSensor` (a small `distance` — desktop drags still start
@@ -133,7 +166,7 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
133
166
  // dnd-kit's own documented pattern for this exact conflict.
134
167
  const sensors = useSensors(
135
168
  useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
136
- useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } }),
169
+ useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } })
137
170
  );
138
171
 
139
172
  const [drawerOpen, setDrawerOpen] = useState(false);
@@ -194,7 +227,7 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
194
227
  const checkedFolderUids = checkedFolderUidsState ?? new Set(calendarFolders.map((f) => f.uid));
195
228
  const folderColors = useMemo(
196
229
  () => Object.fromEntries(calendarFolders.map((f) => [f.uid, colorFor(f)])),
197
- [calendarFolders, colorFor],
230
+ [calendarFolders, colorFor]
198
231
  );
199
232
 
200
233
  function toggleCalendar(toggledUid: string) {
@@ -257,7 +290,7 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
257
290
  }
258
291
  setLoading(true);
259
292
  setError(null);
260
- void Promise.allSettled(targets.map((f) => listCalendarEvents(f.uid, client)))
293
+ void Promise.allSettled(targets.map((f) => listCalendarEvents(f.uid, client).then(openSealedEvents)))
261
294
  .then((results) => {
262
295
  if (seq !== reloadSeqRef.current) {
263
296
  return;
@@ -295,7 +328,10 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
295
328
 
296
329
  // Keyed on stable string summaries (not the array/set objects themselves, which are new references
297
330
  // every render) so this only re-fires when the actual set of folders to fetch changes.
298
- const calendarFolderUidsKey = calendarFolders.map((f) => f.uid).sort().join(",");
331
+ const calendarFolderUidsKey = calendarFolders
332
+ .map((f) => f.uid)
333
+ .sort()
334
+ .join(",");
299
335
  const checkedFolderUidsKey = Array.from(checkedFolderUids).sort().join(",");
300
336
  useEffect(reload, [calendarFolderUidsKey, checkedFolderUidsKey]);
301
337
 
@@ -317,23 +353,25 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
317
353
  setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
318
354
  return;
319
355
  }
320
- void getCalendarEvent(uid, client).then(
321
- (fresh) => {
322
- if (checkedFolderUidsRef.current.has(fresh.folderUid)) {
323
- setEvents((previous) =>
324
- previous.some((existing) => existing.uid === fresh.uid)
325
- ? previous.map((existing) => (existing.uid === fresh.uid ? fresh : existing))
326
- : [...previous, fresh],
327
- );
328
- }
329
- },
330
- (err: unknown) => {
331
- if (err instanceof ApiRequestError && err.status === 404) {
332
- setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
356
+ void getCalendarEvent(uid, client)
357
+ .then(openSealedEvent)
358
+ .then(
359
+ (fresh) => {
360
+ if (checkedFolderUidsRef.current.has(fresh.folderUid)) {
361
+ setEvents((previous) =>
362
+ previous.some((existing) => existing.uid === fresh.uid)
363
+ ? previous.map((existing) => (existing.uid === fresh.uid ? fresh : existing))
364
+ : [...previous, fresh]
365
+ );
366
+ }
367
+ },
368
+ (err: unknown) => {
369
+ if (err instanceof ApiRequestError && err.status === 404) {
370
+ setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
371
+ }
333
372
  }
334
- },
335
- );
336
- },
373
+ );
374
+ }
337
375
  );
338
376
 
339
377
  // Search: `searchText` is the box, `query` what it held once typing paused (or Enter was pressed) and is what is searched for. A search covers every
@@ -361,7 +399,10 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
361
399
 
362
400
  // 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
363
401
  // search is). The List holds only what is from today on, so that is all its card steps through. Worked out only when something needs it.
364
- const everything = useMemo(() => (view === "list" || modal?.occurrence ? allOccurrences(events) : []), [events, view, !!modal?.occurrence]);
402
+ const everything = useMemo(
403
+ () => (view === "list" || modal?.occurrence ? allOccurrences(events) : []),
404
+ [events, view, !!modal?.occurrence]
405
+ );
365
406
  const sequence = useMemo(() => {
366
407
  if (searching) {
367
408
  return matches;
@@ -377,7 +418,9 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
377
418
  if (view === "list") {
378
419
  return sequence;
379
420
  }
380
- return searching ? occurrencesInRange(matches, rangeStart, rangeEnd) : expandAllOccurrences(events, rangeStart, rangeEnd);
421
+ return searching
422
+ ? occurrencesInRange(matches, rangeStart, rangeEnd)
423
+ : expandAllOccurrences(events, rangeStart, rangeEnd);
381
424
  }, [events, matches, searching, view, sequence, rangeStart, rangeEnd]);
382
425
 
383
426
  /** Points at `match`, moving the view to the day it is on unless the view already shows it. */
@@ -397,7 +440,13 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
397
440
  // calendar do not.
398
441
  const stepFrom = modal?.occurrence;
399
442
  const stepIndex = (direction: 1 | -1) =>
400
- stepSequenceIndex(sequence, stepFrom?.occurrenceKey ?? activeKey, stepFrom ? new Date(stepFrom.startDate) : undefined, direction, searching);
443
+ stepSequenceIndex(
444
+ sequence,
445
+ stepFrom?.occurrenceKey ?? activeKey,
446
+ stepFrom ? new Date(stepFrom.startDate) : undefined,
447
+ direction,
448
+ searching
449
+ );
401
450
 
402
451
  function stepMatch(direction: 1 | -1) {
403
452
  const next = sequence[stepIndex(direction)];
@@ -433,17 +482,22 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
433
482
  searchedQueryRef.current = query;
434
483
  // The List holds every match: it starts at the first from the day being viewed on rather than the first of all.
435
484
  const from = startOfDay(viewDate);
436
- showMatch(matches[view === "list" ? initialMatchIndex(matches, from, rangeEnd, from) : initialMatchIndex(matches, rangeStart, rangeEnd, from)]);
485
+ showMatch(
486
+ matches[
487
+ view === "list"
488
+ ? initialMatchIndex(matches, from, rangeEnd, from)
489
+ : initialMatchIndex(matches, rangeStart, rangeEnd, from)
490
+ ]
491
+ );
437
492
  }
438
493
  }, [matches, query, searching]);
439
494
 
440
-
441
495
  const splitColumns = useMemo(
442
496
  () =>
443
497
  calendarFolders
444
498
  .filter((f) => checkedFolderUids.has(f.uid))
445
499
  .map((f) => ({ folderUid: f.uid, name: f.name, color: colorFor(f) })),
446
- [calendarFolders, checkedFolderUidsKey, colorFor],
500
+ [calendarFolders, checkedFolderUidsKey, colorFor]
447
501
  );
448
502
 
449
503
  function shiftView(direction: 1 | -1) {
@@ -457,7 +511,7 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
457
511
  ? addMonths(d, direction)
458
512
  : view === "week" || view === "workWeek"
459
513
  ? addWeeks(d, direction)
460
- : addDays(d, direction),
514
+ : addDays(d, direction)
461
515
  );
462
516
  }
463
517
 
@@ -476,7 +530,8 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
476
530
  const wheelSlide = useEnterSlide();
477
531
  /** Where the scroller of the page a wheel step arrived at starts: its top after going on, its bottom after going back. */
478
532
  const arrivalEdge = useRef<WheelEdge | null>(null);
479
- const scrollerOf = (): HTMLElement | null => swipe.ref.current?.querySelector<HTMLElement>("[data-calendar-scroller]") ?? null;
533
+ const scrollerOf = (): HTMLElement | null =>
534
+ swipe.ref.current?.querySelector<HTMLElement>("[data-calendar-scroller]") ?? null;
480
535
  useWheelPaging(swipe.ref, {
481
536
  enabled: !isMobile && !dragging && !modal && view !== "list",
482
537
  getScroller: () => (view === "month" ? null : scrollerOf()),
@@ -498,7 +553,9 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
498
553
  // Brings the match being pointed at into sight (the views scroll their own hours; the page may too).
499
554
  useEffect(() => {
500
555
  const marked = Array.from(swipe.ref.current!.querySelectorAll<HTMLElement>("[data-occurrence-key]"));
501
- marked.find((el) => el.dataset.occurrenceKey === activeKey)?.scrollIntoView({ block: "nearest", inline: "nearest" });
556
+ marked
557
+ .find((el) => el.dataset.occurrenceKey === activeKey)
558
+ ?.scrollIntoView({ block: "nearest", inline: "nearest" });
502
559
  }, [activeKey, view === "list" ? null : viewDate, view, loading]);
503
560
 
504
561
  // The List scrolls to `viewDate` when this changes (Today, a day picked in the mini calendar), even when it is the date it already has.
@@ -514,7 +571,13 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
514
571
  setViewDate(startOfDay(date));
515
572
  }
516
573
 
517
- function openNewEvent(start?: Date, end?: Date, targetFolderUid?: string, anchor?: EventAnchor, initialAllDay?: boolean) {
574
+ function openNewEvent(
575
+ start?: Date,
576
+ end?: Date,
577
+ targetFolderUid?: string,
578
+ anchor?: EventAnchor,
579
+ initialAllDay?: boolean
580
+ ) {
518
581
  if (!mailboxUid || !folderUid) {
519
582
  return;
520
583
  }
@@ -558,10 +621,17 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
558
621
  const requestedStart = new Date(params.get("start") ?? "");
559
622
  navigateTo("/calendar", { replace: true });
560
623
  getCalendarEvent(eventUid, client)
624
+ .then(openSealedEvent)
561
625
  .then((event) => {
562
626
  const from = isNaN(requestedStart.getTime()) ? new Date(event.startDate) : requestedStart;
563
- const candidates = expandOccurrences(event, new Date(from.getTime() - 86_400_000), new Date(from.getTime() + 86_400_000));
564
- const match = candidates.find((occurrence) => new Date(occurrence.startDate).getTime() === from.getTime()) ?? candidates[0];
627
+ const candidates = expandOccurrences(
628
+ event,
629
+ new Date(from.getTime() - 86_400_000),
630
+ new Date(from.getTime() + 86_400_000)
631
+ );
632
+ const match =
633
+ candidates.find((occurrence) => new Date(occurrence.startDate).getTime() === from.getTime()) ??
634
+ candidates[0];
565
635
  if (match) {
566
636
  setViewDate(occurrenceDay(match));
567
637
  openEvent(match);
@@ -625,9 +695,9 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
625
695
  ? "Upcoming events"
626
696
  : view === "month"
627
697
  ? format(viewDate, "MMMM yyyy")
628
- : view === "week" || view === "workWeek"
629
- ? `${format(rangeStart, "MMM d")} – ${format(rangeEnd, "MMM d, yyyy")}`
630
- : format(viewDate, "EEEE, MMMM d, yyyy");
698
+ : view === "week" || view === "workWeek"
699
+ ? `${format(rangeStart, "MMM d")} – ${format(rangeEnd, "MMM d, yyyy")}`
700
+ : format(viewDate, "EEEE, MMMM d, yyyy");
631
701
 
632
702
  const modalFolderUid = modal ? (modal.targetFolderUid ?? modal.occurrence?.folderUid ?? folderUid) : undefined;
633
703
  const modalMailboxUid = calendarFolders.find((f) => f.uid === modalFolderUid)?.mailboxUid ?? mailboxUid;
@@ -638,9 +708,14 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
638
708
  const mailboxOptions = useMemo(
639
709
  () =>
640
710
  mailboxCalendars
641
- .filter((mc) => mc.calendarFolders.length > 0 && writableMailboxes.some((mb) => mb.uid === mc.mailbox.uid))
642
- .map((mc) => ({ mailbox: mc.mailbox, calendars: mc.calendarFolders.map((f) => ({ uid: f.uid, name: f.name })) })),
643
- [mailboxCalendars, writableMailboxes],
711
+ .filter(
712
+ (mc) => mc.calendarFolders.length > 0 && writableMailboxes.some((mb) => mb.uid === mc.mailbox.uid)
713
+ )
714
+ .map((mc) => ({
715
+ mailbox: mc.mailbox,
716
+ calendars: mc.calendarFolders.map((f) => ({ uid: f.uid, name: f.name })),
717
+ })),
718
+ [mailboxCalendars, writableMailboxes]
644
719
  );
645
720
 
646
721
  const sidebarContent = (
@@ -664,7 +739,9 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
664
739
 
665
740
  return (
666
741
  <div className="flex-1 min-w-0 flex min-h-0">
667
- <div className="hidden lg:flex w-56 shrink-0 border-r border-border flex-col overflow-y-auto">{sidebarContent}</div>
742
+ <div className="hidden lg:flex w-56 shrink-0 border-r border-border flex-col overflow-y-auto">
743
+ {sidebarContent}
744
+ </div>
668
745
  <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Calendars" fullScreen>
669
746
  <div className="flex flex-col">{sidebarContent}</div>
670
747
  </Drawer>
@@ -681,18 +758,42 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
681
758
  </button>
682
759
  {/* On a phone New event is the floating button at the bottom of the page instead, as in Mail. */}
683
760
  {!isMobile && (
684
- <Button type="button" onClick={(e) => openNewEvent(undefined, undefined, undefined, anchorOf(e.currentTarget, "below"))} className="!w-auto shrink-0" {...newEventHint}>
761
+ <Button
762
+ type="button"
763
+ onClick={(e) =>
764
+ openNewEvent(undefined, undefined, undefined, anchorOf(e.currentTarget, "below"))
765
+ }
766
+ className="!w-auto shrink-0"
767
+ {...newEventHint}
768
+ >
685
769
  + New event
686
770
  </Button>
687
771
  )}
688
772
  <div className="flex items-center gap-1">
689
- <button type="button" onClick={() => shiftView(-1)} aria-label="Previous" {...previousHint} className="w-7 h-7 text-sm rounded-sm hover:bg-surface-alt">
773
+ <button
774
+ type="button"
775
+ onClick={() => shiftView(-1)}
776
+ aria-label="Previous"
777
+ {...previousHint}
778
+ className="w-7 h-7 text-sm rounded-sm hover:bg-surface-alt"
779
+ >
690
780
  &lsaquo;
691
781
  </button>
692
- <button type="button" onClick={goToday} {...todayHint} className="px-2 h-7 text-sm rounded-sm hover:bg-surface-alt">
782
+ <button
783
+ type="button"
784
+ onClick={goToday}
785
+ {...todayHint}
786
+ className="px-2 h-7 text-sm rounded-sm hover:bg-surface-alt"
787
+ >
693
788
  Today
694
789
  </button>
695
- <button type="button" onClick={() => shiftView(1)} aria-label="Next" {...nextHint} className="w-7 h-7 text-sm rounded-sm hover:bg-surface-alt">
790
+ <button
791
+ type="button"
792
+ onClick={() => shiftView(1)}
793
+ aria-label="Next"
794
+ {...nextHint}
795
+ className="w-7 h-7 text-sm rounded-sm hover:bg-surface-alt"
796
+ >
696
797
  &rsaquo;
697
798
  </button>
698
799
  </div>
@@ -722,8 +823,17 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
722
823
  </div>
723
824
  )}
724
825
 
725
- <div ref={swipe.ref} className="flex-1 min-w-0 flex flex-col min-h-0 overflow-hidden" {...swipe.handlers} style={swipe.style}>
726
- <div data-swipe-phase={swipe.phase} className="flex-1 min-w-0 flex flex-col min-h-0" style={swipe.contentStyle}>
826
+ <div
827
+ ref={swipe.ref}
828
+ className="flex-1 min-w-0 flex flex-col min-h-0 overflow-hidden"
829
+ {...swipe.handlers}
830
+ style={swipe.style}
831
+ >
832
+ <div
833
+ data-swipe-phase={swipe.phase}
834
+ className="flex-1 min-w-0 flex flex-col min-h-0"
835
+ style={swipe.contentStyle}
836
+ >
727
837
  <div className="flex-1 min-w-0 flex flex-col min-h-0" style={wheelSlide.style}>
728
838
  {loading ? (
729
839
  <p className="p-4 text-sm text-text-muted">Loading&hellip;</p>
@@ -751,7 +861,9 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
751
861
  folderColors={folderColors}
752
862
  onSelectDay={handleSelectDay}
753
863
  onSelectEvent={openEvent}
754
- onSelectSlot={(start, end, anchor) => openNewEvent(start, end, undefined, anchor, true)}
864
+ onSelectSlot={(start, end, anchor) =>
865
+ openNewEvent(start, end, undefined, anchor, true)
866
+ }
755
867
  />
756
868
  ) : view === "split" ? (
757
869
  <SplitDayView
@@ -759,7 +871,9 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
759
871
  columns={splitColumns}
760
872
  occurrences={occurrences}
761
873
  onSelectEvent={openEvent}
762
- onSelectSlot={(start, end, targetFolderUid, anchor) => openNewEvent(start, end, targetFolderUid, anchor)}
874
+ onSelectSlot={(start, end, targetFolderUid, anchor) =>
875
+ openNewEvent(start, end, targetFolderUid, anchor)
876
+ }
763
877
  />
764
878
  ) : (
765
879
  <TimeGridView
@@ -767,7 +881,9 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
767
881
  occurrences={occurrences}
768
882
  folderColors={folderColors}
769
883
  onSelectEvent={openEvent}
770
- onSelectSlot={(start, end, anchor) => openNewEvent(start, end, undefined, anchor)}
884
+ onSelectSlot={(start, end, anchor) =>
885
+ openNewEvent(start, end, undefined, anchor)
886
+ }
771
887
  />
772
888
  )}
773
889
  </DndContext>
@@ -787,7 +903,9 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
787
903
  onClose={closeModal}
788
904
  mailboxUid={modalMailbox.uid}
789
905
  folderUid={modalFolderUid!}
790
- calendars={calendarFolders.filter((f) => f.mailboxUid === modalMailbox.uid).map((f) => ({ uid: f.uid, name: f.name }))}
906
+ calendars={calendarFolders
907
+ .filter((f) => f.mailboxUid === modalMailbox.uid)
908
+ .map((f) => ({ uid: f.uid, name: f.name }))}
791
909
  mailboxOptions={mailboxOptions}
792
910
  folderColors={folderColors}
793
911
  organizerAddress={modalMailbox.primarySmtpAddress}
@@ -816,7 +934,9 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
816
934
  )}
817
935
  {/* The phone layout's New event: the old toolbar button's action (its quick form is a bottom sheet there, so no anchor), offered
818
936
  only where that button could act - a calendar to put the event in - and not while the editor or its sheet is open. */}
819
- {isMobile && mailboxUid && folderUid && !modal && <FloatingActionButton label="New event" icon={HiOutlinePlus} onClick={() => openNewEvent()} />}
937
+ {isMobile && mailboxUid && folderUid && !modal && (
938
+ <FloatingActionButton label="New event" icon={HiOutlinePlus} onClick={() => openNewEvent()} />
939
+ )}
820
940
  </div>
821
941
  </div>
822
942
  );
@@ -160,11 +160,18 @@ async function decryptEncryptedRows(messages: Message[], unlocked: UnlockedKeys)
160
160
  DECRYPT_CONCURRENCY,
161
161
  async (message): Promise<[string, DecryptedRow] | null> => {
162
162
  try {
163
- const rawMime = await getMessageRawContent(message.uid);
164
163
  // Loaded here, on first use: the S/MIME code (PKI.js, ASN.1, X.509) is over half a megabyte and an inbox with
165
164
  // no encrypted mail in it never needs it.
166
- const { evaluateMessageSecurity } = await (securityModule ??= import("../../lib/crypto/messageSecurity.js"));
167
- const security = await evaluateMessageSecurity(rawMime, unlocked);
165
+ const securityApi = await (securityModule ??= import("../../lib/crypto/messageSecurity.js"));
166
+ // A message sealed at rest carries its subject and preview sealed on the row: no download needed.
167
+ const summary = message.zeroAccess ? await securityApi.openZeroAccessSummary(message.zeroAccess.sealedSummary, unlocked.zeroAccessKeys) : undefined;
168
+ if (summary) {
169
+ const preview = summary.preview.slice(0, LIST_PREVIEW_MAX_LENGTH);
170
+ rememberDecrypted(message.uid, message.mailboxUid, { subject: summary.subject, preview });
171
+ return [message.uid, { subject: summary.subject, preview }];
172
+ }
173
+ const rawMime = await getMessageRawContent(message.uid);
174
+ const security = await securityApi.evaluateMessageSecurity(rawMime, unlocked);
168
175
  if (!security.subject && !security.html) {
169
176
  markDecryptFailed(message.uid);
170
177
  return null;
@@ -51,6 +51,8 @@ import { notifyApiError } from "../../../shared/notifications/apiErrors.js";
51
51
  import Alert from "../../../../lib/components/feedback/Alert.js";
52
52
  import Button from "../../../../lib/components/buttons/Button.js";
53
53
  import Modal from "../../../../lib/components/overlays/Modal.js";
54
+ import ZeroAccessSetting from "../../../shared/components/settings/ZeroAccessSetting.js";
55
+ import KeyExportSetting from "../../../shared/components/settings/KeyExportSetting.js";
54
56
  import SigningCertificateCard, { SigningCertificateMode } from "../../../shared/components/settings/SigningCertificateCard.js";
55
57
  import { readStoredSignEnrollment, storeSignEnrollment } from "../../../shared/signing/enrollmentStorage.js";
56
58
  import {
@@ -975,6 +977,9 @@ function EncryptionContent({ canManageKeys }: { canManageKeys: boolean }) {
975
977
  )}
976
978
  </div>
977
979
 
980
+ <ZeroAccessSetting mailbox={mailbox} canManage={canManageKeys} />
981
+ <KeyExportSetting mailbox={mailbox} canManage={canManageKeys} />
982
+
978
983
  <div>
979
984
  <h2 className="text-sm font-semibold mb-2">Digital signatures</h2>
980
985
  {activeSigningKey && signingStatus !== "pending" && (
@@ -174,7 +174,8 @@ function ExportSection({ mailboxUid, mailboxLabel }: { mailboxUid?: string; mail
174
174
  <p className="text-xs text-text-muted mb-3">
175
175
  Requests a copy of <strong>{mailboxLabel}</strong>&rsquo;s messages, contacts, calendar, tasks, and notes. This runs
176
176
  in the background and can take a few minutes — check back here for a download link once it&rsquo;s
177
- ready.
177
+ ready. Mail stored with zero-access encryption is exported encrypted: open it in a mail app that has your
178
+ exported keys (Settings &rsaquo; Encryption).
178
179
  </p>
179
180
 
180
181
  <form onSubmit={handleCreate} className="flex items-end gap-3 mb-5">
@@ -182,7 +182,7 @@ function MatterDetailContent({ uid }) {
182
182
  try {
183
183
  const material = await getAccessRequestMaterial(request.uid);
184
184
  if (seq === materialSeq.current)
185
- setMaterialText(JSON.stringify(material.masterKeyWraps, null, 2));
185
+ setMaterialText(JSON.stringify(material, null, 2));
186
186
  }
187
187
  catch (err) {
188
188
  if (seq === materialSeq.current) {
@@ -270,5 +270,5 @@ function MatterDetailContent({ uid }) {
270
270
  ? "bg-surface-alt text-text-muted"
271
271
  : "bg-success text-white",
272
272
  ].join(" "), children: request.status })] }), actionErrors[request.uid] && _jsx(Alert, { children: actionErrors[request.uid] }), _jsxs("div", { className: "flex gap-3 mt-3", children: [!matter.closedAt && request.status === "pending" && (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", className: "!w-auto", loading: actingOn[request.uid], disabled: actingOn[request.uid], onClick: () => setApproveTarget(request), children: "Approve" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: actingOn[request.uid], disabled: actingOn[request.uid], onClick: () => setDenyTarget(request), children: "Deny" })] })), !matter.closedAt && (request.status === "approved" || request.status === "fulfilled") && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => handleGetMaterial(request), children: "Get material" }))] })] }, request.uid))) })), _jsx(LoadMoreButton, { list: requests, label: "Load more access requests" })] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("div", { className: "flex items-center justify-between mb-3", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide", children: "Export this matter" }), !matter.closedAt && (_jsx(Button, { type: "button", className: "!w-auto", loading: creatingExport, disabled: creatingExport, onClick: handleCreateExport, children: "+ New export" }))] }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Exports messages (narrowed to this matter\u2019s own date range), plus full contacts, calendar events, tasks, and notes, from every custodian mailbox actually assigned to this matter\u2019s escrow scope, as one combined NDJSON file." }), exportError && _jsx(Alert, { children: exportError }), exportRequests.loadError && _jsx(Alert, { children: exportRequests.loadError }), matterExportRequests.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: exportRequests.loading ? "Loading…" : "No export requests yet." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: matterExportRequests.map((request) => (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsxs("span", { children: [new Date(request.dateCreated).toLocaleString(), request.status === "failed" && request.errorMessage && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.errorMessage] }))] }), _jsxs("span", { className: "flex items-center gap-3", children: [_jsx("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${statusBadgeClass(request.status)}`, children: statusLabel(request.status) }), !matter.closedAt && request.status === "ready" && (_jsx("a", { href: matterExportRequestDownloadUrl(request.uid), className: "text-primary-dark hover:underline font-medium", children: "Download" }))] })] }, request.uid))) }))] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-3", children: "Search this matter\u2019s custodians" }), matter.closedAt ? (_jsx("p", { className: "text-sm text-text-muted", children: "This matter is closed, so its custodians can no longer be searched or exported." })) : (_jsxs("form", { onSubmit: handleSearch, className: "flex gap-3 mb-4", children: [_jsx("input", { "aria-label": "Search this matter", className: INPUT_CLASS, value: searchText, onChange: (e) => setSearchText(e.target.value), placeholder: "Free text or an operator filter, e.g. from:alice@example.com" }), _jsx(Button, { type: "submit", className: "!w-auto shrink-0", loading: searching, disabled: searching || !searchText.trim(), children: "Search" })] })), searchError && _jsx(Alert, { children: searchError }), searchResults &&
273
- (Object.keys(searchResults).length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No custodian mailboxes could be searched." })) : (_jsx("div", { className: "flex flex-col gap-4", children: Object.entries(searchResults).map(([mailboxUid, page]) => (_jsxs("div", { children: [_jsx("h3", { className: "text-sm font-semibold mb-2", children: mailboxUid }), page.results.length === 0 ? (_jsx("p", { className: "text-xs text-text-muted", children: "No matches." })) : (_jsx("ul", { className: "flex flex-col gap-1", children: page.results.map((result) => (_jsxs("li", { className: "text-sm py-1 px-2 bg-surface-alt rounded-sm", children: [result.entityType, " \u00B7 ", result.entityUid, result.snippet && _jsxs("span", { className: "text-text-muted", children: [" \u2014 ", result.snippet] })] }, `${result.entityType}-${result.entityUid}`))) }))] }, mailboxUid))) })))] }), _jsxs(Modal, { open: confirmingClose, onClose: closeCloseModal, title: "Close matter", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Close ", _jsx("strong", { children: matter.name }), "? Once closed, no new access requests, exports, searches, or key material reads are allowed under it. This cannot be reopened here."] }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm mb-4", children: [_jsx("dt", { className: "text-text-muted", children: "Custodians" }), _jsx("dd", { className: "break-all", children: matter.custodianMailboxUids.join(", ") }), _jsx("dt", { className: "text-text-muted", children: "Date range" }), _jsx("dd", { children: formatDateRange(matter) })] }), closeError && _jsx(Alert, { children: closeError }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: closing, onClick: closeCloseModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: closing, disabled: closing, onClick: handleClose, children: "Close matter" })] })] }), _jsxs(Modal, { open: approveTarget !== null, onClose: () => setApproveTarget(null), title: "Approve access request", children: [_jsx("p", { className: "text-sm mb-3", children: "Approving lets holders of this matter\u2019s escrow scope read the key material for this mailbox once enough holders have approved." }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm mb-4", children: [_jsx("dt", { className: "text-text-muted", children: "Matter" }), _jsx("dd", { children: matter.name }), _jsx("dt", { className: "text-text-muted", children: "Mailbox" }), _jsx("dd", { className: "break-all", children: approveTarget?.mailboxUid }), _jsx("dt", { className: "text-text-muted", children: "Date range" }), _jsx("dd", { children: formatDateRange(matter) }), _jsx("dt", { className: "text-text-muted", children: "Approvals" }), _jsxs("dd", { children: [approveTarget?.approvals.length, " of ", approveTarget?.requiredHoldersAtCreation, " so far, including requester ", approveTarget?.requestedByUserUid] })] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setApproveTarget(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => void handleApprove(), children: "Approve request" })] })] }), _jsxs(Modal, { open: denyTarget !== null, onClose: () => setDenyTarget(null), title: "Deny access request", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Denying refuses this request for good: it can\u2019t be approved afterwards, and holders can\u2019t read the key material for mailbox ", _jsx("span", { className: "break-all font-semibold", children: denyTarget?.mailboxUid }), " through it. A new request has to be made to ask again."] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setDenyTarget(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: () => void handleDeny(), children: "Deny request" })] })] }), _jsx(Modal, { open: showNewRequest, onClose: () => setShowNewRequest(false), title: "New access request", children: _jsxs("form", { onSubmit: handleCreateRequest, className: "flex flex-col gap-4", children: [newRequestError && _jsx(Alert, { children: newRequestError }), _jsx(FormField, { label: "Mailbox uid", htmlFor: "newRequestMailboxUid", children: _jsx("input", { id: "newRequestMailboxUid", type: "text", className: INPUT_CLASS, value: newRequestMailboxUid, onChange: (e) => setNewRequestMailboxUid(e.target.value) }) }), _jsx("p", { className: "text-xs text-text-muted", children: "Must be one of this matter's own custodian mailboxes, currently assigned to this matter's escrow scope. Your own creation counts as the first approval." }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: creatingRequest, onClick: () => setShowNewRequest(false), children: "Cancel" }), _jsx(Button, { type: "submit", className: "!w-auto", loading: creatingRequest, disabled: creatingRequest, children: "Create request" })] })] }) }), _jsx(Modal, { open: materialFor !== null, onClose: closeMaterialModal, title: "Escrow key material", children: loadingMaterial ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : materialError ? (_jsx(Alert, { children: materialError })) : (_jsxs(_Fragment, { children: [_jsxs("p", { className: "text-sm text-text-muted mb-3", children: ["Still-encrypted master key wraps for ", _jsx("strong", { children: materialFor?.mailboxUid }), " \u2014 this server cannot decrypt these either. Copy the JSON below into your own offline tooling."] }), _jsx("pre", { className: "text-xs font-mono bg-surface-alt border border-border rounded-sm p-3 overflow-x-auto whitespace-pre-wrap break-all", children: materialText })] })) })] }));
273
+ (Object.keys(searchResults).length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No custodian mailboxes could be searched." })) : (_jsx("div", { className: "flex flex-col gap-4", children: Object.entries(searchResults).map(([mailboxUid, page]) => (_jsxs("div", { children: [_jsx("h3", { className: "text-sm font-semibold mb-2", children: mailboxUid }), page.results.length === 0 ? (_jsx("p", { className: "text-xs text-text-muted", children: "No matches." })) : (_jsx("ul", { className: "flex flex-col gap-1", children: page.results.map((result) => (_jsxs("li", { className: "text-sm py-1 px-2 bg-surface-alt rounded-sm", children: [result.entityType, " \u00B7 ", result.entityUid, result.snippet && _jsxs("span", { className: "text-text-muted", children: [" \u2014 ", result.snippet] })] }, `${result.entityType}-${result.entityUid}`))) }))] }, mailboxUid))) })))] }), _jsxs(Modal, { open: confirmingClose, onClose: closeCloseModal, title: "Close matter", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Close ", _jsx("strong", { children: matter.name }), "? Once closed, no new access requests, exports, searches, or key material reads are allowed under it. This cannot be reopened here."] }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm mb-4", children: [_jsx("dt", { className: "text-text-muted", children: "Custodians" }), _jsx("dd", { className: "break-all", children: matter.custodianMailboxUids.join(", ") }), _jsx("dt", { className: "text-text-muted", children: "Date range" }), _jsx("dd", { children: formatDateRange(matter) })] }), closeError && _jsx(Alert, { children: closeError }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: closing, onClick: closeCloseModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: closing, disabled: closing, onClick: handleClose, children: "Close matter" })] })] }), _jsxs(Modal, { open: approveTarget !== null, onClose: () => setApproveTarget(null), title: "Approve access request", children: [_jsx("p", { className: "text-sm mb-3", children: "Approving lets holders of this matter\u2019s escrow scope read the key material for this mailbox once enough holders have approved." }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm mb-4", children: [_jsx("dt", { className: "text-text-muted", children: "Matter" }), _jsx("dd", { children: matter.name }), _jsx("dt", { className: "text-text-muted", children: "Mailbox" }), _jsx("dd", { className: "break-all", children: approveTarget?.mailboxUid }), _jsx("dt", { className: "text-text-muted", children: "Date range" }), _jsx("dd", { children: formatDateRange(matter) }), _jsx("dt", { className: "text-text-muted", children: "Approvals" }), _jsxs("dd", { children: [approveTarget?.approvals.length, " of ", approveTarget?.requiredHoldersAtCreation, " so far, including requester ", approveTarget?.requestedByUserUid] })] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setApproveTarget(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => void handleApprove(), children: "Approve request" })] })] }), _jsxs(Modal, { open: denyTarget !== null, onClose: () => setDenyTarget(null), title: "Deny access request", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Denying refuses this request for good: it can\u2019t be approved afterwards, and holders can\u2019t read the key material for mailbox ", _jsx("span", { className: "break-all font-semibold", children: denyTarget?.mailboxUid }), " through it. A new request has to be made to ask again."] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setDenyTarget(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: () => void handleDeny(), children: "Deny request" })] })] }), _jsx(Modal, { open: showNewRequest, onClose: () => setShowNewRequest(false), title: "New access request", children: _jsxs("form", { onSubmit: handleCreateRequest, className: "flex flex-col gap-4", children: [newRequestError && _jsx(Alert, { children: newRequestError }), _jsx(FormField, { label: "Mailbox uid", htmlFor: "newRequestMailboxUid", children: _jsx("input", { id: "newRequestMailboxUid", type: "text", className: INPUT_CLASS, value: newRequestMailboxUid, onChange: (e) => setNewRequestMailboxUid(e.target.value) }) }), _jsx("p", { className: "text-xs text-text-muted", children: "Must be one of this matter's own custodian mailboxes, currently assigned to this matter's escrow scope. Your own creation counts as the first approval." }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: creatingRequest, onClick: () => setShowNewRequest(false), children: "Cancel" }), _jsx(Button, { type: "submit", className: "!w-auto", loading: creatingRequest, disabled: creatingRequest, children: "Create request" })] })] }) }), _jsx(Modal, { open: materialFor !== null, onClose: closeMaterialModal, title: "Escrow key material", children: loadingMaterial ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : materialError ? (_jsx(Alert, { children: materialError })) : (_jsxs(_Fragment, { children: [_jsxs("p", { className: "text-sm text-text-muted mb-3", children: ["Still-encrypted key material for ", _jsx("strong", { children: materialFor?.mailboxUid }), ": the escrow master key wrap, and the private keys that read its mail, sealed under that master key. This server cannot decrypt any of it either. Copy the JSON below into your own offline tooling."] }), _jsx("pre", { className: "text-xs font-mono bg-surface-alt border border-border rounded-sm p-3 overflow-x-auto whitespace-pre-wrap break-all", children: materialText })] })) })] }));
274
274
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Calendar events the server made from invitations that reached a zero-access mailbox keep their title, location and description only
3
+ * sealed to the mailbox's zero-access key (`CalendarEvent.sealedContent`; the row's `title` is `[...]`). These are opened here with the
4
+ * mailbox's unlocked keys so the calendar shows the real ones; while the mailbox is locked the event keeps its placeholder. The
5
+ * decryption code is loaded only when a sealed event is actually seen.
6
+ */
7
+ import type { CalendarEvent } from "../../../lib/calendar/calendarApi.js";
8
+ /** Forgets every opened event (sign-out, or the test seam). */
9
+ export declare function clearSealedEvents(): void;
10
+ /** `events` with every sealed one this device can open shown with its real title, location and description. Never rejects. */
11
+ export declare function openSealedEvents(events: CalendarEvent[]): Promise<CalendarEvent[]>;
12
+ /** `openSealedEvents()` for one event. */
13
+ export declare function openSealedEvent(event: CalendarEvent): Promise<CalendarEvent>;
@@ -0,0 +1,54 @@
1
+ import { getUnlockedKeys } from "../../../lib/crypto/keySession.js";
2
+ import { fromBase64 } from "../../../lib/crypto/encoding.js";
3
+ import { onSigningOut } from "../components/mail/compose/composeFlushRegistry.js";
4
+ /** Opened fields by sealed text - the same sealed content always opens to the same fields. Cleared with `clearSealedEvents()`. */
5
+ const opened = new Map();
6
+ /** Forgets every opened event (sign-out, or the test seam). */
7
+ export function clearSealedEvents() {
8
+ opened.clear();
9
+ }
10
+ async function openFields(event) {
11
+ const sealed = event.sealedContent.sealed;
12
+ const known = opened.get(sealed);
13
+ if (known) {
14
+ return known;
15
+ }
16
+ const keys = getUnlockedKeys(event.mailboxUid)?.zeroAccessKeys;
17
+ if (!keys?.length) {
18
+ return undefined;
19
+ }
20
+ try {
21
+ const { decryptEnvelopedDataWithKeys } = await import("../../../lib/crypto/smime.js");
22
+ const fields = JSON.parse(new TextDecoder().decode(await decryptEnvelopedDataWithKeys(fromBase64(sealed), keys)));
23
+ const text = (value) => (typeof value === "string" ? value : undefined);
24
+ const result = {
25
+ title: text(fields.title) ?? event.title,
26
+ location: text(fields.location),
27
+ description: text(fields.description) ?? null,
28
+ descriptionHtml: text(fields.descriptionHtml) ?? null,
29
+ };
30
+ opened.set(sealed, result);
31
+ return result;
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ /** `events` with every sealed one this device can open shown with its real title, location and description. Never rejects. */
38
+ export async function openSealedEvents(events) {
39
+ if (!events.some((event) => event.sealedContent)) {
40
+ return events;
41
+ }
42
+ return Promise.all(events.map(async (event) => {
43
+ if (!event.sealedContent) {
44
+ return event;
45
+ }
46
+ const fields = await openFields(event);
47
+ return fields ? { ...event, ...fields } : event;
48
+ }));
49
+ }
50
+ /** `openSealedEvents()` for one event. */
51
+ export async function openSealedEvent(event) {
52
+ return (await openSealedEvents([event]))[0];
53
+ }
54
+ onSigningOut(clearSealedEvents);