@rapidmx/web-client 0.34.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.
- package/apps/escrow/matters/[uid].tsx +4 -3
- package/apps/shared/calendar/calendarLiveUpdates.ts +2 -5
- package/apps/shared/calendar/sealedEvents.ts +80 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +61 -3
- package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -1
- package/apps/shared/components/contacts/contactCardData.ts +8 -4
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +23 -1
- package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +91 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
- package/apps/shared/components/mail/ConversationList.tsx +1 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +91 -20
- package/apps/shared/components/mail/compose/draftResume.ts +28 -3
- package/apps/shared/components/mail/compose/quotedBody.ts +31 -15
- package/apps/shared/components/mail/invite/inviteStore.ts +7 -1
- package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
- package/apps/shared/components/mail/reading/MessageBody.tsx +4 -1
- package/apps/shared/components/mail/reading/bodyContent.ts +27 -17
- package/apps/shared/components/mail/reading/conversationPrint.ts +7 -5
- package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
- package/apps/shared/components/settings/KeyExportSetting.tsx +170 -0
- package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
- package/apps/shared/components/settings/ZeroAccessSetting.tsx +117 -0
- package/apps/shared/live/useFolderLiveUpdates.ts +59 -0
- package/apps/shared/mail/decryptedMessages.ts +11 -3
- package/apps/shared/mail/outbox/sendJob.ts +2 -2
- package/apps/shared/mail/outbox/sendOutcomes.ts +1 -3
- package/apps/shared/mail/reportNotices.ts +3 -1
- package/apps/shared/mail/zeroAccessSource.ts +27 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +31 -0
- package/apps/shared/search/localIndexBuilder.ts +27 -1
- package/apps/shared/search/localIndexRpcClient.ts +8 -4
- package/apps/shared/search/localIndexSchema.ts +2 -2
- package/apps/shared/search/localIndexTransport.ts +2 -1
- package/apps/shared/search/localIndexWorker.ts +6 -4
- package/apps/shared/search/zeroAccessIndexSync.ts +487 -0
- package/apps/www/calendar/index.tsx +180 -58
- package/apps/www/contacts/index.tsx +58 -2
- package/apps/www/index.tsx +10 -3
- package/apps/www/settings/encryption/index.tsx +5 -0
- package/apps/www/settings/privacy/index.tsx +2 -1
- package/apps/www/tasks/index.tsx +42 -0
- package/dist/apps/escrow/matters/[uid].js +2 -2
- package/dist/apps/shared/calendar/calendarLiveUpdates.js +2 -5
- package/dist/apps/shared/calendar/sealedEvents.d.ts +13 -0
- package/dist/apps/shared/calendar/sealedEvents.js +54 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +18 -2
- package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
- package/dist/apps/shared/components/contacts/contactCardData.d.ts +2 -2
- package/dist/apps/shared/components/contacts/contactCardData.js +8 -4
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +21 -2
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +15 -0
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +72 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
- package/dist/apps/shared/components/mail/ConversationList.js +1 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +73 -17
- package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
- package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +4 -3
- package/dist/apps/shared/components/mail/compose/quotedBody.js +28 -15
- package/dist/apps/shared/components/mail/invite/inviteStore.js +6 -1
- package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
- package/dist/apps/shared/components/mail/reading/MessageBody.js +4 -1
- package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +4 -2
- package/dist/apps/shared/components/mail/reading/bodyContent.js +27 -16
- package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +3 -2
- package/dist/apps/shared/components/mail/reading/conversationPrint.js +7 -5
- package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
- package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
- package/dist/apps/shared/components/settings/KeyExportSetting.js +88 -0
- package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
- package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +14 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.js +71 -0
- package/dist/apps/shared/live/useFolderLiveUpdates.d.ts +19 -0
- package/dist/apps/shared/live/useFolderLiveUpdates.js +41 -0
- package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
- package/dist/apps/shared/mail/decryptedMessages.js +11 -3
- package/dist/apps/shared/mail/outbox/sendJob.js +2 -2
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +1 -3
- package/dist/apps/shared/mail/reportNotices.js +3 -1
- package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
- package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +28 -0
- package/dist/apps/shared/search/localIndexBuilder.d.ts +5 -0
- package/dist/apps/shared/search/localIndexBuilder.js +24 -2
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +1 -1
- package/dist/apps/shared/search/localIndexRpcClient.js +6 -4
- package/dist/apps/shared/search/localIndexSchema.d.ts +1 -1
- package/dist/apps/shared/search/localIndexSchema.js +2 -2
- package/dist/apps/shared/search/localIndexTransport.d.ts +2 -1
- package/dist/apps/shared/search/localIndexWorker.d.ts +2 -0
- package/dist/apps/shared/search/localIndexWorker.js +4 -4
- package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +71 -0
- package/dist/apps/shared/search/zeroAccessIndexSync.js +382 -0
- package/dist/apps/www/calendar/index.js +48 -22
- package/dist/apps/www/contacts/index.js +49 -3
- package/dist/apps/www/index.js +10 -3
- package/dist/apps/www/settings/encryption/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.js +1 -1
- package/dist/apps/www/tasks/index.js +29 -1
- package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
- package/dist/lib/calendar/calendarApi.d.ts +6 -0
- package/dist/lib/calendar/inviteApi.js +27 -5
- package/dist/lib/calendar/sealedInvites.d.ts +11 -0
- package/dist/lib/calendar/sealedInvites.js +28 -0
- package/dist/lib/crypto/keyExport.d.ts +54 -0
- package/dist/lib/crypto/keyExport.js +161 -0
- package/dist/lib/crypto/keySession.d.ts +45 -1
- package/dist/lib/crypto/keySession.js +93 -2
- package/dist/lib/crypto/keyvaultApi.d.ts +88 -3
- package/dist/lib/crypto/keyvaultApi.js +32 -0
- package/dist/lib/crypto/messageSecurity.d.ts +30 -1
- package/dist/lib/crypto/messageSecurity.js +80 -4
- package/dist/lib/crypto/mime.d.ts +5 -0
- package/dist/lib/crypto/mime.js +25 -0
- package/dist/lib/crypto/sharedMailboxKeys.d.ts +50 -0
- package/dist/lib/crypto/sharedMailboxKeys.js +264 -0
- package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
- package/dist/lib/crypto/zeroAccessKey.js +66 -0
- package/dist/lib/mail/conversationsApi.d.ts +6 -0
- package/dist/lib/mail/mailApi.d.ts +30 -2
- package/dist/lib/mail/mailApi.js +5 -1
- package/dist/lib/mail/pushClient.d.ts +10 -3
- package/dist/lib/mail/pushClient.js +18 -4
- package/dist/lib/search/zeroAccessIndexApi.d.ts +81 -0
- package/dist/lib/search/zeroAccessIndexApi.js +35 -0
- package/dist/lib/tasks/tasksApi.d.ts +1 -0
- package/dist/lib/tasks/tasksApi.js +3 -0
- package/lib/admin/escrowAccessRequestsApi.ts +6 -0
- package/lib/calendar/calendarApi.ts +3 -0
- package/lib/calendar/inviteApi.ts +28 -5
- package/lib/calendar/sealedInvites.ts +33 -0
- package/lib/crypto/keyExport.ts +229 -0
- package/lib/crypto/keySession.ts +117 -3
- package/lib/crypto/keyvaultApi.ts +125 -3
- package/lib/crypto/messageSecurity.ts +110 -3
- package/lib/crypto/mime.ts +26 -0
- package/lib/crypto/sharedMailboxKeys.ts +324 -0
- package/lib/crypto/zeroAccessKey.ts +97 -0
- package/lib/mail/conversationsApi.ts +3 -0
- package/lib/mail/mailApi.ts +33 -3
- package/lib/mail/pushClient.ts +18 -4
- package/lib/search/zeroAccessIndexApi.ts +119 -0
- package/lib/tasks/tasksApi.ts +4 -0
- package/package.json +3 -3
|
@@ -13,15 +13,16 @@ import { ApiRequestError } from "../../../lib/util/api.js";
|
|
|
13
13
|
import { useApiClient } from "../../../lib/util/apiClientContext.js";
|
|
14
14
|
import useIsMobile from "../../../lib/util/useIsMobile.js";
|
|
15
15
|
import { getCalendarEvent, listCalendarEvents } from "../../../lib/calendar/calendarApi.js";
|
|
16
|
-
import {
|
|
16
|
+
import { openSealedEvent, openSealedEvents } from "../../shared/calendar/sealedEvents.js";
|
|
17
|
+
import { useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
|
|
17
18
|
import { moveOccurrence, resizeOccurrenceEnd } from "../../../lib/calendar/calendarMutations.js";
|
|
18
19
|
import { resolveDragAction } from "../../../lib/calendar/calendarDragIds.js";
|
|
19
20
|
import { createFolder } from "../../../lib/mail/mailApi.js";
|
|
20
21
|
import { expandAllOccurrences, expandOccurrences } from "../../../lib/calendar/recurrence.js";
|
|
21
22
|
import { useDayKey } from "../../../lib/calendar/useDayKey.js";
|
|
22
|
-
import { allOccurrences, initialMatchIndex, occurrencesInRange, searchOccurrences, searchTerms, stepSequenceIndex } from "../../../lib/calendar/eventSearch.js";
|
|
23
|
+
import { allOccurrences, initialMatchIndex, occurrencesInRange, searchOccurrences, searchTerms, stepSequenceIndex, } from "../../../lib/calendar/eventSearch.js";
|
|
23
24
|
import Drawer from "../../../lib/components/overlays/Drawer.js";
|
|
24
|
-
import CalendarShell, { useCalendarShell } from "../../shared/components/calendar/layout/CalendarShell.js";
|
|
25
|
+
import CalendarShell, { useCalendarShell, } from "../../shared/components/calendar/layout/CalendarShell.js";
|
|
25
26
|
import CalendarListSidebar from "../../shared/components/calendar/CalendarListSidebar.js";
|
|
26
27
|
import CalendarSearchBox from "../../shared/components/calendar/CalendarSearchBox.js";
|
|
27
28
|
import { ActiveOccurrenceContext } from "../../shared/components/calendar/activeOccurrence.js";
|
|
@@ -37,7 +38,7 @@ import SplitDayView from "../../shared/components/calendar/SplitDayView.js";
|
|
|
37
38
|
import TimeGridView from "../../shared/components/calendar/TimeGridView.js";
|
|
38
39
|
import Alert from "../../../lib/components/feedback/Alert.js";
|
|
39
40
|
import Button from "../../../lib/components/buttons/Button.js";
|
|
40
|
-
import { CALENDAR_VIEWS, getStoredCalendarView, isCalendarView, storeCalendarView } from "../../shared/components/calendar/calendarView.js";
|
|
41
|
+
import { CALENDAR_VIEWS, getStoredCalendarView, isCalendarView, storeCalendarView, } from "../../shared/components/calendar/calendarView.js";
|
|
41
42
|
import { SWIPE_PERIOD_SHIFT } from "../../shared/components/calendar/swipeNavigation.js";
|
|
42
43
|
import { nextHalfHour } from "../../shared/components/calendar/timePicker.js";
|
|
43
44
|
import { useSwipeSlide } from "../../shared/gestures/useSwipeSlide.js";
|
|
@@ -49,12 +50,21 @@ import { useShortcutProps } from "../../shared/keyboard/useShortcutProps.js";
|
|
|
49
50
|
import { notifyApiError } from "../../shared/notifications/apiErrors.js";
|
|
50
51
|
import { bookingSettingsHref } from "../../shared/calendar/bookingPlugin.js";
|
|
51
52
|
import { changedEventOf } from "../../shared/calendar/calendarLiveUpdates.js";
|
|
53
|
+
/** The push client channel group (`PushClient.setChannels()`) the page's calendars are subscribed under. */
|
|
54
|
+
const CALENDAR_CHANNEL_GROUP = "calendar";
|
|
52
55
|
/** The furthest instants a `Date` can hold: the List's "range" reaches both. */
|
|
53
56
|
const MAX_DATE_MS = 8.64e15;
|
|
54
57
|
function CalendarPage(props) {
|
|
55
58
|
return (_jsx(CalendarShell, { ...props, children: _jsx(CalendarContent, { userUid: props.userUid, bookingHref: bookingSettingsHref(props.pluginNav) }) }));
|
|
56
59
|
}
|
|
57
|
-
const VIEW_LABELS = {
|
|
60
|
+
const VIEW_LABELS = {
|
|
61
|
+
list: "List",
|
|
62
|
+
month: "Month",
|
|
63
|
+
week: "Week",
|
|
64
|
+
workWeek: "Work Week",
|
|
65
|
+
day: "Day",
|
|
66
|
+
split: "Split",
|
|
67
|
+
};
|
|
58
68
|
/** The shortcut that switches to a view (Outlook's Ctrl+Alt+1-4, the List one more); the split view has none. */
|
|
59
69
|
const VIEW_SHORTCUTS = {
|
|
60
70
|
list: SHORTCUTS.calendar.list,
|
|
@@ -64,7 +74,7 @@ const VIEW_SHORTCUTS = {
|
|
|
64
74
|
month: SHORTCUTS.calendar.month,
|
|
65
75
|
};
|
|
66
76
|
/** One of the view switcher's buttons, with its shortcut in the tooltip. */
|
|
67
|
-
function ViewButton({ view, current, onSelect }) {
|
|
77
|
+
function ViewButton({ view, current, onSelect, }) {
|
|
68
78
|
const shortcut = VIEW_SHORTCUTS[view];
|
|
69
79
|
const hint = useShortcutProps(VIEW_LABELS[view], shortcut ?? SHORTCUTS.calendar.day, !!shortcut);
|
|
70
80
|
return (_jsx("button", { type: "button", onClick: () => onSelect(view), ...hint, className: [
|
|
@@ -196,7 +206,7 @@ function CalendarContent({ userUid, bookingHref }) {
|
|
|
196
206
|
}
|
|
197
207
|
setLoading(true);
|
|
198
208
|
setError(null);
|
|
199
|
-
void Promise.allSettled(targets.map((f) => listCalendarEvents(f.uid, client)))
|
|
209
|
+
void Promise.allSettled(targets.map((f) => listCalendarEvents(f.uid, client).then(openSealedEvents)))
|
|
200
210
|
.then((results) => {
|
|
201
211
|
if (seq !== reloadSeqRef.current) {
|
|
202
212
|
return;
|
|
@@ -235,7 +245,10 @@ function CalendarContent({ userUid, bookingHref }) {
|
|
|
235
245
|
}
|
|
236
246
|
// Keyed on stable string summaries (not the array/set objects themselves, which are new references
|
|
237
247
|
// every render) so this only re-fires when the actual set of folders to fetch changes.
|
|
238
|
-
const calendarFolderUidsKey = calendarFolders
|
|
248
|
+
const calendarFolderUidsKey = calendarFolders
|
|
249
|
+
.map((f) => f.uid)
|
|
250
|
+
.sort()
|
|
251
|
+
.join(",");
|
|
239
252
|
const checkedFolderUidsKey = Array.from(checkedFolderUids).sort().join(",");
|
|
240
253
|
useEffect(reload, [calendarFolderUidsKey, checkedFolderUidsKey]);
|
|
241
254
|
// An event that somebody creates, changes or deletes is announced on the push connection - a private or confidential one as its busy block, whatever the
|
|
@@ -244,17 +257,17 @@ function CalendarContent({ userUid, bookingHref }) {
|
|
|
244
257
|
// fetch changes nothing: the next load shows the event as it is.
|
|
245
258
|
const checkedFolderUidsRef = useRef(checkedFolderUids);
|
|
246
259
|
checkedFolderUidsRef.current = checkedFolderUids;
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
260
|
+
// Those events are published on the calendar's own channel, so the page subscribes to every calendar it lists - checked or not, so checking
|
|
261
|
+
// one needs no new subscription - while it is open.
|
|
262
|
+
useFolderLiveUpdates(CALENDAR_CHANNEL_GROUP, calendarFolders.map((f) => f.uid), changedEventOf, (change) => {
|
|
252
263
|
const { uid } = change;
|
|
253
264
|
if (change.deleted) {
|
|
254
265
|
setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
|
|
255
266
|
return;
|
|
256
267
|
}
|
|
257
|
-
void getCalendarEvent(uid, client)
|
|
268
|
+
void getCalendarEvent(uid, client)
|
|
269
|
+
.then(openSealedEvent)
|
|
270
|
+
.then((fresh) => {
|
|
258
271
|
if (checkedFolderUidsRef.current.has(fresh.folderUid)) {
|
|
259
272
|
setEvents((previous) => previous.some((existing) => existing.uid === fresh.uid)
|
|
260
273
|
? previous.map((existing) => (existing.uid === fresh.uid ? fresh : existing))
|
|
@@ -265,7 +278,7 @@ function CalendarContent({ userUid, bookingHref }) {
|
|
|
265
278
|
setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
|
|
266
279
|
}
|
|
267
280
|
});
|
|
268
|
-
})
|
|
281
|
+
});
|
|
269
282
|
// 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
|
|
270
283
|
// event of the checked calendars whatever the view shows (see `searchOccurrences()`); while one runs the views show only its matches, and
|
|
271
284
|
// `activeKey` is the match being pointed at (Previous/Next match step through `matches`).
|
|
@@ -305,7 +318,9 @@ function CalendarContent({ userUid, bookingHref }) {
|
|
|
305
318
|
if (view === "list") {
|
|
306
319
|
return sequence;
|
|
307
320
|
}
|
|
308
|
-
return searching
|
|
321
|
+
return searching
|
|
322
|
+
? occurrencesInRange(matches, rangeStart, rangeEnd)
|
|
323
|
+
: expandAllOccurrences(events, rangeStart, rangeEnd);
|
|
309
324
|
}, [events, matches, searching, view, sequence, rangeStart, rangeEnd]);
|
|
310
325
|
/** Points at `match`, moving the view to the day it is on unless the view already shows it. */
|
|
311
326
|
function showMatch(match) {
|
|
@@ -354,7 +369,9 @@ function CalendarContent({ userUid, bookingHref }) {
|
|
|
354
369
|
searchedQueryRef.current = query;
|
|
355
370
|
// The List holds every match: it starts at the first from the day being viewed on rather than the first of all.
|
|
356
371
|
const from = startOfDay(viewDate);
|
|
357
|
-
showMatch(matches[view === "list"
|
|
372
|
+
showMatch(matches[view === "list"
|
|
373
|
+
? initialMatchIndex(matches, from, rangeEnd, from)
|
|
374
|
+
: initialMatchIndex(matches, rangeStart, rangeEnd, from)]);
|
|
358
375
|
}
|
|
359
376
|
}, [matches, query, searching]);
|
|
360
377
|
const splitColumns = useMemo(() => calendarFolders
|
|
@@ -407,7 +424,9 @@ function CalendarContent({ userUid, bookingHref }) {
|
|
|
407
424
|
// Brings the match being pointed at into sight (the views scroll their own hours; the page may too).
|
|
408
425
|
useEffect(() => {
|
|
409
426
|
const marked = Array.from(swipe.ref.current.querySelectorAll("[data-occurrence-key]"));
|
|
410
|
-
marked
|
|
427
|
+
marked
|
|
428
|
+
.find((el) => el.dataset.occurrenceKey === activeKey)
|
|
429
|
+
?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
411
430
|
}, [activeKey, view === "list" ? null : viewDate, view, loading]);
|
|
412
431
|
// 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.
|
|
413
432
|
const [jumpNonce, setJumpNonce] = useState(0);
|
|
@@ -461,10 +480,12 @@ function CalendarContent({ userUid, bookingHref }) {
|
|
|
461
480
|
const requestedStart = new Date(params.get("start") ?? "");
|
|
462
481
|
navigateTo("/calendar", { replace: true });
|
|
463
482
|
getCalendarEvent(eventUid, client)
|
|
483
|
+
.then(openSealedEvent)
|
|
464
484
|
.then((event) => {
|
|
465
485
|
const from = isNaN(requestedStart.getTime()) ? new Date(event.startDate) : requestedStart;
|
|
466
486
|
const candidates = expandOccurrences(event, new Date(from.getTime() - 86400000), new Date(from.getTime() + 86400000));
|
|
467
|
-
const match = candidates.find((occurrence) => new Date(occurrence.startDate).getTime() === from.getTime()) ??
|
|
487
|
+
const match = candidates.find((occurrence) => new Date(occurrence.startDate).getTime() === from.getTime()) ??
|
|
488
|
+
candidates[0];
|
|
468
489
|
if (match) {
|
|
469
490
|
setViewDate(occurrenceDay(match));
|
|
470
491
|
openEvent(match);
|
|
@@ -535,12 +556,17 @@ function CalendarContent({ userUid, bookingHref }) {
|
|
|
535
556
|
const writableMailboxes = useWritableMailboxes(mailboxes, userUid, modalMailboxUid);
|
|
536
557
|
const mailboxOptions = useMemo(() => mailboxCalendars
|
|
537
558
|
.filter((mc) => mc.calendarFolders.length > 0 && writableMailboxes.some((mb) => mb.uid === mc.mailbox.uid))
|
|
538
|
-
.map((mc) => ({
|
|
559
|
+
.map((mc) => ({
|
|
560
|
+
mailbox: mc.mailbox,
|
|
561
|
+
calendars: mc.calendarFolders.map((f) => ({ uid: f.uid, name: f.name })),
|
|
562
|
+
})), [mailboxCalendars, writableMailboxes]);
|
|
539
563
|
const sidebarContent = (_jsxs(_Fragment, { children: [_jsx(MiniDatePicker, { selected: viewDate, onSelect: (date) => {
|
|
540
564
|
setViewDate(startOfDay(date));
|
|
541
565
|
setJumpNonce((n) => n + 1);
|
|
542
566
|
} }), _jsx(CalendarListSidebar, { mailboxCalendars: mailboxCalendars, checkedFolderUids: checkedFolderUids, onToggle: toggleCalendar, onAddCalendar: handleAddCalendar, colorFor: colorFor })] }));
|
|
543
|
-
return (_jsxs("div", { className: "flex-1 min-w-0 flex min-h-0", children: [_jsx("div", { className: "hidden lg:flex w-56 shrink-0 border-r border-border flex-col overflow-y-auto", children: sidebarContent }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Calendars", fullScreen: true, children: _jsx("div", { className: "flex flex-col", children: sidebarContent }) }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col min-h-0", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-3 gap-y-2 p-3 border-b border-border shrink-0", children: [_jsx("button", { type: "button", className: "lg:hidden w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text shrink-0", "aria-label": "Open calendars", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), !isMobile && (_jsx(Button, { type: "button", onClick: (e) => openNewEvent(undefined, undefined, undefined, anchorOf(e.currentTarget, "below")), className: "!w-auto shrink-0", ...newEventHint, children: "+ New event" })), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("button", { type: "button", onClick: () => shiftView(-1), "aria-label": "Previous", ...previousHint, className: "w-7 h-7 text-sm rounded-sm hover:bg-surface-alt", children: "\u2039" }), _jsx("button", { type: "button", onClick: goToday, ...todayHint, className: "px-2 h-7 text-sm rounded-sm hover:bg-surface-alt", children: "Today" }), _jsx("button", { type: "button", onClick: () => shiftView(1), "aria-label": "Next", ...nextHint, className: "w-7 h-7 text-sm rounded-sm hover:bg-surface-alt", children: "\u203A" })] }), _jsx("h1", { className: "hidden md:block text-lg font-bold tracking-tight", children: title }), _jsx(CalendarSearchBox, { value: searchText, onChange: setSearchText, onSubmit: submitSearch, onClear: clearSearch, onPrevious: () => stepMatch(-1), onNext: () => stepMatch(1), active: searching, count: matches.length, position: activeIndex, inputRef: searchInputRef }), _jsx("div", { className: "flex flex-wrap gap-1", children: CALENDAR_VIEWS.map((v) => (_jsx(ViewButton, { view: v, current: view, onSelect: setView }, v))) })] }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), _jsx("div", { ref: swipe.ref, className: "flex-1 min-w-0 flex flex-col min-h-0 overflow-hidden", ...swipe.handlers, style: swipe.style, children: _jsx("div", { "data-swipe-phase": swipe.phase, className: "flex-1 min-w-0 flex flex-col min-h-0", style: swipe.contentStyle, children: _jsx("div", { className: "flex-1 min-w-0 flex flex-col min-h-0", style: wheelSlide.style, children: loading ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : (_jsx(ActiveOccurrenceContext.Provider, { value: searching ? activeKey : null, children: _jsx(DndContext, { sensors: sensors, onDragStart: () => setDragging(true), onDragEnd: handleDragEnd, onDragCancel: () => setDragging(false), children: view === "list" ? (_jsx(ListView, { occurrences: occurrences, folderColors: folderColors, onSelectEvent: openEvent, focusDate: viewDate, jumpNonce: jumpNonce, step: listStep })) : view === "month" ? (_jsx(MonthView, { viewDate: viewDate, occurrences: occurrences, folderColors: folderColors, onSelectDay: handleSelectDay, onSelectEvent: openEvent, onSelectSlot: (start, end, anchor) => openNewEvent(start, end, undefined, anchor, true) })) : view === "split" ? (_jsx(SplitDayView, { day: viewDate, columns: splitColumns, occurrences: occurrences, onSelectEvent: openEvent, onSelectSlot: (start, end, targetFolderUid, anchor) => openNewEvent(start, end, targetFolderUid, anchor) })) : (_jsx(TimeGridView, { days: days, occurrences: occurrences, folderColors: folderColors, onSelectEvent: openEvent, onSelectSlot: (start, end, anchor) => openNewEvent(start, end, undefined, anchor) })) }) })) }) }) }), modal && modalMailbox && (_jsx(EventModal, { open: true, onClose: closeModal, mailboxUid: modalMailbox.uid, folderUid: modalFolderUid, calendars: calendarFolders
|
|
567
|
+
return (_jsxs("div", { className: "flex-1 min-w-0 flex min-h-0", children: [_jsx("div", { className: "hidden lg:flex w-56 shrink-0 border-r border-border flex-col overflow-y-auto", children: sidebarContent }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Calendars", fullScreen: true, children: _jsx("div", { className: "flex flex-col", children: sidebarContent }) }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col min-h-0", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-3 gap-y-2 p-3 border-b border-border shrink-0", children: [_jsx("button", { type: "button", className: "lg:hidden w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text shrink-0", "aria-label": "Open calendars", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), !isMobile && (_jsx(Button, { type: "button", onClick: (e) => openNewEvent(undefined, undefined, undefined, anchorOf(e.currentTarget, "below")), className: "!w-auto shrink-0", ...newEventHint, children: "+ New event" })), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("button", { type: "button", onClick: () => shiftView(-1), "aria-label": "Previous", ...previousHint, className: "w-7 h-7 text-sm rounded-sm hover:bg-surface-alt", children: "\u2039" }), _jsx("button", { type: "button", onClick: goToday, ...todayHint, className: "px-2 h-7 text-sm rounded-sm hover:bg-surface-alt", children: "Today" }), _jsx("button", { type: "button", onClick: () => shiftView(1), "aria-label": "Next", ...nextHint, className: "w-7 h-7 text-sm rounded-sm hover:bg-surface-alt", children: "\u203A" })] }), _jsx("h1", { className: "hidden md:block text-lg font-bold tracking-tight", children: title }), _jsx(CalendarSearchBox, { value: searchText, onChange: setSearchText, onSubmit: submitSearch, onClear: clearSearch, onPrevious: () => stepMatch(-1), onNext: () => stepMatch(1), active: searching, count: matches.length, position: activeIndex, inputRef: searchInputRef }), _jsx("div", { className: "flex flex-wrap gap-1", children: CALENDAR_VIEWS.map((v) => (_jsx(ViewButton, { view: v, current: view, onSelect: setView }, v))) })] }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), _jsx("div", { ref: swipe.ref, className: "flex-1 min-w-0 flex flex-col min-h-0 overflow-hidden", ...swipe.handlers, style: swipe.style, children: _jsx("div", { "data-swipe-phase": swipe.phase, className: "flex-1 min-w-0 flex flex-col min-h-0", style: swipe.contentStyle, children: _jsx("div", { className: "flex-1 min-w-0 flex flex-col min-h-0", style: wheelSlide.style, children: loading ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : (_jsx(ActiveOccurrenceContext.Provider, { value: searching ? activeKey : null, children: _jsx(DndContext, { sensors: sensors, onDragStart: () => setDragging(true), onDragEnd: handleDragEnd, onDragCancel: () => setDragging(false), children: view === "list" ? (_jsx(ListView, { occurrences: occurrences, folderColors: folderColors, onSelectEvent: openEvent, focusDate: viewDate, jumpNonce: jumpNonce, step: listStep })) : view === "month" ? (_jsx(MonthView, { viewDate: viewDate, occurrences: occurrences, folderColors: folderColors, onSelectDay: handleSelectDay, onSelectEvent: openEvent, onSelectSlot: (start, end, anchor) => openNewEvent(start, end, undefined, anchor, true) })) : view === "split" ? (_jsx(SplitDayView, { day: viewDate, columns: splitColumns, occurrences: occurrences, onSelectEvent: openEvent, onSelectSlot: (start, end, targetFolderUid, anchor) => openNewEvent(start, end, targetFolderUid, anchor) })) : (_jsx(TimeGridView, { days: days, occurrences: occurrences, folderColors: folderColors, onSelectEvent: openEvent, onSelectSlot: (start, end, anchor) => openNewEvent(start, end, undefined, anchor) })) }) })) }) }) }), modal && modalMailbox && (_jsx(EventModal, { open: true, onClose: closeModal, mailboxUid: modalMailbox.uid, folderUid: modalFolderUid, calendars: calendarFolders
|
|
568
|
+
.filter((f) => f.mailboxUid === modalMailbox.uid)
|
|
569
|
+
.map((f) => ({ uid: f.uid, name: f.name })), mailboxOptions: mailboxOptions, folderColors: folderColors, organizerAddress: modalMailbox.primarySmtpAddress, organizerAliases: modalMailbox.aliasAddresses, occurrence: modal.occurrence, initialStart: modal.initialStart, initialEnd: modal.initialEnd, initialAllDay: modal.initialAllDay, anchor: modal.anchor, bookingHref: bookingHref, matchNav: modal.occurrence && sequence.length > 0
|
|
544
570
|
? {
|
|
545
571
|
onPrevious: () => stepMatch(-1),
|
|
546
572
|
onNext: () => stepMatch(1),
|
|
@@ -549,7 +575,7 @@ function CalendarContent({ userUid, bookingHref }) {
|
|
|
549
575
|
noun: searching ? "match" : "event",
|
|
550
576
|
slide,
|
|
551
577
|
}
|
|
552
|
-
: undefined, onSaved: handleSaved, onDeleted: handleDeleted })), isMobile && mailboxUid && folderUid && !modal && _jsx(FloatingActionButton, { label: "New event", icon: HiOutlinePlus, onClick: () => openNewEvent() })] })] }));
|
|
578
|
+
: undefined, onSaved: handleSaved, onDeleted: handleDeleted })), isMobile && mailboxUid && folderUid && !modal && (_jsx(FloatingActionButton, { label: "New event", icon: HiOutlinePlus, onClick: () => openNewEvent() }))] })] }));
|
|
553
579
|
}
|
|
554
580
|
export default CalendarPage;
|
|
555
581
|
/** The tab's title: `Brand: Calendar` (see `pageTitle()`). */
|
|
@@ -7,7 +7,7 @@ import { pageTitle } from "../../shared/navigation/pageTitle.js";
|
|
|
7
7
|
import { useNavigate } from "../../shared/navigation/index.js";
|
|
8
8
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
9
9
|
import { ApiRequestError } from "../../../lib/util/api.js";
|
|
10
|
-
import { createContact, deleteContact, listContacts, ensureSuggestedContacts, listContactsInFolders, listDeletedContacts, setContactFavorite, updateContact, } from "../../../lib/contacts/contactsApi.js";
|
|
10
|
+
import { createContact, deleteContact, getContact, listContacts, ensureSuggestedContacts, listContactsInFolders, listDeletedContacts, setContactFavorite, updateContact, } from "../../../lib/contacts/contactsApi.js";
|
|
11
11
|
import { VCARD_MAX_CHARS, VCARD_MAX_CONTACTS, contactsToVCardFile, contactToVCard, parseVCards } from "../../../lib/contacts/vcard.js";
|
|
12
12
|
import { withoutDuplicates } from "../../shared/components/contacts/participantDetails.js";
|
|
13
13
|
import useIsMobile from "../../../lib/util/useIsMobile.js";
|
|
@@ -33,6 +33,15 @@ import { SHORTCUTS } from "../../shared/keyboard/keymap.js";
|
|
|
33
33
|
import { useShortcut } from "../../shared/keyboard/useShortcut.js";
|
|
34
34
|
import { notifyApiError } from "../../shared/notifications/apiErrors.js";
|
|
35
35
|
import { notify } from "../../shared/notifications/store.js";
|
|
36
|
+
import { changedItemOf, useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
|
|
37
|
+
/** The published model class name of a contact on either database (`ContactMongo`, `ContactSQL`) - not a `ContactList`'s. */
|
|
38
|
+
const CONTACT_MODEL = /^Contact(Mongo|SQL)$/;
|
|
39
|
+
/** The push client channel group (`PushClient.setChannels()`) the page's contacts folders are subscribed under. */
|
|
40
|
+
const CONTACTS_CHANNEL_GROUP = "contacts";
|
|
41
|
+
/** `list` with `contact` in place of the one with its uid, or added when it has none. */
|
|
42
|
+
function upsertContact(list, contact) {
|
|
43
|
+
return list.some((existing) => existing.uid === contact.uid) ? list.map((existing) => (existing.uid === contact.uid ? contact : existing)) : [...list, contact];
|
|
44
|
+
}
|
|
36
45
|
const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
37
46
|
function ContactsPage(props) {
|
|
38
47
|
return (_jsx(ContactsShell, { ...props, children: _jsx(ContactsContent, { userUid: props.userUid }) }));
|
|
@@ -137,6 +146,39 @@ function ContactsContent({ userUid }) {
|
|
|
137
146
|
cancelled = true;
|
|
138
147
|
};
|
|
139
148
|
}, [mailboxUid]);
|
|
149
|
+
// A contact created, changed or deleted anywhere - another tab, a phone over ActiveSync - is announced on its folder's channel. It is fetched again by
|
|
150
|
+
// its uid and filed in the list of the folder it is in now (the mailbox's contacts, or its suggested contacts), and taken out of the other; one that is
|
|
151
|
+
// gone (a delete, or a 404) leaves both. A failed fetch changes nothing: the next load shows it as it is.
|
|
152
|
+
useFolderLiveUpdates(CONTACTS_CHANNEL_GROUP, [folderUid, suggestedFolderUid].filter((uid) => !!uid), (event) => changedItemOf(event, CONTACT_MODEL), (change) => {
|
|
153
|
+
const remove = (uid) => {
|
|
154
|
+
setContacts((previous) => previous.filter((existing) => existing.uid !== uid));
|
|
155
|
+
setSuggestedContacts((previous) => previous.filter((existing) => existing.uid !== uid));
|
|
156
|
+
};
|
|
157
|
+
if (change.deleted) {
|
|
158
|
+
remove(change.uid);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
void getContact(change.uid, client).then((fresh) => {
|
|
162
|
+
if (fresh.deleted === true) {
|
|
163
|
+
remove(fresh.uid);
|
|
164
|
+
}
|
|
165
|
+
else if (fresh.folderUid === folderUid) {
|
|
166
|
+
setContacts((previous) => upsertContact(previous, fresh));
|
|
167
|
+
setSuggestedContacts((previous) => previous.filter((existing) => existing.uid !== fresh.uid));
|
|
168
|
+
}
|
|
169
|
+
else if (fresh.folderUid === suggestedFolderUid) {
|
|
170
|
+
setSuggestedContacts((previous) => upsertContact(previous, fresh));
|
|
171
|
+
setContacts((previous) => previous.filter((existing) => existing.uid !== fresh.uid));
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
remove(fresh.uid);
|
|
175
|
+
}
|
|
176
|
+
}, (err) => {
|
|
177
|
+
if (err instanceof ApiRequestError && err.status === 404) {
|
|
178
|
+
remove(change.uid);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|
|
140
182
|
// restapi only honors `?deleted=true` for a caller with both delete and update rights on the contacts *folder*
|
|
141
183
|
// (its ACL, which inherits the mailbox's but can carry its own records) - anyone else silently gets the *live*
|
|
142
184
|
// contacts back, which the Deleted view would then present as deleted. There is no client API for the caller's
|
|
@@ -194,6 +236,9 @@ function ContactsContent({ userUid }) {
|
|
|
194
236
|
return contacts;
|
|
195
237
|
case "favorites":
|
|
196
238
|
return contacts.filter((c) => c.favorite);
|
|
239
|
+
case "everyone":
|
|
240
|
+
// The mailbox's own contacts and its suggested ones in one list (a person is in only one of them: a suggestion that is accepted is moved).
|
|
241
|
+
return [...contacts, ...suggestedContacts];
|
|
197
242
|
case "suggested":
|
|
198
243
|
return suggestedContacts;
|
|
199
244
|
case "list":
|
|
@@ -377,7 +422,8 @@ function ContactsContent({ userUid }) {
|
|
|
377
422
|
});
|
|
378
423
|
// 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).
|
|
379
424
|
const handleAddToContacts = singleFlight(async () => {
|
|
380
|
-
|
|
425
|
+
// In the All contacts view the selection can hold contacts that already are the mailbox's own: only the suggested ones are added.
|
|
426
|
+
for (const contact of checkedContacts.filter((checked) => checked.folderUid === suggestedFolderUid)) {
|
|
381
427
|
try {
|
|
382
428
|
await createContact({
|
|
383
429
|
mailboxUid: contact.mailboxUid,
|
|
@@ -453,7 +499,7 @@ function ContactsContent({ userUid }) {
|
|
|
453
499
|
return (_jsxs("div", { className: "flex-1 min-w-0 min-h-0 flex flex-col lg:flex-row md:flex-none md:h-[calc(100dvh_-_var(--rr-header-h,4rem))] md:overflow-hidden", children: [_jsx(ContactsSidebar, { mailboxUid: mailboxUid, contacts: contacts, active: view, onSelect: handleSelectView, showDeleted: canViewDeleted, suggestedCount: suggestedFolderUid ? suggestedContacts.length : undefined }), _jsxs("div", { className: "flex-1 min-w-0 min-h-0 flex", children: [_jsxs("div", { className: [
|
|
454
500
|
"w-full md:w-[clamp(16rem,32vw,26rem)] min-w-0 md:shrink-0 md:border-r border-border md:flex flex-col min-h-0",
|
|
455
501
|
mode === "new" ? "hidden md:flex" : "flex",
|
|
456
|
-
].join(" "), children: [_jsx(ContactsToolbar, { selectedCount: checkedContacts.length, allSelectedFavorited: checkedContacts.length > 0 && checkedContacts.every((c) => c.favorite), onNewContact: handleNew, onEdit: handleToolbarEdit, onDelete: () => setConfirmingBulkDelete(true), onEmail: handleEmail, onToggleFavorite: handleToggleFavorite, onAddCategory: handleAddCategory, onExportVCard: handleExportVCard, onAddToContacts: view.type === "suggested" ? handleAddToContacts : undefined, onImportFile: handleImportFile, shortcuts: true, hideNew: isMobile }), _jsx("div", { className: "p-3 border-b border-border", children: _jsx("input", { ref: searchInputRef, type: "search", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Search contacts", "aria-label": "Search contacts", className: INPUT_CLASS }) }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), (isDeletedView ? deletedTruncated : truncated) && (_jsx("div", { className: "p-3", children: _jsxs(Alert, { children: ["This folder has more contacts than can be shown at once - only the first ", LIST_PAGE_SIZE * MAX_LIST_PAGES, " are listed."] }) })), deletedError && isDeletedView && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: deletedError }) })), (isDeletedView ? deletedLoading : loading) ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : sorted.length === 0 ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "No contacts found." })) : (_jsx("div", { className: "flex-1 min-h-0 overflow-auto", children: _jsxs("table", { className: "w-full table-fixed text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border text-left", children: [!isDeletedView && (_jsx("th", { className: "w-10 px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": "Select all contacts", checked: allChecked, onChange: toggleCheckAll }) })), _jsx("th", { className: "w-[58%] px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("name"), className: "block max-w-full truncate text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Name", sortColumn === "name" ? (sortDesc ? " ▾" : " ▴") : ""] }) }), _jsx("th", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("info"), className: "block max-w-full truncate text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Contact info", sortColumn === "info" ? (sortDesc ? " ▾" : " ▴") : ""] }) })] }) }), _jsx("tbody", { children: sorted.map((contact) => (_jsxs("tr", { className: ["border-b border-border", contact.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt"].join(" "), children: [!isDeletedView && (_jsx("td", { className: "px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": `Select ${contact.displayName}`, checked: checkedUids.has(contact.uid), onChange: () => toggleChecked(contact.uid) }) })), _jsx("td", { className: "px-3 py-2", children: _jsxs("button", { type: "button",
|
|
502
|
+
].join(" "), children: [_jsx(ContactsToolbar, { selectedCount: checkedContacts.length, allSelectedFavorited: checkedContacts.length > 0 && checkedContacts.every((c) => c.favorite), onNewContact: handleNew, onEdit: handleToolbarEdit, onDelete: () => setConfirmingBulkDelete(true), onEmail: handleEmail, onToggleFavorite: handleToggleFavorite, onAddCategory: handleAddCategory, onExportVCard: handleExportVCard, onAddToContacts: view.type === "suggested" || view.type === "everyone" ? handleAddToContacts : undefined, onImportFile: handleImportFile, shortcuts: true, hideNew: isMobile }), _jsx("div", { className: "p-3 border-b border-border", children: _jsx("input", { ref: searchInputRef, type: "search", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Search contacts", "aria-label": "Search contacts", className: INPUT_CLASS }) }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), (isDeletedView ? deletedTruncated : truncated) && (_jsx("div", { className: "p-3", children: _jsxs(Alert, { children: ["This folder has more contacts than can be shown at once - only the first ", LIST_PAGE_SIZE * MAX_LIST_PAGES, " are listed."] }) })), deletedError && isDeletedView && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: deletedError }) })), (isDeletedView ? deletedLoading : loading) ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : sorted.length === 0 ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "No contacts found." })) : (_jsx("div", { className: "flex-1 min-h-0 overflow-auto", children: _jsxs("table", { className: "w-full table-fixed text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border text-left", children: [!isDeletedView && (_jsx("th", { className: "w-10 px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": "Select all contacts", checked: allChecked, onChange: toggleCheckAll }) })), _jsx("th", { className: "w-[58%] px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("name"), className: "block max-w-full truncate text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Name", sortColumn === "name" ? (sortDesc ? " ▾" : " ▴") : ""] }) }), _jsx("th", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("info"), className: "block max-w-full truncate text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Contact info", sortColumn === "info" ? (sortDesc ? " ▾" : " ▴") : ""] }) })] }) }), _jsx("tbody", { children: sorted.map((contact) => (_jsxs("tr", { className: ["border-b border-border", contact.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt"].join(" "), children: [!isDeletedView && (_jsx("td", { className: "px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": `Select ${contact.displayName}`, checked: checkedUids.has(contact.uid), onChange: () => toggleChecked(contact.uid) }) })), _jsx("td", { className: "px-3 py-2", children: _jsxs("button", { type: "button",
|
|
457
503
|
// A deleted contact has no pane to show (it can't be restored or opened), and its route would answer 404.
|
|
458
504
|
disabled: isDeletedView, onClick: () => handleSelectRow(contact), className: "flex w-full min-w-0 items-center gap-2 text-left disabled:cursor-default", children: [_jsx(ContactPhotoAvatar, { lazy: true, displayName: contact.displayName, size: 28, contact: contact, email: contact.emails[0]?.address }), _jsxs("span", { className: "min-w-0 truncate font-medium", children: [contact.displayName, contact.favorite && _jsx("span", { "aria-label": "Favorite", children: " \u2605" })] })] }) }), _jsx("td", { className: "max-w-0 truncate px-3 py-2 text-text-muted", children: primaryInfo(contact) })] }, contact.uid))) })] }) }))] }), _jsxs("div", { className: ["flex-1 min-w-0 min-h-0 flex-col md:flex", mode === "new" ? "flex" : "hidden"].join(" "), children: [savedElsewhere && mode !== "new" && (_jsxs("p", { role: "status", className: "m-6 mb-0 text-sm py-2 px-3 rounded-sm bg-surface-alt text-text", children: [savedElsewhere.displayName, " was added to", " ", mailboxes.find((mb) => mb.uid === savedElsewhere.mailboxUid)?.displayName ?? "another mailbox", ".", " ", _jsx("a", { href: `/contacts?mailboxUid=${encodeURIComponent(savedElsewhere.mailboxUid)}`, "data-router-shallow": "", "data-router-prefetch": "false", className: "font-medium text-primary-dark hover:underline", children: "View that mailbox\u2019s contacts" })] })), mode === "new" && mailboxUid && folderUid ? (_jsx(ContactForm, { mailboxUid: mailboxUid, folderUid: folderUid, mailboxes: writableMailboxes, onSaved: handleSaved, onCancel: handleCancel })) : mode === "edit" && selected ? (_jsx(ContactForm, { contact: selected, onSaved: handleSaved, onCancel: handleCancel })) : selected ? (_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-6", children: _jsx(ContactDetailPane, { contact: selected, onEdit: () => setMode("edit"), onDelete: () => handleDelete(selected), canWrite: mailboxWritable, onKeysChanged: () => void reload(), onChanged: () => void reload(), canResolveKeys: ownsMailbox || delegateCanUpdate }) })) : (_jsx("div", { className: "flex flex-1 items-center justify-center p-6 text-center", children: _jsx("p", { className: "text-sm text-text-muted", children: "Select a contact, or create a new one." }) }))] })] }), _jsxs(Modal, { open: confirmingBulkDelete, onClose: () => setConfirmingBulkDelete(false), title: "Delete contacts", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Delete ", checkedContacts.length, " selected ", checkedContacts.length === 1 ? "contact" : "contacts", "? They move to Deleted contacts."] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingBulkDelete(false), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: handleBulkDelete, children: "Delete" })] })] }), isMobile && mode !== "new" && _jsx(FloatingActionButton, { label: "New contact", icon: HiOutlineUserPlus, onClick: handleNew })] }));
|
|
459
505
|
}
|
package/dist/apps/www/index.js
CHANGED
|
@@ -100,11 +100,18 @@ async function decryptEncryptedRows(messages, unlocked) {
|
|
|
100
100
|
// A few at a time: each is a download and a public-key decryption.
|
|
101
101
|
const entries = await mapWithConcurrency(encrypted, DECRYPT_CONCURRENCY, async (message) => {
|
|
102
102
|
try {
|
|
103
|
-
const rawMime = await getMessageRawContent(message.uid);
|
|
104
103
|
// Loaded here, on first use: the S/MIME code (PKI.js, ASN.1, X.509) is over half a megabyte and an inbox with
|
|
105
104
|
// no encrypted mail in it never needs it.
|
|
106
|
-
const
|
|
107
|
-
|
|
105
|
+
const securityApi = await (securityModule ?? (securityModule = import("../../lib/crypto/messageSecurity.js")));
|
|
106
|
+
// A message sealed at rest carries its subject and preview sealed on the row: no download needed.
|
|
107
|
+
const summary = message.zeroAccess ? await securityApi.openZeroAccessSummary(message.zeroAccess.sealedSummary, unlocked.zeroAccessKeys) : undefined;
|
|
108
|
+
if (summary) {
|
|
109
|
+
const preview = summary.preview.slice(0, LIST_PREVIEW_MAX_LENGTH);
|
|
110
|
+
rememberDecrypted(message.uid, message.mailboxUid, { subject: summary.subject, preview });
|
|
111
|
+
return [message.uid, { subject: summary.subject, preview }];
|
|
112
|
+
}
|
|
113
|
+
const rawMime = await getMessageRawContent(message.uid);
|
|
114
|
+
const security = await securityApi.evaluateMessageSecurity(rawMime, unlocked);
|
|
108
115
|
if (!security.subject && !security.html) {
|
|
109
116
|
markDecryptFailed(message.uid);
|
|
110
117
|
return null;
|
|
@@ -25,6 +25,8 @@ import { notifyApiError } from "../../../shared/notifications/apiErrors.js";
|
|
|
25
25
|
import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
26
26
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
27
27
|
import Modal from "../../../../lib/components/overlays/Modal.js";
|
|
28
|
+
import ZeroAccessSetting from "../../../shared/components/settings/ZeroAccessSetting.js";
|
|
29
|
+
import KeyExportSetting from "../../../shared/components/settings/KeyExportSetting.js";
|
|
28
30
|
import SigningCertificateCard from "../../../shared/components/settings/SigningCertificateCard.js";
|
|
29
31
|
import { readStoredSignEnrollment, storeSignEnrollment } from "../../../shared/signing/enrollmentStorage.js";
|
|
30
32
|
import { checkEnrollmentNow, forgetEnrollment, recordEnrollmentResult, useEnrollmentSnapshot, watchCurrentEnrollment, watchEnrollment, } from "../../../shared/signing/enrollmentTracker.js";
|
|
@@ -799,7 +801,7 @@ function EncryptionContent({ canManageKeys }) {
|
|
|
799
801
|
: null;
|
|
800
802
|
const certificateEnrollment = certificateMode === "issued" && enrollmentResult?.status !== "issued" ? null : certificateMode === "expired" ? null : enrollmentResult;
|
|
801
803
|
return (_jsx("div", { className: "flex-1 min-w-0 overflow-y-auto p-6", children: _jsxs("div", { className: "max-w-xl flex flex-col gap-6", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-lg font-bold tracking-tight mb-1", children: "Encryption" }), _jsxs("p", { className: "text-sm text-text-muted", children: ["Manage how ", mailbox.displayName, " unlocks its encryption keys on this and other devices."] })] }), loadError && _jsx(Alert, { children: loadError }), actionError && _jsx(Alert, { children: actionError }), !canManageKeys && (_jsx("p", { className: "text-sm text-text-muted", children: "Only this mailbox\u2019s owner, signed in as themselves, can change its encryption keys and unlock methods." })), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Encryption keys" }), displayedKeys.length > 0 ? (_jsx("ul", { className: "flex flex-col gap-1 text-sm", children: displayedKeys.map((key) => (_jsxs("li", { className: "font-mono text-xs", children: [key.useType === "sign" ? "Signing" : "Encryption", " key: ", key.fingerprint, key.revokedAt &&
|
|
802
|
-
(key.revocationReason === "superseded" ? (_jsx("span", { className: "text-text-muted", children: " (superseded)" })) : (_jsx("span", { className: "text-danger", children: " (revoked)" })))] }, key.fingerprint))) })) : (_jsx("p", { className: "text-sm text-text-muted", children: "No keys enrolled yet." }))] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Digital signatures" }), activeSigningKey && signingStatus !== "pending" && (_jsx("p", { className: ["text-sm text-text-muted", certificateMode ? "mb-3" : ""].join(" "), children: "Enabled \u2014 outgoing mail from this mailbox is signed with a publicly-trusted certificate." })), certificateMode && (_jsxs("div", { className: "flex flex-col gap-2", children: [certificateMode === "failed" && signingError && _jsx(Alert, { children: signingError }), _jsx(SigningCertificateCard, { mode: certificateMode, enrollment: certificateEnrollment, snapshot: enrollment, info: signingInfo, address: mailbox.primarySmtpAddress, keyNotAfter: certificateMode === "expired" ? expiredSigningKey?.notAfter : activeSigningKey?.notAfter, canRequest: canManageKeys, requesting: signingStatus === "enrolling", onCheck: canManageKeys ? () => void checkEnrollmentNow(mailboxUid) : undefined, onRequest: handleEnrollSigning })] })), !activeSigningKey && !certificateMode && !canManageKeys ? (_jsx("p", { className: "text-sm text-text-muted", children: "Not enabled." })) : activeSigningKey || certificateMode ? null : (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("p", { className: "text-xs text-text-muted", children: "Lets recipients verify that mail from this mailbox is genuinely from you. Optional \u2014 encryption already works without it." }), beforeRequestText(signingInfo, mailbox.primarySmtpAddress) && (_jsx("p", { className: "text-xs text-text-muted", children: beforeRequestText(signingInfo, mailbox.primarySmtpAddress) })), healthWarning(signingInfo) && _jsx(Alert, { children: healthWarning(signingInfo) }), enrollment?.gone && _jsx(Alert, { children: STALE_REQUEST_TEXT }), signingError && _jsx(Alert, { children: signingError }), signingInfo?.backend !== "none" && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: signingStatus === "enrolling", disabled: signingStatus !== "idle", onClick: handleEnrollSigning, children: "Enable digital signatures" }))] }))] }), mailbox.escrowScopeId && (_jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Escrow" }), escrowError && _jsx(Alert, { children: escrowError }), hasEscrowWrap ? (_jsx("p", { className: "text-sm text-text-muted", children: "This mailbox is under legal/compliance escrow \u2014 an authorized holder in your organization can recover its encrypted mail if needed. This does not weaken protection against anyone else." })) : (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("p", { className: "text-xs text-text-muted", children: ["Your organization has assigned this mailbox to an escrow scope, but nothing has been protected yet \u2014 an authorized holder cannot recover this mailbox\u2019s encrypted mail until you complete this step.", " ", "This does not weaken protection against anyone else."] }), canManageKeys && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: wrappingEscrow, disabled: wrappingEscrow, onClick: handleWrapEscrow, children: "Add escrow protection" }))] })), _jsxs(Modal, { open: pendingEscrow !== null, onClose: () => setPendingEscrow(null), title: "Confirm escrow certificate", children: [_jsx("p", { className: "text-sm mb-3", children: "Anyone holding the private key for this certificate will be able to recover this mailbox\u2019s encrypted mail. Confirm it is the escrow certificate your organization expects before continuing." }), _jsxs("dl", { className: "text-sm mb-5 flex flex-col gap-1", children: [_jsxs("div", { children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Escrow scope" }), _jsx("dd", { className: "break-all", children: pendingEscrow?.escrowScopeId })] }), _jsxs("div", { children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Certificate fingerprint" }), _jsx("dd", { className: "font-mono break-all", children: pendingEscrow?.publicKey.fingerprint })] })] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setPendingEscrow(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => void handleConfirmEscrow(pendingEscrow), children: "Trust this certificate" })] })] })] })), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Unlock methods" }), _jsxs("p", { className: "text-xs text-text-muted mb-3", children: ["Removing a method here stops it from being usable to unlock this mailbox going forward, but it is ", _jsx("strong", { children: "not" }), " full revocation \u2014 anyone who already captured a wrapped copy and knows its secret could still use it. For real revocation (e.g. after a lost device), rotate your keys entirely below instead."] }), vault && vault.masterKeyWraps.length > 0 ? (_jsx("ul", { className: "flex flex-col gap-2", children: [...passwordWraps, ...recoveryWraps, ...otherWraps].map((wrap) => {
|
|
804
|
+
(key.revocationReason === "superseded" ? (_jsx("span", { className: "text-text-muted", children: " (superseded)" })) : (_jsx("span", { className: "text-danger", children: " (revoked)" })))] }, key.fingerprint))) })) : (_jsx("p", { className: "text-sm text-text-muted", children: "No keys enrolled yet." }))] }), _jsx(ZeroAccessSetting, { mailbox: mailbox, canManage: canManageKeys }), _jsx(KeyExportSetting, { mailbox: mailbox, canManage: canManageKeys }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Digital signatures" }), activeSigningKey && signingStatus !== "pending" && (_jsx("p", { className: ["text-sm text-text-muted", certificateMode ? "mb-3" : ""].join(" "), children: "Enabled \u2014 outgoing mail from this mailbox is signed with a publicly-trusted certificate." })), certificateMode && (_jsxs("div", { className: "flex flex-col gap-2", children: [certificateMode === "failed" && signingError && _jsx(Alert, { children: signingError }), _jsx(SigningCertificateCard, { mode: certificateMode, enrollment: certificateEnrollment, snapshot: enrollment, info: signingInfo, address: mailbox.primarySmtpAddress, keyNotAfter: certificateMode === "expired" ? expiredSigningKey?.notAfter : activeSigningKey?.notAfter, canRequest: canManageKeys, requesting: signingStatus === "enrolling", onCheck: canManageKeys ? () => void checkEnrollmentNow(mailboxUid) : undefined, onRequest: handleEnrollSigning })] })), !activeSigningKey && !certificateMode && !canManageKeys ? (_jsx("p", { className: "text-sm text-text-muted", children: "Not enabled." })) : activeSigningKey || certificateMode ? null : (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("p", { className: "text-xs text-text-muted", children: "Lets recipients verify that mail from this mailbox is genuinely from you. Optional \u2014 encryption already works without it." }), beforeRequestText(signingInfo, mailbox.primarySmtpAddress) && (_jsx("p", { className: "text-xs text-text-muted", children: beforeRequestText(signingInfo, mailbox.primarySmtpAddress) })), healthWarning(signingInfo) && _jsx(Alert, { children: healthWarning(signingInfo) }), enrollment?.gone && _jsx(Alert, { children: STALE_REQUEST_TEXT }), signingError && _jsx(Alert, { children: signingError }), signingInfo?.backend !== "none" && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: signingStatus === "enrolling", disabled: signingStatus !== "idle", onClick: handleEnrollSigning, children: "Enable digital signatures" }))] }))] }), mailbox.escrowScopeId && (_jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Escrow" }), escrowError && _jsx(Alert, { children: escrowError }), hasEscrowWrap ? (_jsx("p", { className: "text-sm text-text-muted", children: "This mailbox is under legal/compliance escrow \u2014 an authorized holder in your organization can recover its encrypted mail if needed. This does not weaken protection against anyone else." })) : (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("p", { className: "text-xs text-text-muted", children: ["Your organization has assigned this mailbox to an escrow scope, but nothing has been protected yet \u2014 an authorized holder cannot recover this mailbox\u2019s encrypted mail until you complete this step.", " ", "This does not weaken protection against anyone else."] }), canManageKeys && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: wrappingEscrow, disabled: wrappingEscrow, onClick: handleWrapEscrow, children: "Add escrow protection" }))] })), _jsxs(Modal, { open: pendingEscrow !== null, onClose: () => setPendingEscrow(null), title: "Confirm escrow certificate", children: [_jsx("p", { className: "text-sm mb-3", children: "Anyone holding the private key for this certificate will be able to recover this mailbox\u2019s encrypted mail. Confirm it is the escrow certificate your organization expects before continuing." }), _jsxs("dl", { className: "text-sm mb-5 flex flex-col gap-1", children: [_jsxs("div", { children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Escrow scope" }), _jsx("dd", { className: "break-all", children: pendingEscrow?.escrowScopeId })] }), _jsxs("div", { children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Certificate fingerprint" }), _jsx("dd", { className: "font-mono break-all", children: pendingEscrow?.publicKey.fingerprint })] })] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setPendingEscrow(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => void handleConfirmEscrow(pendingEscrow), children: "Trust this certificate" })] })] })] })), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Unlock methods" }), _jsxs("p", { className: "text-xs text-text-muted mb-3", children: ["Removing a method here stops it from being usable to unlock this mailbox going forward, but it is ", _jsx("strong", { children: "not" }), " full revocation \u2014 anyone who already captured a wrapped copy and knows its secret could still use it. For real revocation (e.g. after a lost device), rotate your keys entirely below instead."] }), vault && vault.masterKeyWraps.length > 0 ? (_jsx("ul", { className: "flex flex-col gap-2", children: [...passwordWraps, ...recoveryWraps, ...otherWraps].map((wrap) => {
|
|
803
805
|
const key = `${wrap.method}:${wrap.methodId ?? ""}`;
|
|
804
806
|
return (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsx("span", { children: METHOD_LABELS[wrap.method] ?? wrap.method }), wrap.method !== "escrow" &&
|
|
805
807
|
canManageKeys &&
|
|
@@ -108,7 +108,7 @@ function ExportSection({ mailboxUid, mailboxLabel }) {
|
|
|
108
108
|
setCreating(false);
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
|
-
return (_jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Export my data" }), _jsxs("p", { className: "text-xs text-text-muted mb-3", children: ["Requests a copy of ", _jsx("strong", { children: mailboxLabel }), "\u2019s messages, contacts, calendar, tasks, and notes. This runs in the background and can take a few minutes \u2014 check back here for a download link once it\u2019s ready."] }), _jsxs("form", { onSubmit: handleCreate, className: "flex items-end gap-3 mb-5", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Format" }), _jsxs("select", { "aria-label": "Export format", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: format, onChange: (e) => setFormat(e.target.value), children: [_jsx("option", { value: "json", children: "JSON" }), _jsx("option", { value: "mbox", children: "Mbox" })] })] }), _jsx(Button, { type: "submit", variant: "secondary", className: "!w-auto", loading: creating, disabled: creating, children: "Request export" })] }), loadError && _jsx(Alert, { children: loadError }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : requests.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No export requests yet." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: requests.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: [request.format.toUpperCase(), " \u00B7 ", 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) }), request.status === "ready" && (_jsx("a", { href: exportRequestDownloadUrl(request.uid), className: "text-primary-dark hover:underline font-medium", children: "Download" }))] })] }, request.uid))) }))] }));
|
|
111
|
+
return (_jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Export my data" }), _jsxs("p", { className: "text-xs text-text-muted mb-3", children: ["Requests a copy of ", _jsx("strong", { children: mailboxLabel }), "\u2019s messages, contacts, calendar, tasks, and notes. This runs in the background and can take a few minutes \u2014 check back here for a download link once it\u2019s ready. Mail stored with zero-access encryption is exported encrypted: open it in a mail app that has your exported keys (Settings \u203A Encryption)."] }), _jsxs("form", { onSubmit: handleCreate, className: "flex items-end gap-3 mb-5", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Format" }), _jsxs("select", { "aria-label": "Export format", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: format, onChange: (e) => setFormat(e.target.value), children: [_jsx("option", { value: "json", children: "JSON" }), _jsx("option", { value: "mbox", children: "Mbox" })] })] }), _jsx(Button, { type: "submit", variant: "secondary", className: "!w-auto", loading: creating, disabled: creating, children: "Request export" })] }), loadError && _jsx(Alert, { children: loadError }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : requests.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No export requests yet." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: requests.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: [request.format.toUpperCase(), " \u00B7 ", 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) }), request.status === "ready" && (_jsx("a", { href: exportRequestDownloadUrl(request.uid), className: "text-primary-dark hover:underline font-medium", children: "Download" }))] })] }, request.uid))) }))] }));
|
|
112
112
|
}
|
|
113
113
|
function ImportSection({ mailboxUid }) {
|
|
114
114
|
const client = useApiClient();
|
|
@@ -7,7 +7,7 @@ import { pageTitle } from "../../shared/navigation/pageTitle.js";
|
|
|
7
7
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
8
8
|
import { endOfWeek, isAfter, isBefore, isToday, startOfDay } from "date-fns";
|
|
9
9
|
import { ApiRequestError } from "../../../lib/util/api.js";
|
|
10
|
-
import { createTask, deleteTask, listTasks, setTaskCompleted, setTaskMyDay, } from "../../../lib/tasks/tasksApi.js";
|
|
10
|
+
import { createTask, deleteTask, getTask, listTasks, setTaskCompleted, setTaskMyDay, } from "../../../lib/tasks/tasksApi.js";
|
|
11
11
|
import { listFlaggedMessages } from "../../../lib/mail/flaggedMessages.js";
|
|
12
12
|
import { useApiClient } from "../../../lib/util/apiClientContext.js";
|
|
13
13
|
import { useDayKey } from "../../../lib/calendar/useDayKey.js";
|
|
@@ -25,6 +25,11 @@ import { SHORTCUTS } from "../../shared/keyboard/keymap.js";
|
|
|
25
25
|
import { useShortcut } from "../../shared/keyboard/useShortcut.js";
|
|
26
26
|
import { useShortcutProps } from "../../shared/keyboard/useShortcutProps.js";
|
|
27
27
|
import { notifyApiError } from "../../shared/notifications/apiErrors.js";
|
|
28
|
+
import { changedItemOf, useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
|
|
29
|
+
/** The published model class name of a task on either database (`TaskMongo`, `TaskSQL`) - not a `TaskList`'s. */
|
|
30
|
+
const TASK_MODEL = /^Task(Mongo|SQL)$/;
|
|
31
|
+
/** The push client channel group (`PushClient.setChannels()`) the page's tasks folder is subscribed under. */
|
|
32
|
+
const TASKS_CHANNEL_GROUP = "tasks";
|
|
28
33
|
const INPUT_CLASS = "text-sm py-1.5 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
29
34
|
function TasksPage(props) {
|
|
30
35
|
return (_jsx(TasksShell, { ...props, children: _jsx(TasksContent, {}) }));
|
|
@@ -121,6 +126,29 @@ function TasksContent() {
|
|
|
121
126
|
useEffect(() => {
|
|
122
127
|
void reload();
|
|
123
128
|
}, [folderUid]);
|
|
129
|
+
// 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
|
|
130
|
+
// 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
|
|
131
|
+
// failed fetch changes nothing: the next load shows it as it is.
|
|
132
|
+
useFolderLiveUpdates(TASKS_CHANNEL_GROUP, folderUid ? [folderUid] : [], (event) => changedItemOf(event, TASK_MODEL), (change) => {
|
|
133
|
+
const remove = () => setTasks((previous) => previous.filter((existing) => existing.uid !== change.uid));
|
|
134
|
+
if (change.deleted) {
|
|
135
|
+
remove();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
void getTask(change.uid, client).then((fresh) => {
|
|
139
|
+
if (fresh.folderUid !== folderUid) {
|
|
140
|
+
remove();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
setTasks((previous) => previous.some((existing) => existing.uid === fresh.uid)
|
|
144
|
+
? previous.map((existing) => (existing.uid === fresh.uid ? fresh : existing))
|
|
145
|
+
: [...previous, fresh]);
|
|
146
|
+
}, (err) => {
|
|
147
|
+
if (err instanceof ApiRequestError && err.status === 404) {
|
|
148
|
+
remove();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
});
|
|
124
152
|
useEffect(() => {
|
|
125
153
|
setTargetMailboxUid(undefined);
|
|
126
154
|
setAddedElsewhere(null);
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { ApiClient } from "../util/api.js";
|
|
11
11
|
import { ListParams, RequestListParams } from "../util/apiQuery.js";
|
|
12
|
+
import type { PublicKey, WrappedPrivateKey } from "../crypto/keyvaultApi.js";
|
|
12
13
|
export type { ListParams, RequestListParams };
|
|
13
14
|
export interface EscrowAccessRequestApproval {
|
|
14
15
|
holderUserUid: string;
|
|
@@ -54,6 +55,11 @@ export interface MasterKeyWrap {
|
|
|
54
55
|
* tooling, per `specs/end-to-end_encryption.md`'s "this server never holds the scope's private key". */
|
|
55
56
|
export interface EscrowAccessMaterial {
|
|
56
57
|
masterKeyWraps: MasterKeyWrap[];
|
|
58
|
+
/** The mailbox's wrapped private keys that read mail (zero-access and encryption keys, never signing), sealed under its master key. Absent
|
|
59
|
+
* from a server that predates releasing them. */
|
|
60
|
+
wrappedKeys?: WrappedPrivateKey[];
|
|
61
|
+
/** The certificate of each of `wrappedKeys`, matched by fingerprint. */
|
|
62
|
+
certificates?: PublicKey[];
|
|
57
63
|
}
|
|
58
64
|
/** `client`, given by every function below, is an explicit `ApiClient` from `createApiClient()` (e.g. one
|
|
59
65
|
* account of a multi-account app) to call instead of the default global `apiFetch()` - see `withClient()`'s
|
|
@@ -112,6 +112,12 @@ export interface CalendarEvent {
|
|
|
112
112
|
* `mailto` only). Still to be rendered through `sanitizeEventDescriptionHtml()` (`eventDescription.ts`), never trusted as it stands. `null` or absent:
|
|
113
113
|
* the description, if any, is plain text. */
|
|
114
114
|
descriptionHtml?: string | null;
|
|
115
|
+
/** Response-only. An event made from an invitation that reached a zero-access mailbox: its title, location and description are only
|
|
116
|
+
* sealed to the mailbox's zero-access key (`title` is `[...]`). `apps/shared/calendar/sealedEvents.ts` opens them. */
|
|
117
|
+
sealedContent?: {
|
|
118
|
+
keyFingerprint: string;
|
|
119
|
+
sealed: string;
|
|
120
|
+
};
|
|
115
121
|
/** Who may see the event's details - absent (or `null`, on a row stored before this existed) reads as `"default"`; see `visibilityOf()`. */
|
|
116
122
|
visibility?: EventVisibility | null;
|
|
117
123
|
/** Whether the guests may ask for the event to change (default `false`). Absent or `null` reads as the default; see `guestPermissionsOf()`. */
|
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
* iTIP REPLY to the organizer. The client only reads the state and asks for a change; every call returns the invitation as
|
|
9
9
|
* it now stands, ready to draw. */
|
|
10
10
|
import { ApiRequestError, withClient } from "../util/api.js";
|
|
11
|
+
import { sealedInviteCalendar } from "./sealedInvites.js";
|
|
12
|
+
/** The calendar file to send for `messageUid` - a zero-access message this device decrypted (`sealedInvites.ts`) - as the request body's
|
|
13
|
+
* `ics`, or nothing for a message the server reads itself. */
|
|
14
|
+
function sealedSource(messageUid) {
|
|
15
|
+
const ics = sealedInviteCalendar(messageUid);
|
|
16
|
+
return ics !== undefined ? { ics } : {};
|
|
17
|
+
}
|
|
11
18
|
/** `client`, given by every function below, is an explicit `ApiClient` from `createApiClient()` (e.g. one
|
|
12
19
|
* account of a multi-account app) to call instead of the default global `apiFetch()` - see `withClient()`'s
|
|
13
20
|
* own doc comment in `util/api.ts`. Omitted (the default), every function here behaves exactly as before. */
|
|
@@ -16,8 +23,15 @@ import { ApiRequestError, withClient } from "../util/api.js";
|
|
|
16
23
|
* common, expected answer for a message that merely has an attachment - and rejects for any other failure.
|
|
17
24
|
*/
|
|
18
25
|
export async function getMessageInvite(messageUid, client) {
|
|
26
|
+
const source = sealedSource(messageUid);
|
|
19
27
|
try {
|
|
20
|
-
|
|
28
|
+
// A zero-access message's invitation is read from the calendar file decrypted here (`POST .../view`).
|
|
29
|
+
return source.ics !== undefined
|
|
30
|
+
? await withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/view`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
body: JSON.stringify(source),
|
|
33
|
+
})
|
|
34
|
+
: await withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}`);
|
|
21
35
|
}
|
|
22
36
|
catch (err) {
|
|
23
37
|
if (err instanceof ApiRequestError && err.status === 404) {
|
|
@@ -34,21 +48,29 @@ export async function getMessageInvite(messageUid, client) {
|
|
|
34
48
|
export function respondToMessageInvite(messageUid, responseStatus, client) {
|
|
35
49
|
return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/respond`, {
|
|
36
50
|
method: "POST",
|
|
37
|
-
body: JSON.stringify({ responseStatus }),
|
|
51
|
+
body: JSON.stringify({ responseStatus, ...sealedSource(messageUid) }),
|
|
38
52
|
});
|
|
39
53
|
}
|
|
40
54
|
/** Takes a canceled meeting off the reader's calendar. Resolves with the invitation as it now stands. */
|
|
41
55
|
export function removeMessageInvite(messageUid, client) {
|
|
42
|
-
|
|
56
|
+
const source = sealedSource(messageUid);
|
|
57
|
+
return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/remove`, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
...(source.ics !== undefined ? { body: JSON.stringify(source) } : {}),
|
|
60
|
+
});
|
|
43
61
|
}
|
|
44
62
|
/** Proposes a different time to the organizer of a `REQUEST` (an iTIP `COUNTER` sent by the server). Resolves with the invitation as it now stands. */
|
|
45
63
|
export function proposeNewTime(messageUid, proposal, client) {
|
|
46
64
|
return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/propose`, {
|
|
47
65
|
method: "POST",
|
|
48
|
-
body: JSON.stringify(proposal),
|
|
66
|
+
body: JSON.stringify({ ...proposal, ...sealedSource(messageUid) }),
|
|
49
67
|
});
|
|
50
68
|
}
|
|
51
69
|
/** Accepts an attendee's proposed time (a `COUNTER`) for a meeting the reader organizes: the server moves the event and sends the attendees the update. Resolves with the invitation as it now stands. */
|
|
52
70
|
export function acceptProposal(messageUid, client) {
|
|
53
|
-
|
|
71
|
+
const source = sealedSource(messageUid);
|
|
72
|
+
return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/accept-proposal`, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
...(source.ics !== undefined ? { body: JSON.stringify(source) } : {}),
|
|
75
|
+
});
|
|
54
76
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The calendar files of zero-access messages, as decrypted on this device (`MessageSecurityResult.calendar`). The server holds only
|
|
3
|
+
* the sealed copy of such a message, so every invitation call for it (`inviteApi.ts`) sends the file along. Kept in memory for the
|
|
4
|
+
* messages read most recently, and cleared on sign-out (`clearSealedInvites()`).
|
|
5
|
+
*/
|
|
6
|
+
/** Remembers the decrypted calendar file of zero-access message `messageUid`. */
|
|
7
|
+
export declare function rememberSealedInvite(messageUid: string, ics: string): void;
|
|
8
|
+
/** The decrypted calendar file of zero-access message `messageUid`, when this device has read it. */
|
|
9
|
+
export declare function sealedInviteCalendar(messageUid: string): string | undefined;
|
|
10
|
+
/** Forgets every remembered calendar file (sign-out). */
|
|
11
|
+
export declare function clearSealedInvites(): void;
|