@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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import React, { useState } from "react";
|
|
6
|
-
import { Contact, setContactFavorite } from "../../../../lib/contacts/contactsApi.js";
|
|
6
|
+
import { Contact, setContactFavorite, updateContact } from "../../../../lib/contacts/contactsApi.js";
|
|
7
7
|
import CopyIconButton from "../../../../lib/components/buttons/CopyIconButton.js";
|
|
8
8
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
9
9
|
import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
@@ -15,6 +15,11 @@ import ContactPhotoEditor from "./ContactPhotoEditor.js";
|
|
|
15
15
|
import KeyChangeReview from "./KeyChangeReview.js";
|
|
16
16
|
import { KEY_CHANGE_STALE_MESSAGE, formatDate, keyPinnedSince, revocationLabel } from "./contactKeys.js";
|
|
17
17
|
import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
|
|
18
|
+
import { type EndToEndFormat, isOpenPgpKey, keyFormat, OPENPGP_KEY_TYPE, type PublicKey, trustSigner } from "../../../../lib/crypto/keyvaultApi.js";
|
|
19
|
+
import { ApiRequestError } from "../../../../lib/util/api.js";
|
|
20
|
+
import ProtectedMark from "../../zeroAccess/ProtectedMark.js";
|
|
21
|
+
import { useOpenedRecord } from "../../zeroAccess/useOpenedRecord.js";
|
|
22
|
+
import { isOpenedRecord, isProtectedRecord, openSealedRecord } from "../../../../lib/zeroAccess/sealedRecords.js";
|
|
18
23
|
|
|
19
24
|
/** Groups a hex fingerprint into 4-character blocks (`ab12 cd34 ...`) for out-of-band verification -
|
|
20
25
|
* the spec's own use case ("compare this over the phone") is materially easier with a grouped string
|
|
@@ -56,7 +61,28 @@ export interface ContactDetailPaneProps {
|
|
|
56
61
|
* always visible alongside the contact list) and the mobile detail route
|
|
57
62
|
* (`apps/www/contacts/[uid].tsx`, a full page on its own reached by tapping a contact row).
|
|
58
63
|
*/
|
|
59
|
-
|
|
64
|
+
/** The pane for `props.contact` with all its protected details opened (a list hands over a contact with only what it shows opened). */
|
|
65
|
+
export default function ContactDetailPane(props: ContactDetailPaneProps) {
|
|
66
|
+
const { record, ready } = useOpenedRecord(props.contact);
|
|
67
|
+
const { onChanged } = props;
|
|
68
|
+
// The server answers a change with the protected details sealed: what goes back to the list is opened again.
|
|
69
|
+
const openedOnChanged = onChanged && ((changed: Contact) => void openSealedRecord(changed).then(onChanged));
|
|
70
|
+
// A protected contact that can't be opened here shows placeholders: an edit would save them over the real details.
|
|
71
|
+
const editLocked = ready && isProtectedRecord(record) && !isOpenedRecord(record);
|
|
72
|
+
return <ContactDetailPaneView {...props} contact={record!} onChanged={openedOnChanged} editLocked={editLocked} />;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function ContactDetailPaneView({
|
|
76
|
+
contact,
|
|
77
|
+
onEdit,
|
|
78
|
+
onDelete,
|
|
79
|
+
canWrite = true,
|
|
80
|
+
backHref,
|
|
81
|
+
onKeysChanged,
|
|
82
|
+
onChanged,
|
|
83
|
+
canResolveKeys,
|
|
84
|
+
editLocked,
|
|
85
|
+
}: ContactDetailPaneProps & { editLocked: boolean }) {
|
|
60
86
|
const client = useApiClient();
|
|
61
87
|
// The outcome of the last key decision, kept per contact so selecting another contact doesn't carry it over.
|
|
62
88
|
const [keyNotice, setKeyNotice] = useState<{ contactUid: string; text: string } | null>(null);
|
|
@@ -111,6 +137,7 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite
|
|
|
111
137
|
<div>
|
|
112
138
|
<h1 className="text-xl font-bold tracking-tight">
|
|
113
139
|
{contact.displayName}
|
|
140
|
+
<ProtectedMark record={contact} />
|
|
114
141
|
<FavoriteStarButton favorite={!!contact.favorite} disabled={photoBusy} onToggle={() => void handleToggleFavorite()} className="ml-1 align-middle" />
|
|
115
142
|
</h1>
|
|
116
143
|
{contact.jobTitle && contact.company && (
|
|
@@ -136,7 +163,14 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite
|
|
|
136
163
|
</>
|
|
137
164
|
) : (
|
|
138
165
|
<>
|
|
139
|
-
<Button
|
|
166
|
+
<Button
|
|
167
|
+
type="button"
|
|
168
|
+
variant="secondary"
|
|
169
|
+
className="!w-auto"
|
|
170
|
+
onClick={onEdit}
|
|
171
|
+
disabled={editLocked}
|
|
172
|
+
title={editLocked ? "This contact is protected: unlock your mailbox to edit it" : undefined}
|
|
173
|
+
>
|
|
140
174
|
Edit
|
|
141
175
|
</Button>
|
|
142
176
|
<Button type="button" variant="secondary" className="!w-auto text-danger" onClick={() => setConfirmingDeleteUid(contact.uid)}>
|
|
@@ -148,6 +182,12 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite
|
|
|
148
182
|
)}
|
|
149
183
|
</div>
|
|
150
184
|
|
|
185
|
+
{editLocked && canWrite && (
|
|
186
|
+
<p role="status" className="text-sm text-text-muted">
|
|
187
|
+
This contact is protected and can’t be edited while your mailbox is locked. Unlock it to edit.
|
|
188
|
+
</p>
|
|
189
|
+
)}
|
|
190
|
+
|
|
151
191
|
<div className="bg-surface border border-border rounded-md p-6 flex flex-col gap-4 text-sm">
|
|
152
192
|
{contact.emails.length > 0 && (
|
|
153
193
|
<div>
|
|
@@ -196,18 +236,26 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite
|
|
|
196
236
|
previousKeys.length > 0 ? (
|
|
197
237
|
<div>
|
|
198
238
|
<div className="text-text-muted text-xs font-bold uppercase tracking-wide mb-1">Encryption</div>
|
|
239
|
+
{isProtectedRecord(contact) && (
|
|
240
|
+
<p className="mb-2 text-xs text-text-muted">
|
|
241
|
+
This contact’s details are protected by zero-access encryption. Its email addresses aren’t - the server needs
|
|
242
|
+
them to deliver mail and to find this contact’s encryption keys.
|
|
243
|
+
</p>
|
|
244
|
+
)}
|
|
199
245
|
{notice && (
|
|
200
246
|
<p role="status" className="mb-2 py-2 px-3 rounded-sm bg-surface-alt text-text">
|
|
201
247
|
{notice}
|
|
202
248
|
</p>
|
|
203
249
|
)}
|
|
204
250
|
{keyConflicts.map((conflict) => {
|
|
205
|
-
|
|
206
|
-
const
|
|
251
|
+
// Each format's key is pinned on its own: an OpenPGP key's conflict is with the pinned OpenPGP key.
|
|
252
|
+
const openPgp = isOpenPgpKey(conflict.observedKey);
|
|
253
|
+
const pinned = contact.keys?.find((key) => key.useType === conflict.useType && keyFormat(key) === keyFormat(conflict.observedKey));
|
|
254
|
+
const kind = openPgp ? "OpenPGP" : conflict.useType === "sign" ? "signing" : "encryption";
|
|
207
255
|
return (
|
|
208
256
|
<section
|
|
209
|
-
key={conflict.useType}
|
|
210
|
-
aria-label={`${conflict.useType === "sign" ? "Signing" : "Encryption"} key change`}
|
|
257
|
+
key={`${conflict.useType}:${keyFormat(conflict.observedKey)}`}
|
|
258
|
+
aria-label={`${openPgp ? "OpenPGP" : conflict.useType === "sign" ? "Signing" : "Encryption"} key change`}
|
|
211
259
|
className="mb-3 py-3 px-3 rounded-sm bg-warning/15 text-text flex flex-col gap-2"
|
|
212
260
|
>
|
|
213
261
|
<h2 className="font-semibold">
|
|
@@ -218,6 +266,7 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite
|
|
|
218
266
|
mailboxUid={contact.mailboxUid}
|
|
219
267
|
address={contact.emails[0]?.address}
|
|
220
268
|
useType={conflict.useType}
|
|
269
|
+
{...(openPgp ? { keyType: "openpgp" as const } : {})}
|
|
221
270
|
ownerName={contact.displayName}
|
|
222
271
|
current={pinned && { fingerprint: pinned.fingerprint, since: keyPinnedSince(contact, pinned) }}
|
|
223
272
|
proposed={{
|
|
@@ -246,9 +295,12 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite
|
|
|
246
295
|
: "This contact has not indicated a mutual encryption preference."}
|
|
247
296
|
</div>
|
|
248
297
|
)}
|
|
298
|
+
{canWrite && contact.keys?.some((key) => isOpenPgpKey(key)) && (
|
|
299
|
+
<PreferredFormatControl contact={contact} onChanged={onChanged} />
|
|
300
|
+
)}
|
|
249
301
|
{contact.keys?.map((key) => (
|
|
250
302
|
<div key={key.fingerprint} className="font-mono text-xs py-0.5">
|
|
251
|
-
{key
|
|
303
|
+
{keyLabel(key)} key: {formatFingerprint(key.fingerprint)}
|
|
252
304
|
<RevocationBadge label={revocationLabel(key)} />
|
|
253
305
|
</div>
|
|
254
306
|
))}
|
|
@@ -259,7 +311,7 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite
|
|
|
259
311
|
{previousKeys.map((key) => (
|
|
260
312
|
<li key={`${key.useType}:${key.fingerprint}:${key.replacedAt}`} className="text-xs py-0.5">
|
|
261
313
|
<span className="font-mono">
|
|
262
|
-
{key
|
|
314
|
+
{keyLabel(key)} key: {formatFingerprint(key.fingerprint)}
|
|
263
315
|
</span>
|
|
264
316
|
<RevocationBadge label={revocationLabel(key)} />
|
|
265
317
|
<span className="text-text-muted">
|
|
@@ -275,7 +327,135 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, canWrite
|
|
|
275
327
|
)}
|
|
276
328
|
</div>
|
|
277
329
|
) : null}
|
|
330
|
+
{canWrite && contact.emails[0]?.address && !contact.keys?.some((key) => isOpenPgpKey(key)) && (
|
|
331
|
+
<OpenPgpKeyImport contact={contact} onImported={() => refresh("The OpenPGP key is now trusted for this contact.")} />
|
|
332
|
+
)}
|
|
278
333
|
</div>
|
|
279
334
|
</div>
|
|
280
335
|
);
|
|
281
336
|
}
|
|
337
|
+
|
|
338
|
+
const FORMAT_NAMES: Record<EndToEndFormat, string> = { smime: "S/MIME", openpgp: "OpenPGP" };
|
|
339
|
+
|
|
340
|
+
/** What "Automatic" picks for `contact` and why (restapi *Choosing the format*, after the user's own choice): what the address
|
|
341
|
+
* advertises, else what it was last seen using, else S/MIME. */
|
|
342
|
+
function automaticFormat(contact: Contact): string {
|
|
343
|
+
const advertised = contact.encryptPreference?.preferFormat;
|
|
344
|
+
if (advertised) {
|
|
345
|
+
return `${FORMAT_NAMES[advertised]}, which they asked for`;
|
|
346
|
+
}
|
|
347
|
+
const observed = contact.observedFormat?.format;
|
|
348
|
+
return observed ? `${FORMAT_NAMES[observed]}, which they used last` : "S/MIME when they have a certificate";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* "Preferred format" for a contact who has an OpenPGP key: which format mail to them is encrypted in when they can receive both.
|
|
353
|
+
* Automatic follows what they advertise or last used; S/MIME or OpenPGP overrides that - e.g. for someone who reads OpenPGP mail
|
|
354
|
+
* but still has an S/MIME certificate from a former employer.
|
|
355
|
+
*/
|
|
356
|
+
function PreferredFormatControl({ contact, onChanged }: { contact: Contact; onChanged?: (contact: Contact) => void }) {
|
|
357
|
+
const client = useApiClient();
|
|
358
|
+
const [saving, setSaving] = useState(false);
|
|
359
|
+
async function save(value: string) {
|
|
360
|
+
setSaving(true);
|
|
361
|
+
try {
|
|
362
|
+
const changed = await updateContact(
|
|
363
|
+
{ uid: contact.uid, version: contact.version, preferredFormat: value === "smime" || value === "openpgp" ? value : null },
|
|
364
|
+
client,
|
|
365
|
+
);
|
|
366
|
+
onChanged?.(changed);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
notifyApiError(err, "Couldn't update this contact");
|
|
369
|
+
} finally {
|
|
370
|
+
setSaving(false);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return (
|
|
374
|
+
<label className="flex flex-wrap items-center gap-2 text-sm my-1">
|
|
375
|
+
<span>Preferred format</span>
|
|
376
|
+
<select
|
|
377
|
+
className="border border-border rounded-sm py-1 px-2 bg-surface text-sm"
|
|
378
|
+
value={contact.preferredFormat ?? "auto"}
|
|
379
|
+
disabled={saving}
|
|
380
|
+
onChange={(e) => void save(e.target.value)}
|
|
381
|
+
>
|
|
382
|
+
<option value="auto">Automatic ({automaticFormat(contact)})</option>
|
|
383
|
+
<option value="smime">S/MIME</option>
|
|
384
|
+
<option value="openpgp">OpenPGP</option>
|
|
385
|
+
</select>
|
|
386
|
+
</label>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** What a pinned key is called: an OpenPGP certificate by its format (it signs and encrypts), an S/MIME one by its use. */
|
|
391
|
+
function keyLabel(key: Pick<PublicKey, "useType" | "type">): string {
|
|
392
|
+
return isOpenPgpKey(key) ? "OpenPGP" : key.useType === "sign" ? "Signing" : "Encryption";
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* "Add OpenPGP key": pins an OpenPGP public key the user pasted (from the contact's website, a key they sent, a key server they
|
|
397
|
+
* trust) for the contact's first address - `trustSigner()` with `type: "openpgp"`, which restapi checks names that address. For an
|
|
398
|
+
* OpenPGP user whose domain publishes no Web Key Directory, this is how RapidMX can encrypt to them.
|
|
399
|
+
*/
|
|
400
|
+
function OpenPgpKeyImport({ contact, onImported }: { contact: Contact; onImported: () => void }) {
|
|
401
|
+
const client = useApiClient();
|
|
402
|
+
const [open, setOpen] = useState(false);
|
|
403
|
+
const [text, setText] = useState("");
|
|
404
|
+
const [busy, setBusy] = useState(false);
|
|
405
|
+
const [error, setError] = useState<string | null>(null);
|
|
406
|
+
if (!open) {
|
|
407
|
+
return (
|
|
408
|
+
<div>
|
|
409
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={() => setOpen(true)}>
|
|
410
|
+
Add OpenPGP key…
|
|
411
|
+
</Button>
|
|
412
|
+
</div>
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
async function handleImport(e: React.FormEvent) {
|
|
416
|
+
e.preventDefault();
|
|
417
|
+
setBusy(true);
|
|
418
|
+
setError(null);
|
|
419
|
+
try {
|
|
420
|
+
await trustSigner(contact.mailboxUid, { address: contact.emails[0].address, certificate: text.trim(), type: OPENPGP_KEY_TYPE }, client);
|
|
421
|
+
clearPinnedSignerCache();
|
|
422
|
+
setOpen(false);
|
|
423
|
+
setText("");
|
|
424
|
+
onImported();
|
|
425
|
+
} catch (err) {
|
|
426
|
+
setError(
|
|
427
|
+
err instanceof ApiRequestError && err.status === 400
|
|
428
|
+
? `That isn't an OpenPGP public key for ${contact.emails[0].address} that can be used now.`
|
|
429
|
+
: err instanceof ApiRequestError && err.status === 409
|
|
430
|
+
? "A different OpenPGP key is already trusted for this contact."
|
|
431
|
+
: "Couldn't add the key. Try again.",
|
|
432
|
+
);
|
|
433
|
+
} finally {
|
|
434
|
+
setBusy(false);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return (
|
|
438
|
+
<form onSubmit={(e) => void handleImport(e)} className="flex flex-col gap-2">
|
|
439
|
+
<label className="text-sm font-semibold" htmlFor={`openpgp-key-${contact.uid}`}>
|
|
440
|
+
OpenPGP public key for {contact.emails[0].address}
|
|
441
|
+
</label>
|
|
442
|
+
<textarea
|
|
443
|
+
id={`openpgp-key-${contact.uid}`}
|
|
444
|
+
className="w-full text-xs font-mono border border-border rounded-sm py-1.5 px-2 bg-surface min-h-[8rem]"
|
|
445
|
+
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----"
|
|
446
|
+
value={text}
|
|
447
|
+
onChange={(e) => setText(e.target.value)}
|
|
448
|
+
disabled={busy}
|
|
449
|
+
/>
|
|
450
|
+
{error && <Alert>{error}</Alert>}
|
|
451
|
+
<div className="flex gap-2">
|
|
452
|
+
<Button type="submit" className="!w-auto" loading={busy} disabled={busy || !text.trim()}>
|
|
453
|
+
Add key
|
|
454
|
+
</Button>
|
|
455
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={() => setOpen(false)} disabled={busy}>
|
|
456
|
+
Cancel
|
|
457
|
+
</Button>
|
|
458
|
+
</div>
|
|
459
|
+
</form>
|
|
460
|
+
);
|
|
461
|
+
}
|
|
@@ -27,6 +27,8 @@ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
|
27
27
|
import { useContactPhotoSrc } from "../../../../lib/contacts/useContactPhotoSrc.js";
|
|
28
28
|
import { findWellKnownFolderUid } from "../../mail/findWellKnownFolderUid.js";
|
|
29
29
|
import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
|
|
30
|
+
import { useOpenedRecord } from "../../zeroAccess/useOpenedRecord.js";
|
|
31
|
+
import { isOpenedRecord, isProtectedRecord, openSealedRecord, protectedRecordWrite } from "../../../../lib/zeroAccess/sealedRecords.js";
|
|
30
32
|
|
|
31
33
|
const INPUT_CLASS =
|
|
32
34
|
"w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
@@ -52,8 +54,40 @@ const ADDRESS_KINDS: ContactAddressKind[] = ["home", "work", "other"];
|
|
|
52
54
|
* mobile detail route's own edit mode (`apps/www/contacts/[uid].tsx`) — creation itself stays
|
|
53
55
|
* desktop-and-mobile-inline (see that route's own doc comment on why "new" never gets a dedicated route).
|
|
54
56
|
*/
|
|
55
|
-
|
|
57
|
+
/** The form for `props.contact` opened in full - its fields are read once, when it mounts, so it waits for a protected contact to open. One
|
|
58
|
+
* that can't be opened here (the mailbox is locked, or this device has no key for it) is never edited: its form would hold placeholders,
|
|
59
|
+
* and saving them would replace - or clear for good - the real details. */
|
|
60
|
+
export default function ContactForm(props: ContactFormProps) {
|
|
61
|
+
const { record, ready } = useOpenedRecord(props.contact);
|
|
62
|
+
// Bumped by the view's "Reload" to fill it again from the contact as it is now.
|
|
63
|
+
const [reloads, setReloads] = useState(0);
|
|
64
|
+
if (!ready) {
|
|
65
|
+
return <p className="text-sm text-text-muted">Opening this contact…</p>;
|
|
66
|
+
}
|
|
67
|
+
if (isProtectedRecord(record) && !isOpenedRecord(record)) {
|
|
68
|
+
return (
|
|
69
|
+
<div className="flex max-w-xl flex-col items-start gap-3 p-6">
|
|
70
|
+
<p role="status" className="text-sm">
|
|
71
|
+
This contact is protected and can’t be edited while your mailbox is locked. Unlock it to edit.
|
|
72
|
+
</p>
|
|
73
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={props.onCancel}>
|
|
74
|
+
Back
|
|
75
|
+
</Button>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
// The server answers a save with the protected details sealed: what goes back is opened again.
|
|
80
|
+
const onSaved = (saved: Contact) => void openSealedRecord(saved).then(props.onSaved);
|
|
81
|
+
// Another contact is another form: its fields are filled from it.
|
|
82
|
+
return <ContactFormView key={`${record?.uid ?? ""}:${reloads}`} {...props} contact={record} onSaved={onSaved} onReload={() => setReloads((n) => n + 1)} />;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function ContactFormView({ contact, mailboxUid, folderUid, mailboxes, onSaved, onCancel, onReload }: ContactFormProps & { onReload: () => void }) {
|
|
56
86
|
const client = useApiClient();
|
|
87
|
+
// The contact as the form was filled from. A live update hands the open form the contact as changed elsewhere (`contact`), but the fields
|
|
88
|
+
// still hold this one's values: an update is sent at this version, and compared with it, so that change is refused (409) - never
|
|
89
|
+
// overwritten with what the form still shows.
|
|
90
|
+
const [seeded] = useState(contact);
|
|
57
91
|
const currentPhotoUrl = useContactPhotoSrc(contact, client);
|
|
58
92
|
const [targetMailboxUid, setTargetMailboxUid] = useState(mailboxUid);
|
|
59
93
|
const [displayName, setDisplayName] = useState(contact?.displayName ?? "");
|
|
@@ -77,8 +111,8 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
77
111
|
const [error, setError] = useState<string | null>(null);
|
|
78
112
|
const [saving, setSaving] = useState(false);
|
|
79
113
|
// The contact as stored, once it has been saved and only the picture is left to do (that is a second request, which can fail on its own):
|
|
80
|
-
// a retry must not create the contact again or send the version it started from. `key` is the form's content that was saved.
|
|
81
|
-
const [stored, setStored] = useState<{ contact: Contact; key: string } | null>(null);
|
|
114
|
+
// a retry must not create the contact again or send the version it started from. `key` is the form's content that was saved, `input` its fields.
|
|
115
|
+
const [stored, setStored] = useState<{ contact: Contact; key: string; input?: object } | null>(null);
|
|
82
116
|
|
|
83
117
|
function updateEmail(index: number, patch: Partial<ContactEmail>) {
|
|
84
118
|
setEmails((prev) => prev.map((e, i) => (i === index ? { ...e, ...patch } : e)));
|
|
@@ -116,7 +150,7 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
116
150
|
.filter(Boolean);
|
|
117
151
|
// An update merges only the fields in its body, so a field the user cleared is sent as an
|
|
118
152
|
// explicit `null` (an omitted/undefined one would keep its stored value); a create just omits it.
|
|
119
|
-
const existing = stored?.contact ??
|
|
153
|
+
const existing = stored?.contact ?? seeded;
|
|
120
154
|
const cleared = existing ? null : undefined;
|
|
121
155
|
const input = {
|
|
122
156
|
displayName: displayName.trim(),
|
|
@@ -138,11 +172,14 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
138
172
|
// Stored exactly as it is now: only the picture is left.
|
|
139
173
|
saved = stored.contact;
|
|
140
174
|
} else if (existing) {
|
|
175
|
+
// A protected contact's details go only when they changed from what the form was shown (or last saved): the server stores
|
|
176
|
+
// them sealed and can't tell an unchanged value from a new one.
|
|
177
|
+
const shown = seeded && (stored?.input ? { ...seeded, ...stored.input } : seeded);
|
|
141
178
|
saved = await updateContact(
|
|
142
179
|
{
|
|
143
180
|
uid: existing.uid,
|
|
144
181
|
version: existing.version,
|
|
145
|
-
...(input as Omit<ContactPatch, "uid" | "version"
|
|
182
|
+
...protectedRecordWrite(shown, input as Omit<ContactPatch, "uid" | "version">, isOpenedRecord(seeded)),
|
|
146
183
|
},
|
|
147
184
|
client,
|
|
148
185
|
);
|
|
@@ -164,7 +201,7 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
164
201
|
client,
|
|
165
202
|
);
|
|
166
203
|
}
|
|
167
|
-
setStored({ contact: saved, key });
|
|
204
|
+
setStored({ contact: saved, key, input });
|
|
168
205
|
const changePhoto = async (current: Contact) => {
|
|
169
206
|
if (pickedPhoto) {
|
|
170
207
|
return uploadContactPhoto(current.uid, current.version, pickedPhoto, client);
|
|
@@ -180,7 +217,7 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
180
217
|
}
|
|
181
218
|
// A version that is stale means an earlier try had been applied after all (its answer never arrived): read the contact as it is and go again, once.
|
|
182
219
|
const current = await getContact(saved.uid, client);
|
|
183
|
-
setStored({ contact: current, key });
|
|
220
|
+
setStored({ contact: current, key, input });
|
|
184
221
|
saved = await changePhoto(current);
|
|
185
222
|
}
|
|
186
223
|
} catch (err) {
|
|
@@ -197,6 +234,10 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
197
234
|
}
|
|
198
235
|
}
|
|
199
236
|
|
|
237
|
+
// Changed elsewhere since the form was filled (or last saved): a save would be refused.
|
|
238
|
+
const baseline = stored?.contact ?? seeded;
|
|
239
|
+
const changedElsewhere = !!contact && !!baseline && contact.version > baseline.version;
|
|
240
|
+
|
|
200
241
|
function finish(saved: Contact) {
|
|
201
242
|
// A message opened later in this page load must see the changed contact's signing keys.
|
|
202
243
|
clearPinnedSignerCache();
|
|
@@ -212,6 +253,14 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
212
253
|
<h1 className="text-xl font-bold uppercase tracking-wide mb-3">{contact ? "Edit contact" : "New contact"}</h1>
|
|
213
254
|
|
|
214
255
|
{error && <Alert>{error}</Alert>}
|
|
256
|
+
{changedElsewhere && (
|
|
257
|
+
<div role="status" className="mb-3 flex flex-wrap items-center gap-3 text-sm">
|
|
258
|
+
<span>This contact was changed elsewhere while you were editing it, so saving would be refused. Reload it to edit the changed contact - what you typed here is lost.</span>
|
|
259
|
+
<Button type="button" variant="secondary" className="!w-auto" disabled={saving} onClick={onReload}>
|
|
260
|
+
Reload
|
|
261
|
+
</Button>
|
|
262
|
+
</div>
|
|
263
|
+
)}
|
|
215
264
|
|
|
216
265
|
{!contact && mailboxes && mailboxes.length > 1 && (
|
|
217
266
|
<FormField label="Mailbox" htmlFor="contact-mailbox">
|
|
@@ -16,6 +16,8 @@ export interface KeyChangeReviewProps {
|
|
|
16
16
|
/** The contact's address `resolveKeyConflict()` looks the contact up by; absent hides the actions. */
|
|
17
17
|
address?: string;
|
|
18
18
|
useType: "sign" | "encrypt";
|
|
19
|
+
/** `"openpgp"` for an OpenPGP certificate (pinned as `useType: "encrypt"`; it signs too); omitted for an S/MIME certificate. */
|
|
20
|
+
keyType?: "openpgp";
|
|
19
21
|
/** Who the key belongs to, for the advice ("confirm with <name>"). */
|
|
20
22
|
ownerName: string;
|
|
21
23
|
/** The currently pinned key and when it was first trusted; absent (couldn't be loaded) hides the actions. */
|
|
@@ -44,6 +46,7 @@ export default function KeyChangeReview({
|
|
|
44
46
|
mailboxUid,
|
|
45
47
|
address,
|
|
46
48
|
useType,
|
|
49
|
+
keyType,
|
|
47
50
|
ownerName,
|
|
48
51
|
current,
|
|
49
52
|
proposed,
|
|
@@ -57,7 +60,7 @@ export default function KeyChangeReview({
|
|
|
57
60
|
const [busy, setBusy] = useState<"accept" | "reject" | null>(null);
|
|
58
61
|
const [error, setError] = useState<string | null>(null);
|
|
59
62
|
const [forbidden, setForbidden] = useState(false);
|
|
60
|
-
const kind = useType === "sign" ? "signing" : "encryption";
|
|
63
|
+
const kind = keyType === "openpgp" ? "OpenPGP" : useType === "sign" ? "signing" : "encryption";
|
|
61
64
|
const actionable = !!address && !!current && canResolve !== false && !forbidden;
|
|
62
65
|
|
|
63
66
|
async function resolve(action: "accept" | "reject") {
|
|
@@ -70,6 +73,7 @@ export default function KeyChangeReview({
|
|
|
70
73
|
action,
|
|
71
74
|
expectedPinnedFingerprint: current!.fingerprint,
|
|
72
75
|
...(action === "accept" && certificate !== undefined ? { certificate } : {}),
|
|
76
|
+
...(keyType ? { type: keyType } : {}),
|
|
73
77
|
});
|
|
74
78
|
setConfirmOpen(false);
|
|
75
79
|
onResolved(action);
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { addDays } from "date-fns";
|
|
11
11
|
import { CalendarEvent, listCalendarEvents } from "../../../../lib/calendar/calendarApi.js";
|
|
12
|
+
import { openSealedEvents } from "../../calendar/sealedEvents.js";
|
|
12
13
|
import { type CalendarOccurrence, expandAllOccurrences } from "../../../../lib/calendar/recurrence.js";
|
|
13
|
-
import { type Contact, createContact, listContactsInFolders } from "../../../../lib/contacts/contactsApi.js";
|
|
14
|
+
import { type Contact, CONTACT_MATCH_FIELDS, createContact, listContactsInFolders } from "../../../../lib/contacts/contactsApi.js";
|
|
14
15
|
import { type ParsedVCardContact, parseVCards } from "../../../../lib/contacts/vcard.js";
|
|
15
16
|
import {
|
|
16
17
|
type Attachment,
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
ownContactCard,
|
|
37
38
|
withoutDuplicates,
|
|
38
39
|
} from "./participantDetails.js";
|
|
40
|
+
import { openSealedRecord, openSealedRecords } from "../../../../lib/zeroAccess/sealedRecords.js";
|
|
39
41
|
|
|
40
42
|
/** What the card is opened for besides who: the message the participant was met in, and that message's attachments when the caller already has them. */
|
|
41
43
|
export interface ContactCardContext {
|
|
@@ -68,7 +70,9 @@ export async function loadFolders(mailboxes: readonly Mailbox[], client?: ApiCli
|
|
|
68
70
|
/** The contact the address belongs to in `folders`' contacts folders, if any. */
|
|
69
71
|
export async function findContact(folders: readonly Folder[], address: string, client?: ApiClient): Promise<Contact | undefined> {
|
|
70
72
|
const uids = folders.filter((folder) => folder.type === "contacts").map((folder) => folder.uid);
|
|
71
|
-
|
|
73
|
+
const found = findContactByAddress(await listContactsInFolders(uids, client), address);
|
|
74
|
+
// The card shows the one contact in full.
|
|
75
|
+
return found && (await openSealedRecord(found));
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
/** The last `CARD_LIST_SIZE` messages to or from `address` in `mailboxes`, newest first. */
|
|
@@ -90,7 +94,7 @@ export async function loadRecentMessages(mailboxes: readonly Mailbox[], address:
|
|
|
90
94
|
export async function loadUpcomingEvents(folders: readonly Folder[], address: string, now: Date, client?: ApiClient): Promise<CalendarOccurrence[]> {
|
|
91
95
|
const wanted = normalizeAddress(address);
|
|
92
96
|
const calendars = folders.filter((folder) => folder.type === "calendar");
|
|
93
|
-
const lists = await Promise.all(calendars.map((folder) => listCalendarEvents(folder.uid, client)));
|
|
97
|
+
const lists = await Promise.all(calendars.map((folder) => listCalendarEvents(folder.uid, client).then(openSealedEvents)));
|
|
94
98
|
const involved = lists
|
|
95
99
|
.flat()
|
|
96
100
|
.filter(
|
|
@@ -198,7 +202,7 @@ export async function addCardsToAddressBook(
|
|
|
198
202
|
scope: { mailboxUid: string; folderUid: string },
|
|
199
203
|
client?: ApiClient,
|
|
200
204
|
): Promise<AddCardsResult> {
|
|
201
|
-
const { fresh, skipped } = withoutDuplicates(cards, await listContactsInFolders([scope.folderUid], client));
|
|
205
|
+
const { fresh, skipped } = withoutDuplicates(cards, await openSealedRecords(await listContactsInFolders([scope.folderUid], client), CONTACT_MATCH_FIELDS));
|
|
202
206
|
let added = 0;
|
|
203
207
|
for (const card of fresh) {
|
|
204
208
|
try {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `MessageDetailPane`). */
|
|
7
7
|
import { ApiRequestError } from "../../../../lib/util/api.js";
|
|
8
8
|
import type { Contact } from "../../../../lib/contacts/contactsApi.js";
|
|
9
|
-
import { PinnedKeyChangedError, type KeyConflict, type PublicKey } from "../../../../lib/crypto/keyvaultApi.js";
|
|
9
|
+
import { keyFormat, PinnedKeyChangedError, type KeyConflict, type PublicKey } from "../../../../lib/crypto/keyvaultApi.js";
|
|
10
10
|
|
|
11
11
|
export const KEY_CHANGE_STALE_MESSAGE = "This contact's keys changed while you were looking; reload to see the latest.";
|
|
12
12
|
export const KEY_CHANGE_INVALID_MESSAGE =
|
|
@@ -64,9 +64,9 @@ export function conflictSourceLabel(source: KeyConflict["source"]): string {
|
|
|
64
64
|
type ContactWithFirstSeen = Pick<Contact, "previousKeys"> & { keysFirstSeen?: number };
|
|
65
65
|
|
|
66
66
|
/** When `key` (one of `contact`'s pinned keys) started being trusted: when it replaced the newest previous key of its
|
|
67
|
-
* use, else the contact's `keysFirstSeen`, else the key's own `notBefore`. */
|
|
67
|
+
* use and format, else the contact's `keysFirstSeen`, else the key's own `notBefore`. */
|
|
68
68
|
export function keyPinnedSince(contact: ContactWithFirstSeen, key: PublicKey): number {
|
|
69
|
-
const replaced = (contact.previousKeys ?? []).filter((previous) => previous.useType === key.useType).map((previous) => previous.replacedAt);
|
|
69
|
+
const replaced = (contact.previousKeys ?? []).filter((previous) => previous.useType === key.useType && keyFormat(previous) === keyFormat(key)).map((previous) => previous.replacedAt);
|
|
70
70
|
if (replaced.length > 0) {
|
|
71
71
|
return Math.max(...replaced);
|
|
72
72
|
}
|
|
@@ -116,6 +116,24 @@ export interface AppShellProps extends PluginNavProps {
|
|
|
116
116
|
branding?: Branding;
|
|
117
117
|
/** The signed-in user's stored appearance preferences as the server rendered the page (its `appearance` prop), so the first paint already wears the theme and background - see `AppearanceProvider`. */
|
|
118
118
|
appearance?: unknown;
|
|
119
|
+
/** A host that signs its account out itself - see `ShellSignOutHost`. Absent (the webmail), "Sign out" ends the one session this
|
|
120
|
+
* origin holds. */
|
|
121
|
+
signOutHost?: ShellSignOutHost;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* How a host that keeps several accounts in one page (`tauri-client`: one webview, one key session, one local-index storage for all of
|
|
126
|
+
* them) signs out the account whose shell is mounted. The shell's own sign-out assumes one user per origin: it destroys every
|
|
127
|
+
* mailbox's unlocked keys and every local index on the device, posts auth-server's cookie logout and navigates to auth-server. With a
|
|
128
|
+
* host, "Sign out" still marks compose as signing out, clears this session's caches and notifications and saves open compose windows,
|
|
129
|
+
* but leaves the keys, the indexes, the logout and where to go next to the host.
|
|
130
|
+
*/
|
|
131
|
+
export interface ShellSignOutHost {
|
|
132
|
+
/** Called first, where the shell would destroy every mailbox's unlocked keys: destroys the signing-out account's. */
|
|
133
|
+
destroyKeys(): void;
|
|
134
|
+
/** Called once open compose windows are saved: destroys the account's local indexes, ends its session and leaves the page. Should
|
|
135
|
+
* not reject; a rejection is logged. */
|
|
136
|
+
finish(): Promise<void>;
|
|
119
137
|
}
|
|
120
138
|
|
|
121
139
|
export interface AppDef {
|
|
@@ -181,6 +199,7 @@ export function AppChrome({
|
|
|
181
199
|
pluginNav,
|
|
182
200
|
busy,
|
|
183
201
|
hideChrome,
|
|
202
|
+
signOutHost,
|
|
184
203
|
children,
|
|
185
204
|
}: PropsWithChildren<AppChromeProps>) {
|
|
186
205
|
const client = useApiClient();
|
|
@@ -253,9 +272,10 @@ export function AppChrome({
|
|
|
253
272
|
// own, so this is what actually removes the indexes after a console sign-out (the console also records a
|
|
254
273
|
// pending deletion, retried on the next mail load, in case no mail tab is open). `destroyAllLocalIndexes()`
|
|
255
274
|
// announces the sign-out on this same channel, which this tab then hears itself, so the ref is set first:
|
|
256
|
-
// each tab reacts once, and the tab that started the sign-out ignores its own announcement.
|
|
275
|
+
// each tab reacts once, and the tab that started the sign-out ignores its own announcement. A host that signs out itself
|
|
276
|
+
// (`signOutHost`) keeps other accounts in this origin, so another tab's sign-out isn't this one's.
|
|
257
277
|
useEffect(() => {
|
|
258
|
-
if (!userUid || typeof BroadcastChannel === "undefined") {
|
|
278
|
+
if (!userUid || signOutHost || typeof BroadcastChannel === "undefined") {
|
|
259
279
|
return;
|
|
260
280
|
}
|
|
261
281
|
const channel = new BroadcastChannel(SIGN_OUT_CHANNEL);
|
|
@@ -278,15 +298,19 @@ export function AppChrome({
|
|
|
278
298
|
});
|
|
279
299
|
});
|
|
280
300
|
return () => channel.close();
|
|
281
|
-
}, [userUid, authServerUrl]);
|
|
301
|
+
}, [userUid, authServerUrl, signOutHost]);
|
|
282
302
|
|
|
283
303
|
async function handleSignOut() {
|
|
284
304
|
signingOutRef.current = true;
|
|
285
305
|
// Before flushing and navigating: compose windows then skip their "Leave site?" prompt, which could
|
|
286
306
|
// otherwise cancel the sign-out's own navigation.
|
|
287
307
|
markSigningOut();
|
|
288
|
-
// Unlocked private keys never outlive an explicit sign-out.
|
|
289
|
-
|
|
308
|
+
// Unlocked private keys never outlive an explicit sign-out - only the signing-out account's where a host keeps others.
|
|
309
|
+
if (signOutHost) {
|
|
310
|
+
signOutHost.destroyKeys();
|
|
311
|
+
} else {
|
|
312
|
+
destroyUnlockedKeys();
|
|
313
|
+
}
|
|
290
314
|
// Trusted signer pins read from contacts don't outlive the session either.
|
|
291
315
|
clearPinnedSignerCache();
|
|
292
316
|
clearAppearanceCache();
|
|
@@ -295,6 +319,11 @@ export function AppChrome({
|
|
|
295
319
|
// Open compose windows save edits still waiting on their autosave debounce while the session is still
|
|
296
320
|
// valid - logout invalidates it. Bounded the same way as logout itself, and never rejects.
|
|
297
321
|
await flushComposeDrafts(LOGOUT_TIMEOUT_MS);
|
|
322
|
+
if (signOutHost) {
|
|
323
|
+
// The host destroys its account's indexes (spec §11) and ends its session; this page can't tell its accounts apart.
|
|
324
|
+
await signOutHost.finish().catch((err: unknown) => console.warn("The host's sign-out failed:", err));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
298
327
|
// The Tier 2 local index MUST be destroyed on explicit logout, the same as unlocked keys themselves
|
|
299
328
|
// (spec §11). Destroys every index on this device (not only mailboxes opened this page load) and is
|
|
300
329
|
// awaited before navigating - a navigation tears down the Worker mid-delete otherwise.
|