@rapidmx/web-client 0.36.0 → 0.37.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/shared/calendar/calendarReminders.ts +35 -0
- package/apps/shared/calendar/sealedEvents.ts +42 -9
- package/apps/shared/calendar/useCalendarReminders.ts +28 -9
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +29 -1
- package/apps/shared/components/calendar/EventDetails.tsx +13 -2
- package/apps/shared/components/calendar/EventEditor.tsx +124 -17
- package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
- package/apps/shared/components/calendar/EventModal.tsx +19 -2
- package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
- package/apps/shared/components/calendar/eventForm.ts +2 -0
- package/apps/shared/components/contacts/ContactCard.tsx +13 -1
- package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
- package/apps/shared/components/contacts/ContactForm.tsx +56 -7
- package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
- package/apps/shared/components/contacts/contactCardData.ts +6 -3
- package/apps/shared/components/contacts/contactKeys.ts +3 -3
- package/apps/shared/components/layout/AppShell.tsx +34 -5
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +11 -4
- package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +37 -6
- package/apps/shared/components/mail/InviteCard.tsx +11 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +25 -14
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
- package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
- package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
- package/apps/shared/components/mail/invite/inviteStore.ts +11 -1
- package/apps/shared/components/mail/pinnedSigners.ts +14 -0
- package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
- package/apps/shared/components/settings/KeyExportSetting.tsx +4 -1
- package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
- package/apps/shared/components/settings/ZeroAccessSetting.tsx +13 -2
- package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
- package/apps/shared/mail/outbox/sendJob.ts +349 -50
- package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
- package/apps/shared/search/LocalIndexLifecycle.tsx +62 -33
- package/apps/shared/search/localIndexBuilder.ts +35 -24
- package/apps/shared/search/localIndexRpcClient.ts +91 -11
- package/apps/shared/search/localIndexSchema.ts +18 -2
- package/apps/shared/search/localIndexTransport.ts +6 -3
- package/apps/shared/search/localIndexWorker.ts +35 -18
- package/apps/shared/search/searchTier2.ts +5 -0
- package/apps/shared/search/zeroAccessIndexSync.ts +232 -40
- package/apps/shared/search/zeroAccessSyncState.ts +70 -0
- package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
- package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
- package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
- package/apps/www/_shell.tsx +2 -0
- package/apps/www/calendar/index.tsx +49 -5
- package/apps/www/contacts/index.tsx +67 -11
- package/apps/www/index.tsx +10 -1
- package/apps/www/settings/encryption/index.tsx +16 -1
- package/apps/www/settings/privacy/index.tsx +87 -38
- package/apps/www/tasks/index.tsx +24 -6
- package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
- package/dist/apps/shared/calendar/calendarReminders.js +26 -0
- package/dist/apps/shared/calendar/sealedEvents.d.ts +3 -2
- package/dist/apps/shared/calendar/sealedEvents.js +40 -9
- package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
- package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +9 -2
- package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
- package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
- package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
- package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
- package/dist/apps/shared/components/calendar/EventModal.js +15 -2
- package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
- package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
- package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
- package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
- package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
- package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
- package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
- package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
- package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
- package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
- package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
- package/dist/apps/shared/components/layout/AppShell.js +17 -6
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -4
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +3 -1
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +30 -4
- package/dist/apps/shared/components/mail/InviteCard.js +11 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +15 -11
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
- package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.js +11 -1
- package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
- package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
- package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
- package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
- package/dist/apps/shared/components/settings/KeyExportSetting.js +3 -1
- package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
- package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +2 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.js +12 -3
- package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
- package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
- package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
- package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
- package/dist/apps/shared/search/LocalIndexLifecycle.js +55 -33
- package/dist/apps/shared/search/localIndexBuilder.d.ts +4 -1
- package/dist/apps/shared/search/localIndexBuilder.js +31 -20
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
- package/dist/apps/shared/search/localIndexRpcClient.js +126 -10
- package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
- package/dist/apps/shared/search/localIndexSchema.js +16 -2
- package/dist/apps/shared/search/localIndexTransport.d.ts +6 -3
- package/dist/apps/shared/search/localIndexWorker.d.ts +9 -0
- package/dist/apps/shared/search/localIndexWorker.js +22 -15
- package/dist/apps/shared/search/searchTier2.js +5 -0
- package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +37 -6
- package/dist/apps/shared/search/zeroAccessIndexSync.js +199 -37
- package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
- package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
- package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
- package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
- package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
- package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
- package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
- package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
- package/dist/apps/www/_shell.d.ts +1 -1
- package/dist/apps/www/_shell.js +2 -2
- package/dist/apps/www/calendar/index.js +47 -5
- package/dist/apps/www/contacts/index.js +64 -11
- package/dist/apps/www/index.js +10 -1
- package/dist/apps/www/settings/encryption/index.js +14 -4
- package/dist/apps/www/settings/privacy/index.js +46 -5
- package/dist/apps/www/tasks/index.js +21 -7
- package/dist/lib/admin/domainsApi.d.ts +1 -1
- package/dist/lib/calendar/calendarApi.d.ts +46 -6
- package/dist/lib/calendar/calendarApi.js +110 -17
- package/dist/lib/calendar/calendarMutations.d.ts +20 -7
- package/dist/lib/calendar/calendarMutations.js +73 -14
- package/dist/lib/calendar/inviteApi.d.ts +17 -3
- package/dist/lib/calendar/inviteApi.js +61 -4
- package/dist/lib/calendar/protectedEvents.d.ts +31 -0
- package/dist/lib/calendar/protectedEvents.js +113 -0
- package/dist/lib/calendar/recurrence.d.ts +2 -0
- package/dist/lib/calendar/recurrence.js +16 -0
- package/dist/lib/calendar/sealedInvites.d.ts +6 -8
- package/dist/lib/calendar/sealedInvites.js +43 -6
- package/dist/lib/contacts/contactsApi.d.ts +29 -3
- package/dist/lib/contacts/contactsApi.js +23 -7
- package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
- package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
- package/dist/lib/crypto/composeSecurity.d.ts +11 -3
- package/dist/lib/crypto/composeSecurity.js +13 -5
- package/dist/lib/crypto/keyExport.js +2 -2
- package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
- package/dist/lib/crypto/keyPairMatch.js +65 -0
- package/dist/lib/crypto/keySession.d.ts +19 -2
- package/dist/lib/crypto/keySession.js +89 -13
- package/dist/lib/crypto/keyvaultApi.d.ts +41 -5
- package/dist/lib/crypto/keyvaultApi.js +13 -3
- package/dist/lib/crypto/meetingKeys.d.ts +25 -0
- package/dist/lib/crypto/meetingKeys.js +87 -0
- package/dist/lib/crypto/messageSecurity.d.ts +26 -2
- package/dist/lib/crypto/messageSecurity.js +122 -10
- package/dist/lib/crypto/openPgpKey.d.ts +39 -0
- package/dist/lib/crypto/openPgpKey.js +89 -0
- package/dist/lib/crypto/openpgp.d.ts +47 -0
- package/dist/lib/crypto/openpgp.js +88 -0
- package/dist/lib/crypto/pgpMessage.d.ts +51 -0
- package/dist/lib/crypto/pgpMessage.js +271 -0
- package/dist/lib/crypto/sharedMailboxKeys.d.ts +15 -3
- package/dist/lib/crypto/sharedMailboxKeys.js +102 -29
- package/dist/lib/crypto/smimeMessage.d.ts +21 -0
- package/dist/lib/crypto/smimeMessage.js +11 -7
- package/dist/lib/crypto/zeroAccessKey.js +5 -0
- package/dist/lib/mail/mailApi.d.ts +33 -0
- package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
- package/dist/lib/mail/mailboxImportApi.js +15 -0
- package/dist/lib/search/zeroAccessIndexApi.d.ts +4 -0
- package/dist/lib/tasks/tasksApi.d.ts +11 -1
- package/dist/lib/tasks/tasksApi.js +10 -4
- package/dist/lib/util/clientPlatform.d.ts +14 -0
- package/dist/lib/util/clientPlatform.js +26 -0
- package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
- package/dist/lib/videoconf/protectedMeetings.js +181 -0
- package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
- package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
- package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
- package/dist/lib/zeroAccess/sealedMoves.js +63 -0
- package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
- package/dist/lib/zeroAccess/sealedRecords.js +136 -0
- package/lib/admin/domainsApi.ts +1 -1
- package/lib/calendar/calendarApi.ts +140 -17
- package/lib/calendar/calendarMutations.ts +79 -12
- package/lib/calendar/inviteApi.ts +75 -5
- package/lib/calendar/protectedEvents.ts +143 -0
- package/lib/calendar/recurrence.ts +17 -0
- package/lib/calendar/sealedInvites.ts +47 -7
- package/lib/contacts/contactsApi.ts +43 -17
- package/lib/contacts/useContactPhotoSrc.ts +103 -13
- package/lib/crypto/composeSecurity.ts +20 -5
- package/lib/crypto/keyExport.ts +2 -2
- package/lib/crypto/keyPairMatch.ts +69 -0
- package/lib/crypto/keySession.ts +102 -12
- package/lib/crypto/keyvaultApi.ts +49 -7
- package/lib/crypto/meetingKeys.ts +118 -0
- package/lib/crypto/messageSecurity.ts +174 -13
- package/lib/crypto/openPgpKey.ts +118 -0
- package/lib/crypto/openpgp.ts +115 -0
- package/lib/crypto/pgpMessage.ts +336 -0
- package/lib/crypto/sharedMailboxKeys.ts +106 -28
- package/lib/crypto/smimeMessage.ts +11 -7
- package/lib/crypto/zeroAccessKey.ts +5 -0
- package/lib/mail/mailApi.ts +27 -0
- package/lib/mail/mailboxImportApi.ts +24 -0
- package/lib/search/zeroAccessIndexApi.ts +4 -0
- package/lib/tasks/tasksApi.ts +19 -5
- package/lib/util/clientPlatform.ts +50 -0
- package/lib/videoconf/protectedMeetings.ts +221 -0
- package/lib/videoconf/videoMeetingsApi.ts +15 -4
- package/lib/zeroAccess/sealedMoves.ts +79 -0
- package/lib/zeroAccess/sealedRecords.ts +153 -0
- package/package.json +12 -3
|
@@ -12,10 +12,13 @@ import { addDays, addMonths, addWeeks, eachDayOfInterval, endOfDay, endOfMonth,
|
|
|
12
12
|
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
|
-
import { getCalendarEvent, listCalendarEvents } from "../../../lib/calendar/calendarApi.js";
|
|
15
|
+
import { fullyOpenedEvent, getCalendarEvent, listCalendarEvents } from "../../../lib/calendar/calendarApi.js";
|
|
16
|
+
import { isOpenedRecord, isProtectedRecord } from "../../../lib/zeroAccess/sealedRecords.js";
|
|
17
|
+
import { isSealedRecord } from "../../../lib/zeroAccess/sealedMoves.js";
|
|
18
|
+
import { useKeysLocked } from "../../shared/zeroAccess/useKeysLocked.js";
|
|
16
19
|
import { openSealedEvent, openSealedEvents } from "../../shared/calendar/sealedEvents.js";
|
|
17
20
|
import { useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
|
|
18
|
-
import { moveOccurrence, resizeOccurrenceEnd } from "../../../lib/calendar/calendarMutations.js";
|
|
21
|
+
import { SealedOccurrenceError, moveOccurrence, resizeOccurrenceEnd } from "../../../lib/calendar/calendarMutations.js";
|
|
19
22
|
import { resolveDragAction } from "../../../lib/calendar/calendarDragIds.js";
|
|
20
23
|
import { createFolder } from "../../../lib/mail/mailApi.js";
|
|
21
24
|
import { expandAllOccurrences, expandOccurrences } from "../../../lib/calendar/recurrence.js";
|
|
@@ -48,6 +51,8 @@ import { SHORTCUTS } from "../../shared/keyboard/keymap.js";
|
|
|
48
51
|
import { useShortcut } from "../../shared/keyboard/useShortcut.js";
|
|
49
52
|
import { useShortcutProps } from "../../shared/keyboard/useShortcutProps.js";
|
|
50
53
|
import { notifyApiError } from "../../shared/notifications/apiErrors.js";
|
|
54
|
+
import { notify } from "../../shared/notifications/store.js";
|
|
55
|
+
import { KeysLockedError } from "../../../lib/crypto/masterKey.js";
|
|
51
56
|
import { bookingSettingsHref } from "../../shared/calendar/bookingPlugin.js";
|
|
52
57
|
import { changedEventOf } from "../../shared/calendar/calendarLiveUpdates.js";
|
|
53
58
|
/** The push client channel group (`PushClient.setChannels()`) the page's calendars are subscribed under. */
|
|
@@ -295,11 +300,41 @@ function CalendarContent({ userUid, bookingHref }) {
|
|
|
295
300
|
// Today moves on at midnight: the List's "from today on" is worked out again then (`listDay` is only a dependency).
|
|
296
301
|
const listDay = useDayKey();
|
|
297
302
|
const dayOfList = view === "list" ? listDay : "";
|
|
303
|
+
// A search matches descriptions too, which a list leaves sealed (`EVENT_LIST_FIELDS`): the protected events are opened in full once one
|
|
304
|
+
// runs, and searched as they are until then.
|
|
305
|
+
const [searchable, setSearchable] = useState(null);
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
if (!searching || !events.some((event) => isProtectedRecord(event) && !isOpenedRecord(event))) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
let cancelled = false;
|
|
311
|
+
void Promise.all(events.map((event) => fullyOpenedEvent(event))).then((opened) => {
|
|
312
|
+
if (!cancelled) {
|
|
313
|
+
setSearchable({ from: events, events: opened });
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
return () => {
|
|
317
|
+
cancelled = true;
|
|
318
|
+
};
|
|
319
|
+
}, [events, searching]);
|
|
320
|
+
const searchedEvents = searchable?.from === events ? searchable.events : events;
|
|
321
|
+
// The calendar holds protected events opened with their mailboxes' keys: a lock of one takes them off screen - read again with their
|
|
322
|
+
// placeholders, the search's fully opened copies dropped, and an open card or form of a protected event closed - its own mailbox's
|
|
323
|
+
// lock closes it too when it isn't one of the listed events (opened from a reminder, its calendar unchecked).
|
|
324
|
+
useKeysLocked((locked) => {
|
|
325
|
+
const listed = events.some((event) => event.mailboxUid === locked);
|
|
326
|
+
setModal((current) => current?.occurrence && isSealedRecord(current.occurrence) && (listed || current.occurrence.mailboxUid === locked) ? null : current);
|
|
327
|
+
if (!listed) {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
reload();
|
|
331
|
+
setSearchable(null);
|
|
332
|
+
});
|
|
298
333
|
const matches = useMemo(() => {
|
|
299
|
-
const found = searching ? searchOccurrences(
|
|
334
|
+
const found = searching ? searchOccurrences(searchedEvents, query) : [];
|
|
300
335
|
const today = new Date();
|
|
301
336
|
return view === "list" ? found.filter((occurrence) => isUpcoming(occurrence, today)) : found;
|
|
302
|
-
}, [
|
|
337
|
+
}, [searchedEvents, query, searching, view, dayOfList]);
|
|
303
338
|
const activeIndex = matches.findIndex((m) => m.occurrenceKey === activeKey);
|
|
304
339
|
// 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
|
|
305
340
|
// 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.
|
|
@@ -536,7 +571,14 @@ function CalendarContent({ userUid, bookingHref }) {
|
|
|
536
571
|
reload();
|
|
537
572
|
}
|
|
538
573
|
catch (err) {
|
|
539
|
-
|
|
574
|
+
const context = action.type === "move" ? "Couldn't move the event" : "Couldn't resize the event";
|
|
575
|
+
if (err instanceof KeysLockedError || err instanceof SealedOccurrenceError) {
|
|
576
|
+
// A protected occurrence while the mailbox is locked, or one of a zero-access invitation: nothing was written.
|
|
577
|
+
notify({ kind: "warning", title: context, message: err.message });
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
notifyApiError(err, context);
|
|
581
|
+
}
|
|
540
582
|
// A refused change (a 409 most of all) means the page holds a stale version: load again, or every later drag of this event fails the same way.
|
|
541
583
|
reload();
|
|
542
584
|
}
|
|
@@ -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, getContact, listContacts, ensureSuggestedContacts, listContactsInFolders, listDeletedContacts, setContactFavorite, updateContact, } from "../../../lib/contacts/contactsApi.js";
|
|
10
|
+
import { createContact, deleteContact, getContact, CONTACT_LIST_FIELDS, CONTACT_MATCH_FIELDS, 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";
|
|
@@ -34,6 +34,8 @@ 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
36
|
import { changedItemOf, useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
|
|
37
|
+
import { isOpenedRecord, isProtectedRecord, openSealedRecord, openSealedRecords } from "../../../lib/zeroAccess/sealedRecords.js";
|
|
38
|
+
import { useKeysLocked } from "../../shared/zeroAccess/useKeysLocked.js";
|
|
37
39
|
/** The published model class name of a contact on either database (`ContactMongo`, `ContactSQL`) - not a `ContactList`'s. */
|
|
38
40
|
const CONTACT_MODEL = /^Contact(Mongo|SQL)$/;
|
|
39
41
|
/** The push client channel group (`PushClient.setChannels()`) the page's contacts folders are subscribed under. */
|
|
@@ -113,6 +115,7 @@ function ContactsContent({ userUid }) {
|
|
|
113
115
|
void loadSuggested(suggestedFolderUid);
|
|
114
116
|
}
|
|
115
117
|
return listAllPages((page) => listContacts(folderUid, { limit: LIST_PAGE_SIZE, page }, client))
|
|
118
|
+
.then(async (result) => ({ ...result, items: await openSealedRecords(result.items, CONTACT_LIST_FIELDS) }))
|
|
116
119
|
.then((result) => {
|
|
117
120
|
setContacts(result.items);
|
|
118
121
|
setTruncated(result.truncated);
|
|
@@ -123,10 +126,24 @@ function ContactsContent({ userUid }) {
|
|
|
123
126
|
useEffect(() => {
|
|
124
127
|
void reload();
|
|
125
128
|
}, [folderUid]);
|
|
129
|
+
// The lists hold protected contacts opened with the mailbox's keys: its lock takes them off screen, read again with their placeholders (the
|
|
130
|
+
// contacts, the suggested ones and the Deleted view). They leave the lists first, so a reload that fails can't leave them shown. An open
|
|
131
|
+
// contact's pane and form drop what they opened themselves (`useOpenedRecord`).
|
|
132
|
+
const [lockCount, setLockCount] = useState(0);
|
|
133
|
+
useKeysLocked((locked) => {
|
|
134
|
+
if (locked === mailboxUid) {
|
|
135
|
+
const readable = (list) => list.filter((contact) => !isProtectedRecord(contact));
|
|
136
|
+
setContacts(readable);
|
|
137
|
+
setSuggestedContacts(readable);
|
|
138
|
+
setDeletedContacts(readable);
|
|
139
|
+
void reload();
|
|
140
|
+
setLockCount((count) => count + 1);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
126
143
|
/** Reads the Suggested Contacts folder's contacts. A failure leaves the list as it was. */
|
|
127
144
|
function loadSuggested(suggestedUid) {
|
|
128
145
|
return listAllPages((page) => listContacts(suggestedUid, { limit: LIST_PAGE_SIZE, page }, client))
|
|
129
|
-
.then((result) => setSuggestedContacts(result.items))
|
|
146
|
+
.then(async (result) => setSuggestedContacts(await openSealedRecords(result.items, CONTACT_LIST_FIELDS)))
|
|
130
147
|
.catch(() => undefined);
|
|
131
148
|
}
|
|
132
149
|
// Asks the server to turn the people this mailbox has corresponded with into suggested contacts (it does so once for each person, so this is cheap when
|
|
@@ -158,7 +175,9 @@ function ContactsContent({ userUid }) {
|
|
|
158
175
|
remove(change.uid);
|
|
159
176
|
return;
|
|
160
177
|
}
|
|
161
|
-
void getContact(change.uid, client)
|
|
178
|
+
void getContact(change.uid, client)
|
|
179
|
+
.then((fresh) => openSealedRecord(fresh, CONTACT_LIST_FIELDS))
|
|
180
|
+
.then((fresh) => {
|
|
162
181
|
if (fresh.deleted === true) {
|
|
163
182
|
remove(fresh.uid);
|
|
164
183
|
}
|
|
@@ -213,6 +232,7 @@ function ContactsContent({ userUid }) {
|
|
|
213
232
|
setDeletedLoading(true);
|
|
214
233
|
setDeletedError(null);
|
|
215
234
|
listAllPages((page) => listDeletedContacts(folderUid, { limit: LIST_PAGE_SIZE, page }, client))
|
|
235
|
+
.then(async (result) => ({ ...result, items: await openSealedRecords(result.items, CONTACT_LIST_FIELDS) }))
|
|
216
236
|
.then((result) => {
|
|
217
237
|
// A server that dropped the `deleted` filter (no delete+update right on this folder) answers with live
|
|
218
238
|
// contacts, which must never be listed as deleted.
|
|
@@ -228,8 +248,8 @@ function ContactsContent({ userUid }) {
|
|
|
228
248
|
.catch((err) => setDeletedError(err instanceof ApiRequestError ? err.message : "Could not load deleted contacts."))
|
|
229
249
|
.finally(() => setDeletedLoading(false));
|
|
230
250
|
// Re-fetches every time the Deleted view is (re-)selected, matching this page's own `reload()`
|
|
231
|
-
// convention elsewhere rather than caching a possibly-stale list across visits.
|
|
232
|
-
}, [view, folderUid]);
|
|
251
|
+
// convention elsewhere rather than caching a possibly-stale list across visits - and when the mailbox locks.
|
|
252
|
+
}, [view, folderUid, lockCount]);
|
|
233
253
|
const viewFiltered = useMemo(() => {
|
|
234
254
|
switch (view.type) {
|
|
235
255
|
case "all":
|
|
@@ -406,9 +426,17 @@ function ContactsContent({ userUid }) {
|
|
|
406
426
|
if (!category?.trim()) {
|
|
407
427
|
return;
|
|
408
428
|
}
|
|
409
|
-
|
|
410
|
-
|
|
429
|
+
let locked = 0;
|
|
430
|
+
for (const listed of checkedContacts) {
|
|
411
431
|
try {
|
|
432
|
+
// A protected one's categories are added to as opened: one that won't open here (the mailbox is locked) shows a placeholder
|
|
433
|
+
// (none), and saving that plus the new one would replace its real categories for good. It is left as it is.
|
|
434
|
+
const contact = await openSealedRecord(listed);
|
|
435
|
+
if (isProtectedRecord(contact) && !isOpenedRecord(contact)) {
|
|
436
|
+
locked++;
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const categories = Array.from(new Set([...(contact.categories ?? []), category.trim()]));
|
|
412
440
|
// Only the changed field - sending the whole fetched contact back includes server-managed
|
|
413
441
|
// fields restapi rejects, and would overwrite any concurrent edit to the other fields.
|
|
414
442
|
await updateContact({ uid: contact.uid, version: contact.version, categories }, client);
|
|
@@ -417,14 +445,30 @@ function ContactsContent({ userUid }) {
|
|
|
417
445
|
notifyApiError(err, "Couldn't update some of the contacts");
|
|
418
446
|
}
|
|
419
447
|
}
|
|
448
|
+
if (locked > 0) {
|
|
449
|
+
notify({
|
|
450
|
+
kind: "warning",
|
|
451
|
+
title: "Some contacts weren't categorized",
|
|
452
|
+
message: `${locked === 1 ? "A contact is" : `${locked} contacts are`} protected and can't be changed while your mailbox is locked. Unlock it, then add the category again.`,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
420
455
|
clearPinnedSignerCache();
|
|
421
456
|
await reload();
|
|
422
457
|
});
|
|
423
458
|
// 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).
|
|
424
459
|
const handleAddToContacts = singleFlight(async () => {
|
|
425
460
|
// In the All contacts view the selection can hold contacts that already are the mailbox's own: only the suggested ones are added.
|
|
426
|
-
|
|
461
|
+
let locked = 0;
|
|
462
|
+
for (const listed of checkedContacts.filter((checked) => checked.folderUid === suggestedFolderUid)) {
|
|
427
463
|
try {
|
|
464
|
+
// Copied in full: the list opened only what it shows.
|
|
465
|
+
const contact = await openSealedRecord(listed);
|
|
466
|
+
// A protected one that won't open here (the mailbox is locked) holds only placeholders: copying them would replace its real
|
|
467
|
+
// name for good once the original is deleted. It is left as it is.
|
|
468
|
+
if (isProtectedRecord(contact) && !isOpenedRecord(contact)) {
|
|
469
|
+
locked++;
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
428
472
|
await createContact({
|
|
429
473
|
mailboxUid: contact.mailboxUid,
|
|
430
474
|
folderUid: folderUid,
|
|
@@ -445,11 +489,20 @@ function ContactsContent({ userUid }) {
|
|
|
445
489
|
notifyApiError(err, "Couldn't add some of the contacts");
|
|
446
490
|
}
|
|
447
491
|
}
|
|
492
|
+
if (locked > 0) {
|
|
493
|
+
notify({
|
|
494
|
+
kind: "warning",
|
|
495
|
+
title: "Some contacts weren't added",
|
|
496
|
+
message: `${locked === 1 ? "A suggested contact is" : `${locked} suggested contacts are`} protected and can't be opened while your mailbox is locked. Unlock it, then add ${locked === 1 ? "it" : "them"} again.`,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
448
499
|
setCheckedUids(new Set());
|
|
449
500
|
await reload();
|
|
450
501
|
});
|
|
451
|
-
function handleExportVCard() {
|
|
452
|
-
|
|
502
|
+
async function handleExportVCard() {
|
|
503
|
+
// Exported in full: the list opened only what it shows.
|
|
504
|
+
const contacts = await openSealedRecords(checkedContacts);
|
|
505
|
+
downloadTextFile(contacts.length === 1 ? `${contacts[0].displayName}.vcf` : "contacts.vcf", contacts.length === 1 ? contactToVCard(contacts[0]) : contactsToVCardFile(contacts));
|
|
453
506
|
}
|
|
454
507
|
async function handleImportFile(file) {
|
|
455
508
|
if (!mailboxUid || !folderUid) {
|
|
@@ -476,7 +529,7 @@ function ContactsContent({ userUid }) {
|
|
|
476
529
|
// earlier card's in the file - is left alone. The folder is read in full for that, not just the page of it the list holds.
|
|
477
530
|
let fresh;
|
|
478
531
|
try {
|
|
479
|
-
fresh = withoutDuplicates(parsed, await listContactsInFolders([folderUid], client)).fresh;
|
|
532
|
+
fresh = withoutDuplicates(parsed, await openSealedRecords(await listContactsInFolders([folderUid], client), CONTACT_MATCH_FIELDS)).fresh;
|
|
480
533
|
}
|
|
481
534
|
catch (err) {
|
|
482
535
|
notifyApiError(err, "Couldn't check the file against your contacts");
|
package/dist/apps/www/index.js
CHANGED
|
@@ -99,6 +99,9 @@ async function decryptEncryptedRows(messages, unlocked) {
|
|
|
99
99
|
let securityModule;
|
|
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
|
+
// A lock while a row was opened has already cleared the shared store: what it opened must not go back in (`decryptForDisplay()`
|
|
103
|
+
// asks the same). The caller's lock generation keeps it off this page.
|
|
104
|
+
const stillUnlocked = () => getUnlockedKeys(message.mailboxUid) === unlocked;
|
|
102
105
|
try {
|
|
103
106
|
// Loaded here, on first use: the S/MIME code (PKI.js, ASN.1, X.509) is over half a megabyte and an inbox with
|
|
104
107
|
// no encrypted mail in it never needs it.
|
|
@@ -107,11 +110,17 @@ async function decryptEncryptedRows(messages, unlocked) {
|
|
|
107
110
|
const summary = message.zeroAccess ? await securityApi.openZeroAccessSummary(message.zeroAccess.sealedSummary, unlocked.zeroAccessKeys) : undefined;
|
|
108
111
|
if (summary) {
|
|
109
112
|
const preview = summary.preview.slice(0, LIST_PREVIEW_MAX_LENGTH);
|
|
110
|
-
|
|
113
|
+
if (stillUnlocked()) {
|
|
114
|
+
rememberDecrypted(message.uid, message.mailboxUid, { subject: summary.subject, preview });
|
|
115
|
+
}
|
|
111
116
|
return [message.uid, { subject: summary.subject, preview }];
|
|
112
117
|
}
|
|
113
118
|
const rawMime = await getMessageRawContent(message.uid);
|
|
114
119
|
const security = await securityApi.evaluateMessageSecurity(rawMime, unlocked);
|
|
120
|
+
// Nor is a lock a failure to decrypt.
|
|
121
|
+
if (!stillUnlocked()) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
115
124
|
if (!security.subject && !security.html) {
|
|
116
125
|
markDecryptFailed(message.uid);
|
|
117
126
|
return null;
|
|
@@ -6,8 +6,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
6
6
|
import { pageTitle } from "../../../shared/navigation/pageTitle.js";
|
|
7
7
|
import { useEffect, useState } from "react";
|
|
8
8
|
import { ApiRequestError } from "../../../../lib/util/api.js";
|
|
9
|
-
import { addMasterKeyWrap, cancelSignEnrollment, findActivePublicKey, getCurrentSignEnrollment, getEscrowInfo, getKeyVault, rekey, removeMasterKeyWrap, startSignEnrollment, } from "../../../../lib/crypto/keyvaultApi.js";
|
|
10
|
-
import { ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, SIGNING_PRIVATE_KEY_AAD_PURPOSE, destroyUnlockedKeys, getUnlockedKeys, unlockWithPassword, } from "../../../../lib/crypto/keySession.js";
|
|
9
|
+
import { addMasterKeyWrap, cancelSignEnrollment, findActivePublicKey, getCurrentSignEnrollment, getEscrowInfo, getKeyVault, isOpenPgpKey, rekey, removeMasterKeyWrap, startSignEnrollment, } from "../../../../lib/crypto/keyvaultApi.js";
|
|
10
|
+
import { ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, OPENPGP_PRIVATE_KEY_AAD_PURPOSE, SIGNING_PRIVATE_KEY_AAD_PURPOSE, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE, destroyUnlockedKeys, getUnlockedKeys, unlockWithPassword, } from "../../../../lib/crypto/keySession.js";
|
|
11
11
|
import { IDLE_TIMEOUT_OPTIONS_MINUTES, getIdleTimeoutMinutes, setIdleTimeoutMinutes } from "../../../../lib/crypto/idleTimeout.js";
|
|
12
12
|
import { LOCAL_INDEX_SIZE_OPTIONS, getDefaultLocalIndexByteBudget, getLocalIndexByteBudget, setLocalIndexByteBudget, } from "../../../shared/search/localIndexSizePreference.js";
|
|
13
13
|
import { fromBase64 } from "../../../../lib/crypto/encoding.js";
|
|
@@ -27,6 +27,7 @@ import Button from "../../../../lib/components/buttons/Button.js";
|
|
|
27
27
|
import Modal from "../../../../lib/components/overlays/Modal.js";
|
|
28
28
|
import ZeroAccessSetting from "../../../shared/components/settings/ZeroAccessSetting.js";
|
|
29
29
|
import KeyExportSetting from "../../../shared/components/settings/KeyExportSetting.js";
|
|
30
|
+
import OpenPgpKeySetting from "../../../shared/components/settings/OpenPgpKeySetting.js";
|
|
30
31
|
import SigningCertificateCard from "../../../shared/components/settings/SigningCertificateCard.js";
|
|
31
32
|
import { readStoredSignEnrollment, storeSignEnrollment } from "../../../shared/signing/enrollmentStorage.js";
|
|
32
33
|
import { checkEnrollmentNow, forgetEnrollment, recordEnrollmentResult, useEnrollmentSnapshot, watchCurrentEnrollment, watchEnrollment, } from "../../../shared/signing/enrollmentTracker.js";
|
|
@@ -63,7 +64,14 @@ class StaleSessionKeysError extends Error {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
const STALE_KEYS_MESSAGE = "Your encryption keys were changed on another device (for example, rotated), so the copy unlocked in this session no longer works. Nothing was changed. Unlock again with your current password, then try again.";
|
|
67
|
+
/** The AAD a vault key is sealed with - by what it is: an OpenPGP secret key, a zero-access key, a signing key or an encryption key. */
|
|
66
68
|
function wrappedKeyAad(mailboxUid, entry) {
|
|
69
|
+
if (isOpenPgpKey(entry)) {
|
|
70
|
+
return buildAad(mailboxUid, OPENPGP_PRIVATE_KEY_AAD_PURPOSE);
|
|
71
|
+
}
|
|
72
|
+
if (entry.useType === "zero-access") {
|
|
73
|
+
return buildAad(mailboxUid, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE);
|
|
74
|
+
}
|
|
67
75
|
return buildAad(mailboxUid, entry.useType === "sign" ? SIGNING_PRIVATE_KEY_AAD_PURPOSE : ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE);
|
|
68
76
|
}
|
|
69
77
|
/**
|
|
@@ -125,6 +133,8 @@ async function rewrapVaultPrivateKeys(mailboxUid, currentMasterKey, wrappedKeys)
|
|
|
125
133
|
algorithm: "AES-256-GCM",
|
|
126
134
|
fingerprint: entry.fingerprint,
|
|
127
135
|
useType: entry.useType,
|
|
136
|
+
// An OpenPGP secret key stays one: without its type it would read back as an S/MIME key.
|
|
137
|
+
...(isOpenPgpKey(entry) ? { type: entry.type } : {}),
|
|
128
138
|
});
|
|
129
139
|
}
|
|
130
140
|
return { mk, wrappedKeys: rewrapped };
|
|
@@ -167,7 +177,7 @@ function EncryptionGate({ userUid, impersonating }) {
|
|
|
167
177
|
// restapi only lets the mailbox's actual owner write its key vault (`requireMailboxOwner()`), so a
|
|
168
178
|
// delegate (or an admin impersonating the owner) never provisions keys here, and sees no write actions.
|
|
169
179
|
const isOwner = mailbox.ownerUserUid !== undefined && mailbox.ownerUserUid === userUid && !impersonating;
|
|
170
|
-
return (_jsx(KeyEnrollmentGate, { mailboxUid: mailboxUid, mailboxAddress: mailbox.primarySmtpAddress, mailboxKeys: mailbox.keys, canProvision: isOwner, children: _jsx(EncryptionContent, { canManageKeys: isOwner }) }));
|
|
180
|
+
return (_jsx(KeyEnrollmentGate, { mailboxUid: mailboxUid, mailboxAddress: mailbox.primarySmtpAddress, mailboxKeys: mailbox.keys, canProvision: isOwner, children: _jsx(EncryptionContent, { canManageKeys: isOwner }, mailboxUid) }));
|
|
171
181
|
}
|
|
172
182
|
function EncryptionContent({ canManageKeys }) {
|
|
173
183
|
const { mailboxUid, mailboxes } = useSettingsShell();
|
|
@@ -801,7 +811,7 @@ function EncryptionContent({ canManageKeys }) {
|
|
|
801
811
|
: null;
|
|
802
812
|
const certificateEnrollment = certificateMode === "issued" && enrollmentResult?.status !== "issued" ? null : certificateMode === "expired" ? null : enrollmentResult;
|
|
803
813
|
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 &&
|
|
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) => {
|
|
814
|
+
(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 }), _jsx(OpenPgpKeySetting, { 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) => {
|
|
805
815
|
const key = `${wrap.method}:${wrap.methodId ?? ""}`;
|
|
806
816
|
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" &&
|
|
807
817
|
canManageKeys &&
|
|
@@ -7,7 +7,8 @@ import { pageTitle } from "../../../shared/navigation/pageTitle.js";
|
|
|
7
7
|
import { useEffect, useRef, useState } from "react";
|
|
8
8
|
import { ApiRequestError } from "../../../../lib/util/api.js";
|
|
9
9
|
import { createExportRequest, exportRequestDownloadUrl, listExportRequests, } from "../../../../lib/mail/dataExportApi.js";
|
|
10
|
-
import { listImportRequests, uploadMailboxImport, } from "../../../../lib/mail/mailboxImportApi.js";
|
|
10
|
+
import { ZERO_ACCESS_IMPORT_MESSAGE, listImportRequests, mailIsZeroAccess, uploadMailboxImport, } from "../../../../lib/mail/mailboxImportApi.js";
|
|
11
|
+
import { getEncryptionPolicy } from "../../../../lib/crypto/keyvaultApi.js";
|
|
11
12
|
import { createErasureRequest, listErasureRequests, } from "../../../../lib/mail/erasureRequestApi.js";
|
|
12
13
|
import { listFolders } from "../../../../lib/mail/mailApi.js";
|
|
13
14
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
@@ -16,6 +17,7 @@ import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
|
16
17
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
17
18
|
import Modal from "../../../../lib/components/overlays/Modal.js";
|
|
18
19
|
import { notifyApiError } from "../../../shared/notifications/apiErrors.js";
|
|
20
|
+
import { INVALID_REQUEST_CODE } from "../../../../lib/zeroAccess/sealedMoves.js";
|
|
19
21
|
import GravatarSetting from "../../../shared/components/settings/GravatarSetting.js";
|
|
20
22
|
const NON_MAIL_FOLDER_TYPES = new Set(["calendar", "contacts", "tasks", "notes"]);
|
|
21
23
|
function importFormatFromFilename(filename) {
|
|
@@ -50,7 +52,7 @@ function PrivacyContent({ userUid }) {
|
|
|
50
52
|
const ownedMailboxes = mailboxes.filter((mb) => mb.ownerUserUid !== undefined && mb.ownerUserUid === userUid);
|
|
51
53
|
const ownMailbox = ownedMailboxes.length === 1 ? ownedMailboxes[0] : undefined;
|
|
52
54
|
const selectedIsOwn = selected.uid === ownMailbox?.uid;
|
|
53
|
-
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-8", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-lg font-bold tracking-tight mb-1", children: "Privacy & Data" }), _jsx("p", { className: "text-sm text-text-muted", children: "Export or manage the data associated with this mailbox." })] }), _jsx(GravatarSetting, {}), selectedIsOwn ? (_jsxs(_Fragment, { children: [_jsx(ExportSection, { mailboxUid: mailboxUid, mailboxLabel: `${selected.displayName} (${selected.primarySmtpAddress})` }), _jsx(ImportSection, { mailboxUid: mailboxUid }), _jsx(ErasureSection, { mailboxLabel: `${selected.displayName} (${selected.primarySmtpAddress})` })] })) : (_jsx(OwnMailboxOnlyNotice, { ownedMailboxCount: ownedMailboxes.length, ownMailboxLabel: ownMailbox && `${ownMailbox.displayName} (${ownMailbox.primarySmtpAddress})` }))] }) }));
|
|
55
|
+
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-8", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-lg font-bold tracking-tight mb-1", children: "Privacy & Data" }), _jsx("p", { className: "text-sm text-text-muted", children: "Export or manage the data associated with this mailbox." })] }), _jsx(GravatarSetting, {}), selectedIsOwn ? (_jsxs(_Fragment, { children: [_jsx(ExportSection, { mailboxUid: mailboxUid, mailboxLabel: `${selected.displayName} (${selected.primarySmtpAddress})` }), _jsx(ImportSection, { mailboxUid: mailboxUid, mailbox: selected }), _jsx(ErasureSection, { mailboxLabel: `${selected.displayName} (${selected.primarySmtpAddress})` })] })) : (_jsx(OwnMailboxOnlyNotice, { ownedMailboxCount: ownedMailboxes.length, ownMailboxLabel: ownMailbox && `${ownMailbox.displayName} (${ownMailbox.primarySmtpAddress})` }))] }) }));
|
|
54
56
|
}
|
|
55
57
|
function OwnMailboxOnlyNotice({ ownedMailboxCount, ownMailboxLabel }) {
|
|
56
58
|
let detail;
|
|
@@ -110,8 +112,36 @@ function ExportSection({ mailboxUid, mailboxLabel }) {
|
|
|
110
112
|
}
|
|
111
113
|
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
114
|
}
|
|
113
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Uploads a mail archive for restapi's server-side import. Not offered for a mailbox whose mail is zero-access (`mailIsZeroAccess()`):
|
|
117
|
+
* restapi refuses it (409), since the archive would wait readable in its blob store until the import ran, and the reason is shown
|
|
118
|
+
* instead. The server's zero-access refusal given anyway (zero-access turned on since the page loaded) is shown in its place too; its other
|
|
119
|
+
* 409, an import already in progress, is not.
|
|
120
|
+
*/
|
|
121
|
+
function ImportSection({ mailboxUid, mailbox }) {
|
|
114
122
|
const client = useApiClient();
|
|
123
|
+
// Only when the mailbox's own fields say its mail is zero-access is the administrator's policy asked: `undefined` until it answers
|
|
124
|
+
// (counted as allowed), and a failed read counts as allowed too, so the upload is never offered only to be refused.
|
|
125
|
+
const zeroAccessCandidate = mailIsZeroAccess(mailbox, undefined);
|
|
126
|
+
const [allowZeroAccess, setAllowZeroAccess] = useState(undefined);
|
|
127
|
+
const [refused, setRefused] = useState(null);
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (!zeroAccessCandidate) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
let cancelled = false;
|
|
133
|
+
void getEncryptionPolicy(client)
|
|
134
|
+
.then((policy) => {
|
|
135
|
+
if (!cancelled) {
|
|
136
|
+
setAllowZeroAccess(policy.allowZeroAccess);
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
.catch(() => undefined);
|
|
140
|
+
return () => {
|
|
141
|
+
cancelled = true;
|
|
142
|
+
};
|
|
143
|
+
}, [zeroAccessCandidate, client]);
|
|
144
|
+
const zeroAccess = mailIsZeroAccess(mailbox, allowZeroAccess);
|
|
115
145
|
const [folders, setFolders] = useState([]);
|
|
116
146
|
const [targetFolderUid, setTargetFolderUid] = useState("");
|
|
117
147
|
const [foldersError, setFoldersError] = useState(null);
|
|
@@ -161,10 +191,21 @@ function ImportSection({ mailboxUid }) {
|
|
|
161
191
|
setUploading(true);
|
|
162
192
|
uploadMailboxImport(file, { format: importFormatFromFilename(file.name), targetFolderUid, mailboxUid: mailboxUid }, client)
|
|
163
193
|
.then(() => loadRequests())
|
|
164
|
-
.catch((err) =>
|
|
194
|
+
.catch((err) => {
|
|
195
|
+
if (err instanceof ApiRequestError && err.status === 409 && err.code === INVALID_REQUEST_CODE) {
|
|
196
|
+
// The server refuses an import into this mailbox (its mail is zero-access): say why, in place of the upload.
|
|
197
|
+
setRefused(err.message);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
// Any other refusal - an import already in progress is a 409 too - leaves the upload offered, and shows that import.
|
|
201
|
+
notifyApiError(err, "Couldn't upload the file");
|
|
202
|
+
if (err instanceof ApiRequestError && err.status === 409) {
|
|
203
|
+
void loadRequests();
|
|
204
|
+
}
|
|
205
|
+
})
|
|
165
206
|
.finally(() => setUploading(false));
|
|
166
207
|
}
|
|
167
|
-
return (_jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Import mail" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Uploads an Mbox or PST archive and imports every message it contains into the folder you choose below. This runs in the background \u2014 check back here for its progress." }), folders.length > 0 && (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm mb-3 max-w-xs", children: [_jsx("span", { className: "font-semibold", children: "Destination folder" }), _jsx("select", { "aria-label": "Destination folder", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: targetFolderUid, onChange: (e) => setTargetFolderUid(e.target.value), children: folders.map((folder) => (_jsx("option", { value: folder.uid, children: folder.name }, folder.uid))) })] })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto mb-3", loading: uploading, disabled: uploading || !targetFolderUid, onClick: () => fileInputRef.current?.click(), children: "Upload Mbox or PST file" }), _jsx("input", { ref: fileInputRef, type: "file", accept: ".mbox,.pst", "aria-label": "Upload mail archive", className: "hidden", onChange: handleFileChange }), foldersError && _jsx(Alert, { children: foldersError }), 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 import 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 === "completed" && (_jsxs("span", { className: "text-text-muted", children: [" ", "\u2014 ", request.importedCount ?? 0, " imported", request.failedCount ? `, ${request.failedCount} failed` : ""] })), request.status === "failed" && request.errorMessage && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.errorMessage] }))] }), _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.uid))) }))] }));
|
|
208
|
+
return (_jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Import mail" }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Uploads an Mbox or PST archive and imports every message it contains into the folder you choose below. This runs in the background \u2014 check back here for its progress." }), zeroAccess || refused ? (_jsx(Alert, { children: refused ?? ZERO_ACCESS_IMPORT_MESSAGE })) : (_jsxs(_Fragment, { children: [folders.length > 0 && (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm mb-3 max-w-xs", children: [_jsx("span", { className: "font-semibold", children: "Destination folder" }), _jsx("select", { "aria-label": "Destination folder", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: targetFolderUid, onChange: (e) => setTargetFolderUid(e.target.value), children: folders.map((folder) => (_jsx("option", { value: folder.uid, children: folder.name }, folder.uid))) })] })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto mb-3", loading: uploading, disabled: uploading || !targetFolderUid, onClick: () => fileInputRef.current?.click(), children: "Upload Mbox or PST file" }), _jsx("input", { ref: fileInputRef, type: "file", accept: ".mbox,.pst", "aria-label": "Upload mail archive", className: "hidden", onChange: handleFileChange })] })), foldersError && _jsx(Alert, { children: foldersError }), 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 import 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 === "completed" && (_jsxs("span", { className: "text-text-muted", children: [" ", "\u2014 ", request.importedCount ?? 0, " imported", request.failedCount ? `, ${request.failedCount} failed` : ""] })), request.status === "failed" && request.errorMessage && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.errorMessage] }))] }), _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.uid))) }))] }));
|
|
168
209
|
}
|
|
169
210
|
function ErasureSection({ mailboxLabel }) {
|
|
170
211
|
const client = useApiClient();
|
|
@@ -26,6 +26,9 @@ 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
28
|
import { changedItemOf, useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
|
|
29
|
+
import ProtectedMark from "../../shared/zeroAccess/ProtectedMark.js";
|
|
30
|
+
import { useKeysLocked } from "../../shared/zeroAccess/useKeysLocked.js";
|
|
31
|
+
import { isProtectedRecord, openSealedRecord, openSealedRecords } from "../../../lib/zeroAccess/sealedRecords.js";
|
|
29
32
|
/** The published model class name of a task on either database (`TaskMongo`, `TaskSQL`) - not a `TaskList`'s. */
|
|
30
33
|
const TASK_MODEL = /^Task(Mongo|SQL)$/;
|
|
31
34
|
/** The push client channel group (`PushClient.setChannels()`) the page's tasks folder is subscribed under. */
|
|
@@ -106,6 +109,7 @@ function TasksContent() {
|
|
|
106
109
|
setLoading(true);
|
|
107
110
|
setError(null);
|
|
108
111
|
return listAllPages((page) => listTasks(folderUid, { limit: LIST_PAGE_SIZE, page }, client))
|
|
112
|
+
.then(async (result) => ({ ...result, items: await openSealedRecords(result.items) }))
|
|
109
113
|
.then((result) => {
|
|
110
114
|
if (run === loadRun.current) {
|
|
111
115
|
setTasks(result.items);
|
|
@@ -126,6 +130,14 @@ function TasksContent() {
|
|
|
126
130
|
useEffect(() => {
|
|
127
131
|
void reload();
|
|
128
132
|
}, [folderUid]);
|
|
133
|
+
// The list holds protected tasks opened with the mailbox's keys: its lock takes them off screen, read again with their placeholders. They
|
|
134
|
+
// leave the list first, so a reload that fails can't leave them shown.
|
|
135
|
+
useKeysLocked((locked) => {
|
|
136
|
+
if (locked === mailboxUid) {
|
|
137
|
+
setTasks((list) => list.filter((task) => !isProtectedRecord(task)));
|
|
138
|
+
void reload();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
129
141
|
// 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
142
|
// 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
143
|
// failed fetch changes nothing: the next load shows it as it is.
|
|
@@ -135,7 +147,7 @@ function TasksContent() {
|
|
|
135
147
|
remove();
|
|
136
148
|
return;
|
|
137
149
|
}
|
|
138
|
-
void getTask(change.uid, client).then((fresh) => {
|
|
150
|
+
void getTask(change.uid, client).then(openSealedRecord).then((fresh) => {
|
|
139
151
|
if (fresh.folderUid !== folderUid) {
|
|
140
152
|
remove();
|
|
141
153
|
return;
|
|
@@ -199,14 +211,14 @@ function TasksContent() {
|
|
|
199
211
|
setCreateError("That mailbox has no Tasks folder.");
|
|
200
212
|
return;
|
|
201
213
|
}
|
|
202
|
-
const created = await createTask({
|
|
214
|
+
const created = await openSealedRecord(await createTask({
|
|
203
215
|
mailboxUid: target,
|
|
204
216
|
folderUid: targetFolderUid,
|
|
205
217
|
title: title.trim(),
|
|
206
218
|
// The date input's "yyyy-MM-dd" is stored as that day's UTC midnight - see `dueDateInstant()`.
|
|
207
219
|
dueDate: dueDate ? dueDateInstant(dueDate) : undefined,
|
|
208
220
|
priority,
|
|
209
|
-
}, client);
|
|
221
|
+
}, client));
|
|
210
222
|
if (target === mailboxUid) {
|
|
211
223
|
setAddedElsewhere(null);
|
|
212
224
|
setTasks((prev) => [...prev, created]);
|
|
@@ -247,11 +259,13 @@ function TasksContent() {
|
|
|
247
259
|
const nextCompleted = !task.completed;
|
|
248
260
|
setTasks((prev) => prev.map((t) => (t.uid === task.uid ? { ...t, completed: nextCompleted } : t)));
|
|
249
261
|
try {
|
|
250
|
-
const updated = await setTaskCompleted(task, nextCompleted, client);
|
|
262
|
+
const updated = await openSealedRecord(await setTaskCompleted(task, nextCompleted, client));
|
|
251
263
|
setTasks((prev) => prev.map((t) => (t.uid === task.uid ? updated : t)));
|
|
252
264
|
}
|
|
253
265
|
catch (err) {
|
|
254
|
-
|
|
266
|
+
// Only the flag goes back, on the row there now: `task` is the row as it was shown, opened, and a lock since may have
|
|
267
|
+
// reloaded it with its placeholders (#224), which putting it back whole would undo.
|
|
268
|
+
setTasks((prev) => prev.map((t) => (t.uid === task.uid ? { ...t, completed: task.completed } : t)));
|
|
255
269
|
notifyApiError(err, "Couldn't update the task");
|
|
256
270
|
}
|
|
257
271
|
finally {
|
|
@@ -415,7 +429,7 @@ function CompletionToggle({ task, onToggle, pending }) {
|
|
|
415
429
|
].join(" "), children: "\u2713" }));
|
|
416
430
|
}
|
|
417
431
|
function TaskGroup({ label, tasks, checkedUids, onToggleChecked, onToggle, toggling, onDelete }) {
|
|
418
|
-
return (_jsxs("div", { children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-2", children: label }), _jsx("ul", { className: "bg-surface border border-border rounded-md divide-y divide-border", children: tasks.map((task) => (_jsxs("li", { className: "flex items-center gap-3 py-2.5 px-3", children: [_jsx("input", { type: "checkbox", "aria-label": `Select ${task.title}`, checked: checkedUids.has(task.uid), onChange: () => onToggleChecked(task.uid) }), _jsx(CompletionToggle, { task: task, onToggle: onToggle, pending: toggling.has(task.uid) }),
|
|
432
|
+
return (_jsxs("div", { children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-2", children: label }), _jsx("ul", { className: "bg-surface border border-border rounded-md divide-y divide-border", children: tasks.map((task) => (_jsxs("li", { className: "flex items-center gap-3 py-2.5 px-3", children: [_jsx("input", { type: "checkbox", "aria-label": `Select ${task.title}`, checked: checkedUids.has(task.uid), onChange: () => onToggleChecked(task.uid) }), _jsx(CompletionToggle, { task: task, onToggle: onToggle, pending: toggling.has(task.uid) }), _jsxs("span", { className: ["flex-1 text-sm", task.completed ? "line-through text-text-muted" : ""].join(" "), children: [task.title, _jsx(ProtectedMark, { record: task })] }), task.priority !== "normal" && (_jsx("span", { className: ["text-xs font-medium", PRIORITY_CLASS[task.priority]].join(" "), children: PRIORITY_LABEL[task.priority] })), task.dueDate && (_jsx("span", { className: "text-xs text-text-muted shrink-0", children: parseDueDate(task.dueDate).toLocaleDateString() })), _jsx("button", { type: "button", onClick: () => onDelete(task), "aria-label": `Delete "${task.title}"`, className: "text-text-muted hover:text-danger text-sm px-1", children: "\u00D7" })] }, task.uid))) })] }));
|
|
419
433
|
}
|
|
420
434
|
function TaskTable({ tasks, checkedUids, onToggleChecked, onToggle, toggling, onDelete }) {
|
|
421
435
|
const allChecked = tasks.length > 0 && tasks.every((t) => checkedUids.has(t.uid));
|
|
@@ -435,7 +449,7 @@ function TaskTable({ tasks, checkedUids, onToggleChecked, onToggle, toggling, on
|
|
|
435
449
|
}
|
|
436
450
|
}
|
|
437
451
|
}
|
|
438
|
-
return (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm bg-surface border border-border rounded-md", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border text-left", children: [_jsx("th", { className: "w-8 px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": "Select all tasks", checked: allChecked, onChange: toggleAll }) }), _jsx("th", { className: "w-8 px-3 py-2" }), _jsx("th", { className: "px-3 py-2 text-xs font-bold uppercase tracking-wide text-text-muted", children: "Title" }), _jsx("th", { className: "px-3 py-2 text-xs font-bold uppercase tracking-wide text-text-muted", children: "Due Date" }), _jsx("th", { className: "px-3 py-2 text-xs font-bold uppercase tracking-wide text-text-muted", children: "Importance" }), _jsx("th", { className: "w-8 px-3 py-2" })] }) }), _jsx("tbody", { children: tasks.map((task) => (_jsxs("tr", { className: "border-b border-border last:border-0", children: [_jsx("td", { className: "px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": `Select ${task.title}`, checked: checkedUids.has(task.uid), onChange: () => onToggleChecked(task.uid) }) }), _jsx("td", { className: "px-3 py-2", children: _jsx(CompletionToggle, { task: task, onToggle: onToggle, pending: toggling.has(task.uid) }) }),
|
|
452
|
+
return (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm bg-surface border border-border rounded-md", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border text-left", children: [_jsx("th", { className: "w-8 px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": "Select all tasks", checked: allChecked, onChange: toggleAll }) }), _jsx("th", { className: "w-8 px-3 py-2" }), _jsx("th", { className: "px-3 py-2 text-xs font-bold uppercase tracking-wide text-text-muted", children: "Title" }), _jsx("th", { className: "px-3 py-2 text-xs font-bold uppercase tracking-wide text-text-muted", children: "Due Date" }), _jsx("th", { className: "px-3 py-2 text-xs font-bold uppercase tracking-wide text-text-muted", children: "Importance" }), _jsx("th", { className: "w-8 px-3 py-2" })] }) }), _jsx("tbody", { children: tasks.map((task) => (_jsxs("tr", { className: "border-b border-border last:border-0", children: [_jsx("td", { className: "px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": `Select ${task.title}`, checked: checkedUids.has(task.uid), onChange: () => onToggleChecked(task.uid) }) }), _jsx("td", { className: "px-3 py-2", children: _jsx(CompletionToggle, { task: task, onToggle: onToggle, pending: toggling.has(task.uid) }) }), _jsxs("td", { className: ["px-3 py-2", task.completed ? "line-through text-text-muted" : ""].join(" "), children: [task.title, _jsx(ProtectedMark, { record: task })] }), _jsx("td", { className: "px-3 py-2 text-text-muted", children: task.dueDate ? parseDueDate(task.dueDate).toLocaleDateString() : "" }), _jsx("td", { className: ["px-3 py-2", PRIORITY_CLASS[task.priority]].join(" "), children: PRIORITY_LABEL[task.priority] }), _jsx("td", { className: "px-3 py-2", children: _jsx("button", { type: "button", onClick: () => onDelete(task), "aria-label": `Delete "${task.title}"`, className: "text-text-muted hover:text-danger text-sm px-1", children: "\u00D7" }) })] }, task.uid))) })] }) }));
|
|
439
453
|
}
|
|
440
454
|
export default TasksPage;
|
|
441
455
|
/** The tab's title: `Brand: Tasks` (see `pageTitle()`). */
|
|
@@ -56,7 +56,7 @@ export declare function deleteDomain(uid: string, version: number, client?: ApiC
|
|
|
56
56
|
* Idempotent on an already-verified domain; a still-unverified result is not an error (only
|
|
57
57
|
* `lastCheckedAt` advances) — always returns the domain's current state either way. */
|
|
58
58
|
export declare function verifyDomain(uid: string, client?: ApiClient): Promise<Domain>;
|
|
59
|
-
export type DnsRecordType = "ownership" | "mx" | "spf" | "dkim" | "dmarc" | "autodiscover_cname" | "autodiscover_srv";
|
|
59
|
+
export type DnsRecordType = "ownership" | "mx" | "spf" | "dkim" | "dmarc" | "openpgpkey_cname" | "autodiscover_cname" | "autodiscover_srv";
|
|
60
60
|
/** One mail-related DNS record this server recommends for a domain, live-checked against real DNS —
|
|
61
61
|
* purely diagnostic, never mutates anything (unlike `verifyDomain`). The two `autodiscover_*` types are only
|
|
62
62
|
* ever present while `@rapidmx/autodiscover-plugin` is active for this deployment. */
|