@rapidmx/web-client 0.36.0 → 0.37.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/shared/calendar/calendarReminders.ts +35 -0
- package/apps/shared/calendar/sealedEvents.ts +42 -9
- package/apps/shared/calendar/useCalendarReminders.ts +28 -9
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +29 -1
- package/apps/shared/components/calendar/EventDetails.tsx +13 -2
- package/apps/shared/components/calendar/EventEditor.tsx +124 -17
- package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
- package/apps/shared/components/calendar/EventModal.tsx +19 -2
- package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
- package/apps/shared/components/calendar/eventForm.ts +2 -0
- package/apps/shared/components/contacts/ContactCard.tsx +13 -1
- package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
- package/apps/shared/components/contacts/ContactForm.tsx +56 -7
- package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
- package/apps/shared/components/contacts/contactCardData.ts +6 -3
- package/apps/shared/components/contacts/contactKeys.ts +3 -3
- package/apps/shared/components/layout/AppShell.tsx +34 -5
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +11 -4
- package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +37 -6
- package/apps/shared/components/mail/InviteCard.tsx +11 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +25 -14
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
- package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
- package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
- package/apps/shared/components/mail/invite/inviteStore.ts +11 -1
- package/apps/shared/components/mail/pinnedSigners.ts +14 -0
- package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
- package/apps/shared/components/settings/KeyExportSetting.tsx +4 -1
- package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
- package/apps/shared/components/settings/ZeroAccessSetting.tsx +13 -2
- package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
- package/apps/shared/mail/outbox/sendJob.ts +349 -50
- package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
- package/apps/shared/search/LocalIndexLifecycle.tsx +62 -33
- package/apps/shared/search/localIndexBuilder.ts +35 -24
- package/apps/shared/search/localIndexRpcClient.ts +91 -11
- package/apps/shared/search/localIndexSchema.ts +18 -2
- package/apps/shared/search/localIndexTransport.ts +6 -3
- package/apps/shared/search/localIndexWorker.ts +35 -18
- package/apps/shared/search/searchTier2.ts +5 -0
- package/apps/shared/search/zeroAccessIndexSync.ts +232 -40
- package/apps/shared/search/zeroAccessSyncState.ts +70 -0
- package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
- package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
- package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
- package/apps/www/_shell.tsx +2 -0
- package/apps/www/calendar/index.tsx +49 -5
- package/apps/www/contacts/index.tsx +67 -11
- package/apps/www/index.tsx +10 -1
- package/apps/www/settings/encryption/index.tsx +16 -1
- package/apps/www/settings/privacy/index.tsx +87 -38
- package/apps/www/tasks/index.tsx +24 -6
- package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
- package/dist/apps/shared/calendar/calendarReminders.js +26 -0
- package/dist/apps/shared/calendar/sealedEvents.d.ts +3 -2
- package/dist/apps/shared/calendar/sealedEvents.js +40 -9
- package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
- package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +9 -2
- package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
- package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
- package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
- package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
- package/dist/apps/shared/components/calendar/EventModal.js +15 -2
- package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
- package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
- package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
- package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
- package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
- package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
- package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
- package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
- package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
- package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
- package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
- package/dist/apps/shared/components/layout/AppShell.js +17 -6
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -4
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +3 -1
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +30 -4
- package/dist/apps/shared/components/mail/InviteCard.js +11 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +15 -11
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
- package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.js +11 -1
- package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
- package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
- package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
- package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
- package/dist/apps/shared/components/settings/KeyExportSetting.js +3 -1
- package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
- package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +2 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.js +12 -3
- package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
- package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
- package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
- package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
- package/dist/apps/shared/search/LocalIndexLifecycle.js +55 -33
- package/dist/apps/shared/search/localIndexBuilder.d.ts +4 -1
- package/dist/apps/shared/search/localIndexBuilder.js +31 -20
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
- package/dist/apps/shared/search/localIndexRpcClient.js +126 -10
- package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
- package/dist/apps/shared/search/localIndexSchema.js +16 -2
- package/dist/apps/shared/search/localIndexTransport.d.ts +6 -3
- package/dist/apps/shared/search/localIndexWorker.d.ts +9 -0
- package/dist/apps/shared/search/localIndexWorker.js +22 -15
- package/dist/apps/shared/search/searchTier2.js +5 -0
- package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +37 -6
- package/dist/apps/shared/search/zeroAccessIndexSync.js +199 -37
- package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
- package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
- package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
- package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
- package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
- package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
- package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
- package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
- package/dist/apps/www/_shell.d.ts +1 -1
- package/dist/apps/www/_shell.js +2 -2
- package/dist/apps/www/calendar/index.js +47 -5
- package/dist/apps/www/contacts/index.js +64 -11
- package/dist/apps/www/index.js +10 -1
- package/dist/apps/www/settings/encryption/index.js +14 -4
- package/dist/apps/www/settings/privacy/index.js +46 -5
- package/dist/apps/www/tasks/index.js +21 -7
- package/dist/lib/admin/domainsApi.d.ts +1 -1
- package/dist/lib/calendar/calendarApi.d.ts +46 -6
- package/dist/lib/calendar/calendarApi.js +110 -17
- package/dist/lib/calendar/calendarMutations.d.ts +20 -7
- package/dist/lib/calendar/calendarMutations.js +73 -14
- package/dist/lib/calendar/inviteApi.d.ts +17 -3
- package/dist/lib/calendar/inviteApi.js +61 -4
- package/dist/lib/calendar/protectedEvents.d.ts +31 -0
- package/dist/lib/calendar/protectedEvents.js +113 -0
- package/dist/lib/calendar/recurrence.d.ts +2 -0
- package/dist/lib/calendar/recurrence.js +16 -0
- package/dist/lib/calendar/sealedInvites.d.ts +6 -8
- package/dist/lib/calendar/sealedInvites.js +43 -6
- package/dist/lib/contacts/contactsApi.d.ts +29 -3
- package/dist/lib/contacts/contactsApi.js +23 -7
- package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
- package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
- package/dist/lib/crypto/composeSecurity.d.ts +11 -3
- package/dist/lib/crypto/composeSecurity.js +13 -5
- package/dist/lib/crypto/keyExport.js +2 -2
- package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
- package/dist/lib/crypto/keyPairMatch.js +65 -0
- package/dist/lib/crypto/keySession.d.ts +19 -2
- package/dist/lib/crypto/keySession.js +89 -13
- package/dist/lib/crypto/keyvaultApi.d.ts +41 -5
- package/dist/lib/crypto/keyvaultApi.js +13 -3
- package/dist/lib/crypto/meetingKeys.d.ts +25 -0
- package/dist/lib/crypto/meetingKeys.js +87 -0
- package/dist/lib/crypto/messageSecurity.d.ts +26 -2
- package/dist/lib/crypto/messageSecurity.js +122 -10
- package/dist/lib/crypto/openPgpKey.d.ts +39 -0
- package/dist/lib/crypto/openPgpKey.js +89 -0
- package/dist/lib/crypto/openpgp.d.ts +47 -0
- package/dist/lib/crypto/openpgp.js +88 -0
- package/dist/lib/crypto/pgpMessage.d.ts +51 -0
- package/dist/lib/crypto/pgpMessage.js +271 -0
- package/dist/lib/crypto/sharedMailboxKeys.d.ts +15 -3
- package/dist/lib/crypto/sharedMailboxKeys.js +102 -29
- package/dist/lib/crypto/smimeMessage.d.ts +21 -0
- package/dist/lib/crypto/smimeMessage.js +11 -7
- package/dist/lib/crypto/zeroAccessKey.js +5 -0
- package/dist/lib/mail/mailApi.d.ts +33 -0
- package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
- package/dist/lib/mail/mailboxImportApi.js +15 -0
- package/dist/lib/search/zeroAccessIndexApi.d.ts +4 -0
- package/dist/lib/tasks/tasksApi.d.ts +11 -1
- package/dist/lib/tasks/tasksApi.js +10 -4
- package/dist/lib/util/clientPlatform.d.ts +14 -0
- package/dist/lib/util/clientPlatform.js +26 -0
- package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
- package/dist/lib/videoconf/protectedMeetings.js +181 -0
- package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
- package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
- package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
- package/dist/lib/zeroAccess/sealedMoves.js +63 -0
- package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
- package/dist/lib/zeroAccess/sealedRecords.js +136 -0
- package/lib/admin/domainsApi.ts +1 -1
- package/lib/calendar/calendarApi.ts +140 -17
- package/lib/calendar/calendarMutations.ts +79 -12
- package/lib/calendar/inviteApi.ts +75 -5
- package/lib/calendar/protectedEvents.ts +143 -0
- package/lib/calendar/recurrence.ts +17 -0
- package/lib/calendar/sealedInvites.ts +47 -7
- package/lib/contacts/contactsApi.ts +43 -17
- package/lib/contacts/useContactPhotoSrc.ts +103 -13
- package/lib/crypto/composeSecurity.ts +20 -5
- package/lib/crypto/keyExport.ts +2 -2
- package/lib/crypto/keyPairMatch.ts +69 -0
- package/lib/crypto/keySession.ts +102 -12
- package/lib/crypto/keyvaultApi.ts +49 -7
- package/lib/crypto/meetingKeys.ts +118 -0
- package/lib/crypto/messageSecurity.ts +174 -13
- package/lib/crypto/openPgpKey.ts +118 -0
- package/lib/crypto/openpgp.ts +115 -0
- package/lib/crypto/pgpMessage.ts +336 -0
- package/lib/crypto/sharedMailboxKeys.ts +106 -28
- package/lib/crypto/smimeMessage.ts +11 -7
- package/lib/crypto/zeroAccessKey.ts +5 -0
- package/lib/mail/mailApi.ts +27 -0
- package/lib/mail/mailboxImportApi.ts +24 -0
- package/lib/search/zeroAccessIndexApi.ts +4 -0
- package/lib/tasks/tasksApi.ts +19 -5
- package/lib/util/clientPlatform.ts +50 -0
- package/lib/videoconf/protectedMeetings.ts +221 -0
- package/lib/videoconf/videoMeetingsApi.ts +15 -4
- package/lib/zeroAccess/sealedMoves.ts +79 -0
- package/lib/zeroAccess/sealedRecords.ts +153 -0
- package/package.json +12 -3
|
@@ -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);
|
|
@@ -11,13 +11,14 @@ import { addDays } from "date-fns";
|
|
|
11
11
|
import { listCalendarEvents } from "../../../../lib/calendar/calendarApi.js";
|
|
12
12
|
import { openSealedEvents } from "../../calendar/sealedEvents.js";
|
|
13
13
|
import { expandAllOccurrences } from "../../../../lib/calendar/recurrence.js";
|
|
14
|
-
import { createContact, listContactsInFolders } from "../../../../lib/contacts/contactsApi.js";
|
|
14
|
+
import { CONTACT_MATCH_FIELDS, createContact, listContactsInFolders } from "../../../../lib/contacts/contactsApi.js";
|
|
15
15
|
import { parseVCards } from "../../../../lib/contacts/vcard.js";
|
|
16
16
|
import { attachmentContentUrl, getMessage, listAttachments, listFolders, } from "../../../../lib/mail/mailApi.js";
|
|
17
17
|
import { search } from "../../../../lib/search/searchApi.js";
|
|
18
18
|
import { ApiRequestError } from "../../../../lib/util/api.js";
|
|
19
19
|
import { ownMailboxes, primaryMailbox } from "../../mail/primaryMailbox.js";
|
|
20
20
|
import { cardForAddress, contactInputFor, contactInputFromCard, findContactByAddress, isVCardAttachment, normalizeAddress, ownContactCard, withoutDuplicates, } from "./participantDetails.js";
|
|
21
|
+
import { openSealedRecord, openSealedRecords } from "../../../../lib/zeroAccess/sealedRecords.js";
|
|
21
22
|
/** How many recent messages and upcoming events the card lists. */
|
|
22
23
|
export const CARD_LIST_SIZE = 5;
|
|
23
24
|
/** How far ahead "upcoming" looks, in days. */
|
|
@@ -40,7 +41,9 @@ export async function loadFolders(mailboxes, client) {
|
|
|
40
41
|
/** The contact the address belongs to in `folders`' contacts folders, if any. */
|
|
41
42
|
export async function findContact(folders, address, client) {
|
|
42
43
|
const uids = folders.filter((folder) => folder.type === "contacts").map((folder) => folder.uid);
|
|
43
|
-
|
|
44
|
+
const found = findContactByAddress(await listContactsInFolders(uids, client), address);
|
|
45
|
+
// The card shows the one contact in full.
|
|
46
|
+
return found && (await openSealedRecord(found));
|
|
44
47
|
}
|
|
45
48
|
/** The last `CARD_LIST_SIZE` messages to or from `address` in `mailboxes`, newest first. */
|
|
46
49
|
export async function loadRecentMessages(mailboxes, address, client) {
|
|
@@ -139,7 +142,7 @@ export async function buildOwnCardFile(mailbox, mailboxes, userUid, client) {
|
|
|
139
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.
|
|
140
143
|
*/
|
|
141
144
|
export async function addCardsToAddressBook(cards, scope, client) {
|
|
142
|
-
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));
|
|
143
146
|
let added = 0;
|
|
144
147
|
for (const card of fresh) {
|
|
145
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.
|
|
@@ -14,6 +14,7 @@ import { FrameTakeover } from "../../navigation/frameContext.js";
|
|
|
14
14
|
import { MIN_PASSWORD_LENGTH, RecoveryFollowUpModal, UnlockModeToggle, startRecoveryUnlock, } from "./RecoveryCodeUnlock.js";
|
|
15
15
|
import { ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, getUnlockedKeys, subscribeKeySession, unlockWithPassword, } from "../../../../lib/crypto/keySession.js";
|
|
16
16
|
import { ensureZeroAccessKey, provisionZeroAccessKey } from "../../../../lib/crypto/zeroAccessKey.js";
|
|
17
|
+
import { ensureOpenPgpKey } from "../../../../lib/crypto/openPgpKey.js";
|
|
17
18
|
import { buildAad, generateMasterKey, sealWithKey } from "../../../../lib/crypto/masterKey.js";
|
|
18
19
|
import { buildPasswordWrap, buildRecoveryWraps } from "../../../../lib/crypto/masterKeyWraps.js";
|
|
19
20
|
import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "../../../../lib/crypto/keys.js";
|
|
@@ -94,14 +95,19 @@ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxK
|
|
|
94
95
|
cancelled = true;
|
|
95
96
|
};
|
|
96
97
|
}, [mailboxUid, canProvision]);
|
|
97
|
-
// A mailbox set up before zero-access encryption existed has no zero-access key
|
|
98
|
-
//
|
|
99
|
-
//
|
|
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.
|
|
100
102
|
useEffect(() => {
|
|
101
103
|
if (!mailboxUid || !mailboxAddress || !canProvision) {
|
|
102
104
|
return;
|
|
103
105
|
}
|
|
104
|
-
|
|
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);
|
|
105
111
|
ensure();
|
|
106
112
|
return subscribeKeySession((event) => {
|
|
107
113
|
if (event.mailboxUid === mailboxUid && event.state === "unlocked") {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Mailbox } from "../../../../lib/mail/mailApi.js";
|
|
2
2
|
/** How often the shared mailboxes are looked after again while some own mailbox stays unlocked. */
|
|
3
3
|
export declare const SHARED_MAILBOX_CHECK_INTERVAL_MS: number;
|
|
4
|
+
/** Forgets which refused rotations were shown (tests). */
|
|
5
|
+
export declare function resetSharedMailboxKeyManager(): void;
|
|
4
6
|
export interface SharedMailboxKeyManagerProps {
|
|
5
7
|
/** Every mailbox the user can open. */
|
|
6
8
|
mailboxes: Mailbox[];
|
|
@@ -10,6 +12,6 @@ export interface SharedMailboxKeyManagerProps {
|
|
|
10
12
|
* Looks after the zero-access keys of the user's shared mailboxes (`lib/crypto/sharedMailboxKeys.ts`) whenever a mailbox of their own is
|
|
11
13
|
* unlocked, and every `SHARED_MAILBOX_CHECK_INTERVAL_MS` after that: opens each mailbox shared with them through their member key wraps (no
|
|
12
14
|
* password), and - for a mailbox they own or manage - provisions, rotates and shares its key with members still waiting. Best effort and
|
|
13
|
-
* silent
|
|
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`.
|
|
14
16
|
*/
|
|
15
17
|
export default function SharedMailboxKeyManager({ mailboxes, userUid }: SharedMailboxKeyManagerProps): null;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { useEffect, useRef } from "react";
|
|
6
6
|
import { getUnlockedKeys, personallyUnlockedKeys, subscribeKeySession } from "../../../../lib/crypto/keySession.js";
|
|
7
7
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
8
|
+
import { notify } from "../../notifications/store.js";
|
|
8
9
|
/** How often the shared mailboxes are looked after again while some own mailbox stays unlocked. */
|
|
9
10
|
export const SHARED_MAILBOX_CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
|
10
11
|
/** The shared mailbox key code (pkijs and friends), loaded only once an own mailbox is unlocked. */
|
|
@@ -13,11 +14,17 @@ const loadSharedKeys = () => (loaded ?? (loaded = import("../../../../lib/crypto
|
|
|
13
14
|
loaded = undefined;
|
|
14
15
|
throw err;
|
|
15
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
|
+
}
|
|
16
23
|
/**
|
|
17
24
|
* Looks after the zero-access keys of the user's shared mailboxes (`lib/crypto/sharedMailboxKeys.ts`) whenever a mailbox of their own is
|
|
18
25
|
* unlocked, and every `SHARED_MAILBOX_CHECK_INTERVAL_MS` after that: opens each mailbox shared with them through their member key wraps (no
|
|
19
26
|
* password), and - for a mailbox they own or manage - provisions, rotates and shares its key with members still waiting. Best effort and
|
|
20
|
-
* silent
|
|
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`.
|
|
21
28
|
*/
|
|
22
29
|
export default function SharedMailboxKeyManager({ mailboxes, userUid }) {
|
|
23
30
|
const client = useApiClient();
|
|
@@ -60,13 +67,32 @@ export default function SharedMailboxKeyManager({ mailboxes, userUid }) {
|
|
|
60
67
|
}
|
|
61
68
|
async function look(mailbox, me, client, unlockSharedMailbox, maintainSharedMailbox) {
|
|
62
69
|
const owned = !!mailbox.ownerUserUid && mailbox.ownerUserUid === me;
|
|
63
|
-
if (!owned
|
|
64
|
-
|
|
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);
|
|
65
73
|
}
|
|
66
74
|
const unlocked = getUnlockedKeys(mailbox.uid);
|
|
67
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
|
|
68
76
|
// without an owner may have no key to open).
|
|
69
77
|
if (owned ? unlocked && !unlocked.shared : true) {
|
|
70
|
-
await maintainSharedMailbox(mailbox.uid, mailbox.primarySmtpAddress, client);
|
|
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
|
+
}
|
|
71
97
|
}
|
|
72
98
|
}
|
|
@@ -7,6 +7,7 @@ import React, { useId, useState } from "react";
|
|
|
7
7
|
import { HiOutlineCalendarDays, HiOutlineCheck } from "react-icons/hi2";
|
|
8
8
|
import { acceptProposal, proposeNewTime, removeMessageInvite, respondToMessageInvite, } from "../../../../lib/calendar/inviteApi.js";
|
|
9
9
|
import { guestPermissionsOf } from "../../../../lib/calendar/calendarApi.js";
|
|
10
|
+
import { openSealedEvent } from "../../calendar/sealedEvents.js";
|
|
10
11
|
import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
|
|
11
12
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
12
13
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
@@ -134,7 +135,7 @@ export default function InviteCard({ messageUid, headingLevel = 2 }) {
|
|
|
134
135
|
return (_jsxs("section", { "aria-labelledby": headingId, className: "rounded-sm border border-border bg-surface-alt px-3 py-3 flex flex-col gap-2 text-sm text-text", children: [_jsxs(Heading, { id: headingId, className: "flex items-center gap-2 text-sm font-semibold", children: [_jsx(HiOutlineCalendarDays, { size: 16, "aria-hidden": "true", className: "shrink-0" }), changeRequest ? CHANGE_REQUEST_HEADING : (HEADINGS[method] ?? "Calendar event")] }), isReply && (_jsxs("p", { className: "break-words", children: [senderNode, " ", (sender?.responseStatus && REPLY_VERBS[sender.responseStatus]) ?? "responded", "."] })), isCounter && !changeRequest && (_jsxs("p", { className: "break-words", children: [senderNode, " proposed a new time", when ? _jsxs(_Fragment, { children: [": ", when] }) : "."] })), changeRequest && (_jsx("p", { className: "break-words", children: changeRequest.applied ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "font-semibold", children: senderName }), "\u2019s change was applied."] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "font-semibold", children: senderName }), " requested a change."] })) })), _jsx("p", { className: "font-medium break-words", children: title }), !isReply && (_jsxs("dl", { className: "grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1", children: [when && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "When" }), _jsxs("dd", { className: "break-words", children: [when, invite.recurring && _jsx("span", { className: "text-text-muted", children: " (Repeats)" })] })] })), invite.location && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Where" }), _jsx("dd", { className: "break-words", children: invite.location })] })), hasDescription && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Description" }), _jsx("dd", { className: "break-words", children: _jsx(EventDescriptionView, { html: invite.descriptionHtml, text: invite.description }) })] })), invite.organizer && !isCounter && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Organizer" }), _jsx("dd", { className: "break-words", children: _jsx(ParticipantLink, { participant: invite.organizer, children: formatMailAddress(invite.organizer) }) })] })), attendees && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Attendees" }), _jsx("dd", { className: "break-words", children: attendees })] })), visibility && !isCounter && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Visibility" }), _jsx("dd", { className: "break-words", children: visibility })] }))] })), isReply && when && _jsx("p", { className: "text-text-muted break-words", children: when }), conflicts && (_jsxs("p", { className: "text-danger break-words", children: [_jsx("span", { className: "font-medium", children: "Conflicts with:" }), " ", conflicts] })), invite.guestPermissions && !isReply && !isCounter && method !== "CANCEL" && (_jsx("p", { className: "text-xs text-text-muted break-words", children: describeGuestPermissions(permissions) })), invite.isOrganizer && !isReply && !isCounter && _jsx("p", { className: "text-text-muted", children: "You are the organizer of this meeting." }), method === "CANCEL" && _jsx("p", { className: "font-medium text-danger", children: "This meeting was canceled." }), invite.response && !invite.isOrganizer && !isReply && !isCounter && (_jsx("p", { role: "status", className: `font-medium ${ANSWER_CLASS[invite.response]}`, children: ANSWER_TEXT[invite.response] })), isCounter && invite.response === "accepted" && !changeRequest?.applied && (_jsx("p", { role: "status", className: "font-medium text-success", children: "You accepted the proposed time." })), proposed && (_jsx("p", { role: "status", className: "font-medium text-text", children: "Your proposed time was sent to the organizer." })), requested && (_jsx("p", { role: "status", className: "font-medium text-text", children: "Your change was sent to the organizer." })), invite.outdated && _jsx("p", { className: "text-xs text-text-muted", children: "A newer version of this meeting is already on your calendar." }), hasActions && (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [showResponses && (_jsx("div", { role: "group", "aria-label": "Respond to this meeting", "aria-busy": busy || undefined, className: "flex flex-wrap gap-2", children: RESPONSES.map(({ response, label, failure }) => {
|
|
135
136
|
const current = invite.response === response;
|
|
136
137
|
return (_jsxs(Button, { type: "button", variant: "secondary", className: ["!w-auto", current ? "!bg-primary/10 !border-primary disabled:!opacity-100" : ""].join(" "), "aria-pressed": current, "aria-busy": pending === response || undefined, loading: pending === response, disabled: busy || current, onClick: () => void run(response, failure, () => respondToMessageInvite(messageUid, response, client)), children: [current && _jsx(HiOutlineCheck, { size: 14, "aria-hidden": "true" }), label] }, response));
|
|
137
|
-
}) })), invite.canPropose && !proposing && (_jsx(Button, { type: "button", variant: "text", disabled: busy, onClick: () => setProposing(true), children: "Propose new time" })), canRequest && !requesting && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: busy, onClick: () => setRequesting(true), children: invite.canRequestChange ? "Request a change" : "Add guests" })), invite.canAcceptProposal && invite.response !== "accepted" && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "acceptProposal" || undefined, loading: pending === "acceptProposal", disabled: busy, onClick: () => void run("acceptProposal", "Couldn't accept the proposed time", () => acceptProposal(messageUid, client)), children: "Accept proposal" })), invite.canAdd && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "add" || undefined, loading: pending === "add", disabled: busy,
|
|
138
|
+
}) })), invite.canPropose && !proposing && (_jsx(Button, { type: "button", variant: "text", disabled: busy, onClick: () => setProposing(true), children: "Propose new time" })), canRequest && !requesting && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: busy, onClick: () => setRequesting(true), children: invite.canRequestChange ? "Request a change" : "Add guests" })), invite.canAcceptProposal && invite.response !== "accepted" && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "acceptProposal" || undefined, loading: pending === "acceptProposal", disabled: busy, onClick: () => void run("acceptProposal", "Couldn't accept the proposed time", () => acceptProposal(messageUid, client, invite.calendarEventUid, openSealedEvent)), children: "Accept proposal" })), invite.canAdd && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "add" || undefined, loading: pending === "add", disabled: busy,
|
|
138
139
|
// A published event has no organizer to answer, so adding it is accepting it: the server sends no reply.
|
|
139
140
|
onClick: () => void run("add", "Couldn't add this event to your calendar", () => respondToMessageInvite(messageUid, "accepted", client)), children: "Add to calendar" })), invite.canRemove && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "remove" || undefined, loading: pending === "remove", disabled: busy, onClick: () => void run("remove", "Couldn't remove this meeting from your calendar", () => removeMessageInvite(messageUid, client)), children: "Remove from calendar" })), invite.onCalendar && invite.calendarEventUid && (_jsx("a", { href: calendarHref(invite), className: "text-sm font-medium text-primary-dark hover:underline", children: "Open in Calendar" }))] })), requesting && (_jsx("div", { className: "rounded-md bg-surface p-3", children: _jsx(RequestChangeForm, { subject: {
|
|
140
141
|
uid: invite.calendarEventUid,
|
|
@@ -147,6 +148,15 @@ export default function InviteCard({ messageUid, headingLevel = 2 }) {
|
|
|
147
148
|
description: invite.description,
|
|
148
149
|
descriptionHtml: invite.descriptionHtml,
|
|
149
150
|
guestAddresses: invite.attendees.map((attendee) => attendee.address),
|
|
151
|
+
// The invitation's own content: what a protected copy of the event is named by.
|
|
152
|
+
invitationContent: invite.summary
|
|
153
|
+
? {
|
|
154
|
+
title: invite.summary,
|
|
155
|
+
location: invite.location ?? undefined,
|
|
156
|
+
description: invite.description ?? undefined,
|
|
157
|
+
descriptionHtml: invite.descriptionHtml ?? undefined,
|
|
158
|
+
}
|
|
159
|
+
: undefined,
|
|
150
160
|
}, canChange: !!invite.canRequestChange, canInvite: !!invite.canRequestInvite, onSent: () => {
|
|
151
161
|
setRequesting(false);
|
|
152
162
|
setRequested(true);
|