@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
|
@@ -17,4 +17,7 @@ export interface ContactFormProps {
|
|
|
17
17
|
* mobile detail route's own edit mode (`apps/www/contacts/[uid].tsx`) — creation itself stays
|
|
18
18
|
* desktop-and-mobile-inline (see that route's own doc comment on why "new" never gets a dedicated route).
|
|
19
19
|
*/
|
|
20
|
-
|
|
20
|
+
/** 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
|
|
21
|
+
* 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,
|
|
22
|
+
* and saving them would replace - or clear for good - the real details. */
|
|
23
|
+
export default function ContactForm(props: ContactFormProps): React.JSX.Element;
|
|
@@ -15,6 +15,8 @@ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
|
15
15
|
import { useContactPhotoSrc } from "../../../../lib/contacts/useContactPhotoSrc.js";
|
|
16
16
|
import { findWellKnownFolderUid } from "../../mail/findWellKnownFolderUid.js";
|
|
17
17
|
import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
|
|
18
|
+
import { useOpenedRecord } from "../../zeroAccess/useOpenedRecord.js";
|
|
19
|
+
import { isOpenedRecord, isProtectedRecord, openSealedRecord, protectedRecordWrite } from "../../../../lib/zeroAccess/sealedRecords.js";
|
|
18
20
|
const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
19
21
|
const SELECT_CLASS = "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
20
22
|
const ADDRESS_KINDS = ["home", "work", "other"];
|
|
@@ -23,8 +25,30 @@ const ADDRESS_KINDS = ["home", "work", "other"];
|
|
|
23
25
|
* mobile detail route's own edit mode (`apps/www/contacts/[uid].tsx`) — creation itself stays
|
|
24
26
|
* desktop-and-mobile-inline (see that route's own doc comment on why "new" never gets a dedicated route).
|
|
25
27
|
*/
|
|
26
|
-
|
|
28
|
+
/** 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
|
|
29
|
+
* 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,
|
|
30
|
+
* and saving them would replace - or clear for good - the real details. */
|
|
31
|
+
export default function ContactForm(props) {
|
|
32
|
+
const { record, ready } = useOpenedRecord(props.contact);
|
|
33
|
+
// Bumped by the view's "Reload" to fill it again from the contact as it is now.
|
|
34
|
+
const [reloads, setReloads] = useState(0);
|
|
35
|
+
if (!ready) {
|
|
36
|
+
return _jsx("p", { className: "text-sm text-text-muted", children: "Opening this contact\u2026" });
|
|
37
|
+
}
|
|
38
|
+
if (isProtectedRecord(record) && !isOpenedRecord(record)) {
|
|
39
|
+
return (_jsxs("div", { className: "flex max-w-xl flex-col items-start gap-3 p-6", children: [_jsx("p", { role: "status", className: "text-sm", children: "This contact is protected and can\u2019t be edited while your mailbox is locked. Unlock it to edit." }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: props.onCancel, children: "Back" })] }));
|
|
40
|
+
}
|
|
41
|
+
// The server answers a save with the protected details sealed: what goes back is opened again.
|
|
42
|
+
const onSaved = (saved) => void openSealedRecord(saved).then(props.onSaved);
|
|
43
|
+
// Another contact is another form: its fields are filled from it.
|
|
44
|
+
return _jsx(ContactFormView, { ...props, contact: record, onSaved: onSaved, onReload: () => setReloads((n) => n + 1) }, `${record?.uid ?? ""}:${reloads}`);
|
|
45
|
+
}
|
|
46
|
+
function ContactFormView({ contact, mailboxUid, folderUid, mailboxes, onSaved, onCancel, onReload }) {
|
|
27
47
|
const client = useApiClient();
|
|
48
|
+
// The contact as the form was filled from. A live update hands the open form the contact as changed elsewhere (`contact`), but the fields
|
|
49
|
+
// still hold this one's values: an update is sent at this version, and compared with it, so that change is refused (409) - never
|
|
50
|
+
// overwritten with what the form still shows.
|
|
51
|
+
const [seeded] = useState(contact);
|
|
28
52
|
const currentPhotoUrl = useContactPhotoSrc(contact, client);
|
|
29
53
|
const [targetMailboxUid, setTargetMailboxUid] = useState(mailboxUid);
|
|
30
54
|
const [displayName, setDisplayName] = useState(contact?.displayName ?? "");
|
|
@@ -48,7 +72,7 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
48
72
|
const [error, setError] = useState(null);
|
|
49
73
|
const [saving, setSaving] = useState(false);
|
|
50
74
|
// 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):
|
|
51
|
-
// a retry must not create the contact again or send the version it started from. `key` is the form's content that was saved.
|
|
75
|
+
// 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.
|
|
52
76
|
const [stored, setStored] = useState(null);
|
|
53
77
|
function updateEmail(index, patch) {
|
|
54
78
|
setEmails((prev) => prev.map((e, i) => (i === index ? { ...e, ...patch } : e)));
|
|
@@ -83,7 +107,7 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
83
107
|
.filter(Boolean);
|
|
84
108
|
// An update merges only the fields in its body, so a field the user cleared is sent as an
|
|
85
109
|
// explicit `null` (an omitted/undefined one would keep its stored value); a create just omits it.
|
|
86
|
-
const existing = stored?.contact ??
|
|
110
|
+
const existing = stored?.contact ?? seeded;
|
|
87
111
|
const cleared = existing ? null : undefined;
|
|
88
112
|
const input = {
|
|
89
113
|
displayName: displayName.trim(),
|
|
@@ -106,10 +130,13 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
106
130
|
saved = stored.contact;
|
|
107
131
|
}
|
|
108
132
|
else if (existing) {
|
|
133
|
+
// A protected contact's details go only when they changed from what the form was shown (or last saved): the server stores
|
|
134
|
+
// them sealed and can't tell an unchanged value from a new one.
|
|
135
|
+
const shown = seeded && (stored?.input ? { ...seeded, ...stored.input } : seeded);
|
|
109
136
|
saved = await updateContact({
|
|
110
137
|
uid: existing.uid,
|
|
111
138
|
version: existing.version,
|
|
112
|
-
...input,
|
|
139
|
+
...protectedRecordWrite(shown, input, isOpenedRecord(seeded)),
|
|
113
140
|
}, client);
|
|
114
141
|
}
|
|
115
142
|
else {
|
|
@@ -126,7 +153,7 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
126
153
|
...input,
|
|
127
154
|
}, client);
|
|
128
155
|
}
|
|
129
|
-
setStored({ contact: saved, key });
|
|
156
|
+
setStored({ contact: saved, key, input });
|
|
130
157
|
const changePhoto = async (current) => {
|
|
131
158
|
if (pickedPhoto) {
|
|
132
159
|
return uploadContactPhoto(current.uid, current.version, pickedPhoto, client);
|
|
@@ -143,7 +170,7 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
143
170
|
}
|
|
144
171
|
// 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.
|
|
145
172
|
const current = await getContact(saved.uid, client);
|
|
146
|
-
setStored({ contact: current, key });
|
|
173
|
+
setStored({ contact: current, key, input });
|
|
147
174
|
saved = await changePhoto(current);
|
|
148
175
|
}
|
|
149
176
|
}
|
|
@@ -162,12 +189,15 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
162
189
|
setSaving(false);
|
|
163
190
|
}
|
|
164
191
|
}
|
|
192
|
+
// Changed elsewhere since the form was filled (or last saved): a save would be refused.
|
|
193
|
+
const baseline = stored?.contact ?? seeded;
|
|
194
|
+
const changedElsewhere = !!contact && !!baseline && contact.version > baseline.version;
|
|
165
195
|
function finish(saved) {
|
|
166
196
|
// A message opened later in this page load must see the changed contact's signing keys.
|
|
167
197
|
clearPinnedSignerCache();
|
|
168
198
|
onSaved(saved);
|
|
169
199
|
}
|
|
170
|
-
return (_jsxs("form", { onSubmit: handleSubmit, className: "@container flex min-h-0 flex-1 flex-col", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-6", children: _jsxs("div", { className: "flex max-w-xl flex-col gap-1", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-3", children: contact ? "Edit contact" : "New contact" }), error && _jsx(Alert, { children: error }), !contact && mailboxes && mailboxes.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "contact-mailbox", children: _jsx("select", { id: "contact-mailbox", className: `${SELECT_CLASS} w-full`, value: targetMailboxUid, onChange: (e) => setTargetMailboxUid(e.target.value), children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) }) })), _jsx(FormField, { label: "Display name", htmlFor: "contact-displayName", children: _jsx("input", { id: "contact-displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value) }) }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("div", { className: "min-w-0 flex-1", children: _jsx(ContactPhotoField, { displayName: displayName, email: emails[0]?.address, currentUrl: currentPhotoUrl, picked: pickedPhoto, removed: removePhoto, onPick: (file) => {
|
|
200
|
+
return (_jsxs("form", { onSubmit: handleSubmit, className: "@container flex min-h-0 flex-1 flex-col", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-6", children: _jsxs("div", { className: "flex max-w-xl flex-col gap-1", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-3", children: contact ? "Edit contact" : "New contact" }), error && _jsx(Alert, { children: error }), changedElsewhere && (_jsxs("div", { role: "status", className: "mb-3 flex flex-wrap items-center gap-3 text-sm", children: [_jsx("span", { children: "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." }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: saving, onClick: onReload, children: "Reload" })] })), !contact && mailboxes && mailboxes.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "contact-mailbox", children: _jsx("select", { id: "contact-mailbox", className: `${SELECT_CLASS} w-full`, value: targetMailboxUid, onChange: (e) => setTargetMailboxUid(e.target.value), children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) }) })), _jsx(FormField, { label: "Display name", htmlFor: "contact-displayName", children: _jsx("input", { id: "contact-displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value) }) }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("div", { className: "min-w-0 flex-1", children: _jsx(ContactPhotoField, { displayName: displayName, email: emails[0]?.address, currentUrl: currentPhotoUrl, picked: pickedPhoto, removed: removePhoto, onPick: (file) => {
|
|
171
201
|
setPickedPhoto(file);
|
|
172
202
|
setRemovePhoto(false);
|
|
173
203
|
setError(null);
|
|
@@ -6,6 +6,8 @@ export interface KeyChangeReviewProps {
|
|
|
6
6
|
/** The contact's address `resolveKeyConflict()` looks the contact up by; absent hides the actions. */
|
|
7
7
|
address?: string;
|
|
8
8
|
useType: "sign" | "encrypt";
|
|
9
|
+
/** `"openpgp"` for an OpenPGP certificate (pinned as `useType: "encrypt"`; it signs too); omitted for an S/MIME certificate. */
|
|
10
|
+
keyType?: "openpgp";
|
|
9
11
|
/** Who the key belongs to, for the advice ("confirm with <name>"). */
|
|
10
12
|
ownerName: string;
|
|
11
13
|
/** The currently pinned key and when it was first trusted; absent (couldn't be loaded) hides the actions. */
|
|
@@ -37,4 +39,4 @@ export interface KeyChangeReviewProps {
|
|
|
37
39
|
* key, calling `resolveKeyConflict()` against the pinned fingerprint shown. Shared by a message's "signing key changed"
|
|
38
40
|
* notice and a contact's recorded key conflicts.
|
|
39
41
|
*/
|
|
40
|
-
export default function KeyChangeReview({ mailboxUid, address, useType, ownerName, current, proposed, certificate, canReject, canResolve, onResolved, onPinnedKeyChanged, }: KeyChangeReviewProps): React.JSX.Element;
|
|
42
|
+
export default function KeyChangeReview({ mailboxUid, address, useType, keyType, ownerName, current, proposed, certificate, canReject, canResolve, onResolved, onPinnedKeyChanged, }: KeyChangeReviewProps): React.JSX.Element;
|
|
@@ -15,12 +15,12 @@ import { conflictSourceLabel, formatDate, groupFingerprint, keyChangeErrorMessag
|
|
|
15
15
|
* key, calling `resolveKeyConflict()` against the pinned fingerprint shown. Shared by a message's "signing key changed"
|
|
16
16
|
* notice and a contact's recorded key conflicts.
|
|
17
17
|
*/
|
|
18
|
-
export default function KeyChangeReview({ mailboxUid, address, useType, ownerName, current, proposed, certificate, canReject, canResolve, onResolved, onPinnedKeyChanged, }) {
|
|
18
|
+
export default function KeyChangeReview({ mailboxUid, address, useType, keyType, ownerName, current, proposed, certificate, canReject, canResolve, onResolved, onPinnedKeyChanged, }) {
|
|
19
19
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
20
20
|
const [busy, setBusy] = useState(null);
|
|
21
21
|
const [error, setError] = useState(null);
|
|
22
22
|
const [forbidden, setForbidden] = useState(false);
|
|
23
|
-
const kind = useType === "sign" ? "signing" : "encryption";
|
|
23
|
+
const kind = keyType === "openpgp" ? "OpenPGP" : useType === "sign" ? "signing" : "encryption";
|
|
24
24
|
const actionable = !!address && !!current && canResolve !== false && !forbidden;
|
|
25
25
|
async function resolve(action) {
|
|
26
26
|
setBusy(action);
|
|
@@ -32,6 +32,7 @@ export default function KeyChangeReview({ mailboxUid, address, useType, ownerNam
|
|
|
32
32
|
action,
|
|
33
33
|
expectedPinnedFingerprint: current.fingerprint,
|
|
34
34
|
...(action === "accept" && certificate !== undefined ? { certificate } : {}),
|
|
35
|
+
...(keyType ? { type: keyType } : {}),
|
|
35
36
|
});
|
|
36
37
|
setConfirmOpen(false);
|
|
37
38
|
onResolved(action);
|
|
@@ -9,14 +9,16 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { addDays } from "date-fns";
|
|
11
11
|
import { listCalendarEvents } from "../../../../lib/calendar/calendarApi.js";
|
|
12
|
+
import { openSealedEvents } from "../../calendar/sealedEvents.js";
|
|
12
13
|
import { expandAllOccurrences } from "../../../../lib/calendar/recurrence.js";
|
|
13
|
-
import { createContact, listContactsInFolders } from "../../../../lib/contacts/contactsApi.js";
|
|
14
|
+
import { CONTACT_MATCH_FIELDS, createContact, listContactsInFolders } from "../../../../lib/contacts/contactsApi.js";
|
|
14
15
|
import { parseVCards } from "../../../../lib/contacts/vcard.js";
|
|
15
16
|
import { attachmentContentUrl, getMessage, listAttachments, listFolders, } from "../../../../lib/mail/mailApi.js";
|
|
16
17
|
import { search } from "../../../../lib/search/searchApi.js";
|
|
17
18
|
import { ApiRequestError } from "../../../../lib/util/api.js";
|
|
18
19
|
import { ownMailboxes, primaryMailbox } from "../../mail/primaryMailbox.js";
|
|
19
20
|
import { cardForAddress, contactInputFor, contactInputFromCard, findContactByAddress, isVCardAttachment, normalizeAddress, ownContactCard, withoutDuplicates, } from "./participantDetails.js";
|
|
21
|
+
import { openSealedRecord, openSealedRecords } from "../../../../lib/zeroAccess/sealedRecords.js";
|
|
20
22
|
/** How many recent messages and upcoming events the card lists. */
|
|
21
23
|
export const CARD_LIST_SIZE = 5;
|
|
22
24
|
/** How far ahead "upcoming" looks, in days. */
|
|
@@ -39,7 +41,9 @@ export async function loadFolders(mailboxes, client) {
|
|
|
39
41
|
/** The contact the address belongs to in `folders`' contacts folders, if any. */
|
|
40
42
|
export async function findContact(folders, address, client) {
|
|
41
43
|
const uids = folders.filter((folder) => folder.type === "contacts").map((folder) => folder.uid);
|
|
42
|
-
|
|
44
|
+
const found = findContactByAddress(await listContactsInFolders(uids, client), address);
|
|
45
|
+
// The card shows the one contact in full.
|
|
46
|
+
return found && (await openSealedRecord(found));
|
|
43
47
|
}
|
|
44
48
|
/** The last `CARD_LIST_SIZE` messages to or from `address` in `mailboxes`, newest first. */
|
|
45
49
|
export async function loadRecentMessages(mailboxes, address, client) {
|
|
@@ -53,7 +57,7 @@ export async function loadRecentMessages(mailboxes, address, client) {
|
|
|
53
57
|
export async function loadUpcomingEvents(folders, address, now, client) {
|
|
54
58
|
const wanted = normalizeAddress(address);
|
|
55
59
|
const calendars = folders.filter((folder) => folder.type === "calendar");
|
|
56
|
-
const lists = await Promise.all(calendars.map((folder) => listCalendarEvents(folder.uid, client)));
|
|
60
|
+
const lists = await Promise.all(calendars.map((folder) => listCalendarEvents(folder.uid, client).then(openSealedEvents)));
|
|
57
61
|
const involved = lists
|
|
58
62
|
.flat()
|
|
59
63
|
.filter((event) => event.status !== "cancelled" &&
|
|
@@ -138,7 +142,7 @@ export async function buildOwnCardFile(mailbox, mailboxes, userUid, client) {
|
|
|
138
142
|
* card's in the same file). A card that cannot be stored fails the whole call after the earlier ones were added - `AddCardsError` says how many.
|
|
139
143
|
*/
|
|
140
144
|
export async function addCardsToAddressBook(cards, scope, client) {
|
|
141
|
-
const { fresh, skipped } = withoutDuplicates(cards, await listContactsInFolders([scope.folderUid], client));
|
|
145
|
+
const { fresh, skipped } = withoutDuplicates(cards, await openSealedRecords(await listContactsInFolders([scope.folderUid], client), CONTACT_MATCH_FIELDS));
|
|
142
146
|
let added = 0;
|
|
143
147
|
for (const card of fresh) {
|
|
144
148
|
try {
|
|
@@ -23,7 +23,7 @@ type ContactWithFirstSeen = Pick<Contact, "previousKeys"> & {
|
|
|
23
23
|
keysFirstSeen?: number;
|
|
24
24
|
};
|
|
25
25
|
/** When `key` (one of `contact`'s pinned keys) started being trusted: when it replaced the newest previous key of its
|
|
26
|
-
* use, else the contact's `keysFirstSeen`, else the key's own `notBefore`. */
|
|
26
|
+
* use and format, else the contact's `keysFirstSeen`, else the key's own `notBefore`. */
|
|
27
27
|
export declare function keyPinnedSince(contact: ContactWithFirstSeen, key: PublicKey): number;
|
|
28
28
|
export declare function formatDate(ms: number): string;
|
|
29
29
|
export {};
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
/** Display and error helpers for a contact's pinned keys and key changes (`KeyChangeReview`, `ContactDetailPane`,
|
|
6
6
|
* `MessageDetailPane`). */
|
|
7
7
|
import { ApiRequestError } from "../../../../lib/util/api.js";
|
|
8
|
-
import { PinnedKeyChangedError } from "../../../../lib/crypto/keyvaultApi.js";
|
|
8
|
+
import { keyFormat, PinnedKeyChangedError } from "../../../../lib/crypto/keyvaultApi.js";
|
|
9
9
|
export const KEY_CHANGE_STALE_MESSAGE = "This contact's keys changed while you were looking; reload to see the latest.";
|
|
10
10
|
export const KEY_CHANGE_INVALID_MESSAGE = "The new key isn't a valid, current certificate for this address, so it can't be accepted. Nothing was changed.";
|
|
11
11
|
export const KEY_CHANGE_FORBIDDEN_MESSAGE = "You don't have permission to change this contact's keys.";
|
|
@@ -51,9 +51,9 @@ export function conflictSourceLabel(source) {
|
|
|
51
51
|
return source === "header" ? "from an incoming message" : "by key discovery";
|
|
52
52
|
}
|
|
53
53
|
/** When `key` (one of `contact`'s pinned keys) started being trusted: when it replaced the newest previous key of its
|
|
54
|
-
* use, else the contact's `keysFirstSeen`, else the key's own `notBefore`. */
|
|
54
|
+
* use and format, else the contact's `keysFirstSeen`, else the key's own `notBefore`. */
|
|
55
55
|
export function keyPinnedSince(contact, key) {
|
|
56
|
-
const replaced = (contact.previousKeys ?? []).filter((previous) => previous.useType === key.useType).map((previous) => previous.replacedAt);
|
|
56
|
+
const replaced = (contact.previousKeys ?? []).filter((previous) => previous.useType === key.useType && keyFormat(previous) === keyFormat(key)).map((previous) => previous.replacedAt);
|
|
57
57
|
if (replaced.length > 0) {
|
|
58
58
|
return Math.max(...replaced);
|
|
59
59
|
}
|
|
@@ -40,6 +40,23 @@ export interface AppShellProps extends PluginNavProps {
|
|
|
40
40
|
branding?: Branding;
|
|
41
41
|
/** 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`. */
|
|
42
42
|
appearance?: unknown;
|
|
43
|
+
/** A host that signs its account out itself - see `ShellSignOutHost`. Absent (the webmail), "Sign out" ends the one session this
|
|
44
|
+
* origin holds. */
|
|
45
|
+
signOutHost?: ShellSignOutHost;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* How a host that keeps several accounts in one page (`tauri-client`: one webview, one key session, one local-index storage for all of
|
|
49
|
+
* them) signs out the account whose shell is mounted. The shell's own sign-out assumes one user per origin: it destroys every
|
|
50
|
+
* mailbox's unlocked keys and every local index on the device, posts auth-server's cookie logout and navigates to auth-server. With a
|
|
51
|
+
* host, "Sign out" still marks compose as signing out, clears this session's caches and notifications and saves open compose windows,
|
|
52
|
+
* but leaves the keys, the indexes, the logout and where to go next to the host.
|
|
53
|
+
*/
|
|
54
|
+
export interface ShellSignOutHost {
|
|
55
|
+
/** Called first, where the shell would destroy every mailbox's unlocked keys: destroys the signing-out account's. */
|
|
56
|
+
destroyKeys(): void;
|
|
57
|
+
/** Called once open compose windows are saved: destroys the account's local indexes, ends its session and leaves the page. Should
|
|
58
|
+
* not reject; a rejection is logged. */
|
|
59
|
+
finish(): Promise<void>;
|
|
43
60
|
}
|
|
44
61
|
export interface AppDef {
|
|
45
62
|
id: AppShellApp;
|
|
@@ -67,7 +84,7 @@ export interface AppChromeProps extends AppShellProps {
|
|
|
67
84
|
* Rendered by the webmail's app shell (`apps/www/_shell.tsx`, the router's persistent client layout), once, for the life of the
|
|
68
85
|
* page, so that moving between apps replaces only `children` - see `AppShell` below for what a page's own shell renders instead.
|
|
69
86
|
*/
|
|
70
|
-
export declare function AppChrome({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, branding: initialBranding, appearance, pluginNav, busy, hideChrome, children, }: PropsWithChildren<AppChromeProps>): React.JSX.Element;
|
|
87
|
+
export declare function AppChrome({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, branding: initialBranding, appearance, pluginNav, busy, hideChrome, signOutHost, children, }: PropsWithChildren<AppChromeProps>): React.JSX.Element;
|
|
71
88
|
/**
|
|
72
89
|
* What a page's own shell (`MailShell`, `CalendarShell`, ...) renders around its content. Outside the client-side router it
|
|
73
90
|
* is the whole `AppChrome`, as it always was. Inside it (the router's app shell, `apps/www/_shell.tsx`) the chrome is already mounted above the
|
|
@@ -100,7 +100,7 @@ export function appRailItems(pluginNav, resolved = {}) {
|
|
|
100
100
|
* Rendered by the webmail's app shell (`apps/www/_shell.tsx`, the router's persistent client layout), once, for the life of the
|
|
101
101
|
* page, so that moving between apps replaces only `children` - see `AppShell` below for what a page's own shell renders instead.
|
|
102
102
|
*/
|
|
103
|
-
export function AppChrome({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, branding: initialBranding, appearance, pluginNav, busy, hideChrome, children, }) {
|
|
103
|
+
export function AppChrome({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, branding: initialBranding, appearance, pluginNav, busy, hideChrome, signOutHost, children, }) {
|
|
104
104
|
const client = useApiClient();
|
|
105
105
|
const [stoppingImpersonation, setStoppingImpersonation] = useState(false);
|
|
106
106
|
// The keyboard shortcuts dialog: opened by `?`/Ctrl+/ (`GlobalShortcuts`) and by the account menu's item.
|
|
@@ -168,9 +168,10 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
|
|
|
168
168
|
// own, so this is what actually removes the indexes after a console sign-out (the console also records a
|
|
169
169
|
// pending deletion, retried on the next mail load, in case no mail tab is open). `destroyAllLocalIndexes()`
|
|
170
170
|
// announces the sign-out on this same channel, which this tab then hears itself, so the ref is set first:
|
|
171
|
-
// each tab reacts once, and the tab that started the sign-out ignores its own announcement.
|
|
171
|
+
// each tab reacts once, and the tab that started the sign-out ignores its own announcement. A host that signs out itself
|
|
172
|
+
// (`signOutHost`) keeps other accounts in this origin, so another tab's sign-out isn't this one's.
|
|
172
173
|
useEffect(() => {
|
|
173
|
-
if (!userUid || typeof BroadcastChannel === "undefined") {
|
|
174
|
+
if (!userUid || signOutHost || typeof BroadcastChannel === "undefined") {
|
|
174
175
|
return;
|
|
175
176
|
}
|
|
176
177
|
const channel = new BroadcastChannel(SIGN_OUT_CHANNEL);
|
|
@@ -193,14 +194,19 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
|
|
|
193
194
|
});
|
|
194
195
|
});
|
|
195
196
|
return () => channel.close();
|
|
196
|
-
}, [userUid, authServerUrl]);
|
|
197
|
+
}, [userUid, authServerUrl, signOutHost]);
|
|
197
198
|
async function handleSignOut() {
|
|
198
199
|
signingOutRef.current = true;
|
|
199
200
|
// Before flushing and navigating: compose windows then skip their "Leave site?" prompt, which could
|
|
200
201
|
// otherwise cancel the sign-out's own navigation.
|
|
201
202
|
markSigningOut();
|
|
202
|
-
// Unlocked private keys never outlive an explicit sign-out.
|
|
203
|
-
|
|
203
|
+
// Unlocked private keys never outlive an explicit sign-out - only the signing-out account's where a host keeps others.
|
|
204
|
+
if (signOutHost) {
|
|
205
|
+
signOutHost.destroyKeys();
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
destroyUnlockedKeys();
|
|
209
|
+
}
|
|
204
210
|
// Trusted signer pins read from contacts don't outlive the session either.
|
|
205
211
|
clearPinnedSignerCache();
|
|
206
212
|
clearAppearanceCache();
|
|
@@ -209,6 +215,11 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
|
|
|
209
215
|
// Open compose windows save edits still waiting on their autosave debounce while the session is still
|
|
210
216
|
// valid - logout invalidates it. Bounded the same way as logout itself, and never rejects.
|
|
211
217
|
await flushComposeDrafts(LOGOUT_TIMEOUT_MS);
|
|
218
|
+
if (signOutHost) {
|
|
219
|
+
// The host destroys its account's indexes (spec §11) and ends its session; this page can't tell its accounts apart.
|
|
220
|
+
await signOutHost.finish().catch((err) => console.warn("The host's sign-out failed:", err));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
212
223
|
// The Tier 2 local index MUST be destroyed on explicit logout, the same as unlocked keys themselves
|
|
213
224
|
// (spec §11). Destroys every index on this device (not only mailboxes opened this page load) and is
|
|
214
225
|
// awaited before navigating - a navigation tears down the Worker mid-delete otherwise.
|
|
@@ -12,7 +12,9 @@ import { enrollKey, getKeyVault, VaultAlreadyInitializedError } from "../../../.
|
|
|
12
12
|
import { UnopenableKeysNotice, unlockErrorMessage } from "./UnlockPromptProvider.js";
|
|
13
13
|
import { FrameTakeover } from "../../navigation/frameContext.js";
|
|
14
14
|
import { MIN_PASSWORD_LENGTH, RecoveryFollowUpModal, UnlockModeToggle, startRecoveryUnlock, } from "./RecoveryCodeUnlock.js";
|
|
15
|
-
import { ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, getUnlockedKeys, unlockWithPassword, } from "../../../../lib/crypto/keySession.js";
|
|
15
|
+
import { ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, getUnlockedKeys, subscribeKeySession, unlockWithPassword, } from "../../../../lib/crypto/keySession.js";
|
|
16
|
+
import { ensureZeroAccessKey, provisionZeroAccessKey } from "../../../../lib/crypto/zeroAccessKey.js";
|
|
17
|
+
import { ensureOpenPgpKey } from "../../../../lib/crypto/openPgpKey.js";
|
|
16
18
|
import { buildAad, generateMasterKey, sealWithKey } from "../../../../lib/crypto/masterKey.js";
|
|
17
19
|
import { buildPasswordWrap, buildRecoveryWraps } from "../../../../lib/crypto/masterKeyWraps.js";
|
|
18
20
|
import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "../../../../lib/crypto/keys.js";
|
|
@@ -27,12 +29,15 @@ async function provisionEncryptionKey(mailboxUid, mailboxAddress, password) {
|
|
|
27
29
|
const { keyPair, csrPem } = await generateKeyPairWithCsr(mailboxAddress, "encrypt");
|
|
28
30
|
const privateKeyRaw = await exportPrivateKeyPkcs8(keyPair.privateKey);
|
|
29
31
|
const wrappedKeySealed = await sealWithKey(mk, privateKeyRaw, buildAad(mailboxUid, ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE));
|
|
30
|
-
await enrollKey(mailboxUid, {
|
|
32
|
+
const vault = await enrollKey(mailboxUid, {
|
|
31
33
|
useType: "encrypt",
|
|
32
34
|
csr: csrPem,
|
|
33
35
|
wrappedKey: { ciphertext: wrappedKeySealed.ciphertext, nonce: wrappedKeySealed.nonce, algorithm: "AES-256-GCM" },
|
|
34
36
|
masterKeyWraps: [passwordWrap, ...recoveryWraps],
|
|
35
37
|
});
|
|
38
|
+
// The zero-access key (restapi specs/search.md §3), under the same master key. Not fatal: setup is done once the
|
|
39
|
+
// encryption key is in, and a mailbox without a zero-access key gets one on its next unlock (`ensureZeroAccessKey()`).
|
|
40
|
+
await provisionZeroAccessKey(mailboxUid, mailboxAddress, mk, vault.masterKeyGeneration).catch(() => undefined);
|
|
36
41
|
return { recoveryCodes };
|
|
37
42
|
}
|
|
38
43
|
/**
|
|
@@ -90,6 +95,26 @@ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxK
|
|
|
90
95
|
cancelled = true;
|
|
91
96
|
};
|
|
92
97
|
}, [mailboxUid, canProvision]);
|
|
98
|
+
// A mailbox set up before zero-access encryption existed has no zero-access key, and one set up before OpenPGP was supported
|
|
99
|
+
// has no OpenPGP key (restapi specs/end-to-end_encryption.md *OpenPGP Interoperability*): give it each whenever its owner has
|
|
100
|
+
// it unlocked - now, or on a later unlock from anywhere (this gate, an unlock prompt, recovery, first-time setup). Best
|
|
101
|
+
// effort: a failure is retried on the next unlock.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!mailboxUid || !mailboxAddress || !canProvision) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// One after the other: both enrollments update the vault, which a concurrent write would refuse (409).
|
|
107
|
+
const ensure = () => void ensureZeroAccessKey(mailboxUid, mailboxAddress)
|
|
108
|
+
.catch(() => undefined)
|
|
109
|
+
.then(() => ensureOpenPgpKey(mailboxUid, mailboxAddress))
|
|
110
|
+
.catch(() => undefined);
|
|
111
|
+
ensure();
|
|
112
|
+
return subscribeKeySession((event) => {
|
|
113
|
+
if (event.mailboxUid === mailboxUid && event.state === "unlocked") {
|
|
114
|
+
ensure();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}, [mailboxUid, mailboxAddress, canProvision]);
|
|
93
118
|
async function handleUnlock(e) {
|
|
94
119
|
e.preventDefault();
|
|
95
120
|
setError(null);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Mailbox } from "../../../../lib/mail/mailApi.js";
|
|
2
|
+
/** How often the shared mailboxes are looked after again while some own mailbox stays unlocked. */
|
|
3
|
+
export declare const SHARED_MAILBOX_CHECK_INTERVAL_MS: number;
|
|
4
|
+
/** Forgets which refused rotations were shown (tests). */
|
|
5
|
+
export declare function resetSharedMailboxKeyManager(): void;
|
|
6
|
+
export interface SharedMailboxKeyManagerProps {
|
|
7
|
+
/** Every mailbox the user can open. */
|
|
8
|
+
mailboxes: Mailbox[];
|
|
9
|
+
userUid?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Looks after the zero-access keys of the user's shared mailboxes (`lib/crypto/sharedMailboxKeys.ts`) whenever a mailbox of their own is
|
|
13
|
+
* unlocked, and every `SHARED_MAILBOX_CHECK_INTERVAL_MS` after that: opens each mailbox shared with them through their member key wraps (no
|
|
14
|
+
* password), and - for a mailbox they own or manage - provisions, rotates and shares its key with members still waiting. Best effort and
|
|
15
|
+
* silent (a pass that fails is tried again on the next), except for a rotation the server refuses, shown once as a warning. Renders nothing; mounted by `MailShell`.
|
|
16
|
+
*/
|
|
17
|
+
export default function SharedMailboxKeyManager({ mailboxes, userUid }: SharedMailboxKeyManagerProps): null;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect, useRef } from "react";
|
|
6
|
+
import { getUnlockedKeys, personallyUnlockedKeys, subscribeKeySession } from "../../../../lib/crypto/keySession.js";
|
|
7
|
+
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
8
|
+
import { notify } from "../../notifications/store.js";
|
|
9
|
+
/** How often the shared mailboxes are looked after again while some own mailbox stays unlocked. */
|
|
10
|
+
export const SHARED_MAILBOX_CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
|
11
|
+
/** The shared mailbox key code (pkijs and friends), loaded only once an own mailbox is unlocked. */
|
|
12
|
+
let loaded;
|
|
13
|
+
const loadSharedKeys = () => (loaded ?? (loaded = import("../../../../lib/crypto/sharedMailboxKeys.js").catch((err) => {
|
|
14
|
+
loaded = undefined;
|
|
15
|
+
throw err;
|
|
16
|
+
})));
|
|
17
|
+
/** The refused rotations already shown, per mailbox (the reason): each is shown once a session, not on every pass that retries it. */
|
|
18
|
+
const shownRefusals = new Map();
|
|
19
|
+
/** Forgets which refused rotations were shown (tests). */
|
|
20
|
+
export function resetSharedMailboxKeyManager() {
|
|
21
|
+
shownRefusals.clear();
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Looks after the zero-access keys of the user's shared mailboxes (`lib/crypto/sharedMailboxKeys.ts`) whenever a mailbox of their own is
|
|
25
|
+
* unlocked, and every `SHARED_MAILBOX_CHECK_INTERVAL_MS` after that: opens each mailbox shared with them through their member key wraps (no
|
|
26
|
+
* password), and - for a mailbox they own or manage - provisions, rotates and shares its key with members still waiting. Best effort and
|
|
27
|
+
* silent (a pass that fails is tried again on the next), except for a rotation the server refuses, shown once as a warning. Renders nothing; mounted by `MailShell`.
|
|
28
|
+
*/
|
|
29
|
+
export default function SharedMailboxKeyManager({ mailboxes, userUid }) {
|
|
30
|
+
const client = useApiClient();
|
|
31
|
+
const latest = useRef({ mailboxes, userUid, client });
|
|
32
|
+
latest.current = { mailboxes, userUid, client };
|
|
33
|
+
const running = useRef(false);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
async function pass() {
|
|
36
|
+
if (running.current || personallyUnlockedKeys().length === 0) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
running.current = true;
|
|
40
|
+
try {
|
|
41
|
+
const { maintainSharedMailbox, unlockSharedMailbox } = await loadSharedKeys();
|
|
42
|
+
const { mailboxes: current, userUid: me, client: apiClient } = latest.current;
|
|
43
|
+
for (const mailbox of current) {
|
|
44
|
+
await look(mailbox, me, apiClient, unlockSharedMailbox, maintainSharedMailbox).catch(() => undefined);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Nothing to do until the next pass.
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
running.current = false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
void pass();
|
|
55
|
+
const unsubscribe = subscribeKeySession((event) => {
|
|
56
|
+
if (event.state === "unlocked" && !getUnlockedKeys(event.mailboxUid)?.shared) {
|
|
57
|
+
void pass();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
const interval = setInterval(() => void pass(), SHARED_MAILBOX_CHECK_INTERVAL_MS);
|
|
61
|
+
return () => {
|
|
62
|
+
unsubscribe();
|
|
63
|
+
clearInterval(interval);
|
|
64
|
+
};
|
|
65
|
+
}, []);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
async function look(mailbox, me, client, unlockSharedMailbox, maintainSharedMailbox) {
|
|
69
|
+
const owned = !!mailbox.ownerUserUid && mailbox.ownerUserUid === me;
|
|
70
|
+
if (!owned) {
|
|
71
|
+
// Opened, or - once open - opened again when a rotation left it without the active key.
|
|
72
|
+
await unlockSharedMailbox(mailbox.uid, client, true);
|
|
73
|
+
}
|
|
74
|
+
const unlocked = getUnlockedKeys(mailbox.uid);
|
|
75
|
+
// An owner looks after their mailbox with its own keys; a member, with full access, whether or not it is open yet (a mailbox
|
|
76
|
+
// without an owner may have no key to open).
|
|
77
|
+
if (owned ? unlocked && !unlocked.shared : true) {
|
|
78
|
+
const done = await maintainSharedMailbox(mailbox.uid, mailbox.primarySmtpAddress, client);
|
|
79
|
+
// The server kept the current key (its vault can't take the new key's member wraps): say so once, members keep reading.
|
|
80
|
+
if (done.rotationRefused && shownRefusals.get(mailbox.uid) !== done.rotationRefused) {
|
|
81
|
+
shownRefusals.set(mailbox.uid, done.rotationRefused);
|
|
82
|
+
notify({
|
|
83
|
+
kind: "warning",
|
|
84
|
+
title: `${mailbox.displayName || mailbox.primarySmtpAddress}: the zero-access key wasn't replaced`,
|
|
85
|
+
message: "A member has left, but the server refused a new key for this shared mailbox. The current key stays active.",
|
|
86
|
+
details: done.rotationRefused,
|
|
87
|
+
dedupeKey: `shared-rotation-refused:${mailbox.uid}`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
else if (!done.rotationRefused) {
|
|
91
|
+
shownRefusals.delete(mailbox.uid);
|
|
92
|
+
}
|
|
93
|
+
// A key this member just made (a mailbox without an owner) goes into their session now, not on the next pass.
|
|
94
|
+
if (!owned && (done.provisioned || done.rotated)) {
|
|
95
|
+
await unlockSharedMailbox(mailbox.uid, client, true);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -36,6 +36,9 @@ interface UnlockPromptContextValue {
|
|
|
36
36
|
* mailbox happens to resolve - see `KeyEnrollmentGate`'s `blocking` prop, which `MailShell` now sets to
|
|
37
37
|
* `false` for exactly this reason.
|
|
38
38
|
*/
|
|
39
|
+
/** Messages for a shared mailbox `openShared()` can't open. */
|
|
40
|
+
export declare const SHARED_ACCESS_PENDING_MESSAGE = "This mailbox is shared with you, and is waiting for another member to share its key with you. Try again shortly.";
|
|
41
|
+
export declare const SHARED_OWN_KEYS_LOCKED_MESSAGE = "Unlock your own mailbox first: it holds the key that opens the mailboxes shared with you.";
|
|
39
42
|
export declare function UnlockPromptProvider({ children }: {
|
|
40
43
|
children: React.ReactNode;
|
|
41
44
|
}): React.JSX.Element;
|
|
@@ -9,6 +9,7 @@ import Modal from "../../../../lib/components/overlays/Modal.js";
|
|
|
9
9
|
import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
10
10
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
11
11
|
import FormField from "../../../../lib/components/forms/FormField.js";
|
|
12
|
+
import { notify } from "../../notifications/store.js";
|
|
12
13
|
import { RECOVERY_CODE_ERROR, RecoveryFollowUpModal, UnlockModeToggle, startRecoveryUnlock, } from "./RecoveryCodeUnlock.js";
|
|
13
14
|
/** Shown instead of "Incorrect password" when the password was right but the mailbox's encryption key itself
|
|
14
15
|
* won't open (`UnopenableEncryptionKeyError`) - retrying can't fix that, so it must not read as a typo. */
|
|
@@ -44,6 +45,33 @@ const UnlockPromptContext = createContext(null);
|
|
|
44
45
|
* mailbox happens to resolve - see `KeyEnrollmentGate`'s `blocking` prop, which `MailShell` now sets to
|
|
45
46
|
* `false` for exactly this reason.
|
|
46
47
|
*/
|
|
48
|
+
/** Messages for a shared mailbox `openShared()` can't open. */
|
|
49
|
+
export const SHARED_ACCESS_PENDING_MESSAGE = "This mailbox is shared with you, and is waiting for another member to share its key with you. Try again shortly.";
|
|
50
|
+
export const SHARED_OWN_KEYS_LOCKED_MESSAGE = "Unlock your own mailbox first: it holds the key that opens the mailboxes shared with you.";
|
|
51
|
+
/**
|
|
52
|
+
* Opens `mailboxUid` through the user's member key wraps when it is a mailbox shared with them (`sharedMailboxKeys.ts`): its keys, or
|
|
53
|
+
* `undefined` when it isn't one of theirs (the password prompt applies). Rejects, after saying why, when it is shared with them but can't
|
|
54
|
+
* open yet. The shared key code is loaded only here.
|
|
55
|
+
*/
|
|
56
|
+
async function openShared(mailboxUid) {
|
|
57
|
+
let outcome;
|
|
58
|
+
try {
|
|
59
|
+
const { unlockSharedMailbox } = await import("../../../../lib/crypto/sharedMailboxKeys.js");
|
|
60
|
+
outcome = await unlockSharedMailbox(mailboxUid);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
if (outcome === "unlocked") {
|
|
66
|
+
return getUnlockedKeys(mailboxUid);
|
|
67
|
+
}
|
|
68
|
+
if (outcome === "pending" || outcome === "own_keys_locked") {
|
|
69
|
+
const message = outcome === "pending" ? SHARED_ACCESS_PENDING_MESSAGE : SHARED_OWN_KEYS_LOCKED_MESSAGE;
|
|
70
|
+
notify({ kind: "info", title: "Shared mailbox", message });
|
|
71
|
+
throw new Error(message);
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
47
75
|
export function UnlockPromptProvider({ children }) {
|
|
48
76
|
const [pending, setPendingState] = useState(null);
|
|
49
77
|
// Mirrors `pending` synchronously, so two requests issued in the same tick see each other.
|
|
@@ -66,6 +94,10 @@ export function UnlockPromptProvider({ children }) {
|
|
|
66
94
|
if (existing) {
|
|
67
95
|
return Promise.resolve(existing);
|
|
68
96
|
}
|
|
97
|
+
// A mailbox shared with the user opens through their member key wraps, not a password - and its password, if it has one, isn't theirs.
|
|
98
|
+
return openShared(mailboxUid).then((shared) => shared ?? promptForPassword(mailboxUid, mailboxKeys));
|
|
99
|
+
}, []);
|
|
100
|
+
const promptForPassword = useCallback((mailboxUid, mailboxKeys) => {
|
|
69
101
|
return new Promise((resolve, reject) => {
|
|
70
102
|
const current = pendingRef.current;
|
|
71
103
|
if (current?.mailboxUid === mailboxUid) {
|
|
@@ -49,7 +49,7 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
|
|
|
49
49
|
let stale = false;
|
|
50
50
|
void mapWithConcurrency(conversations.filter(conversationLooksEncrypted), 4, async (conversation) => {
|
|
51
51
|
if (!stale) {
|
|
52
|
-
await decryptForDisplay(conversation.latestMessageUid, conversation.mailboxUid ?? mailboxUid);
|
|
52
|
+
await decryptForDisplay(conversation.latestMessageUid, conversation.mailboxUid ?? mailboxUid, conversation.latestZeroAccess?.sealedSummary);
|
|
53
53
|
}
|
|
54
54
|
});
|
|
55
55
|
return () => {
|