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