@rapidmx/web-client 0.36.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/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
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useState } from "react";
|
|
6
|
+
import { type EncryptionPreference, type EndToEndFormat, findActivePublicKey } from "../../../../lib/crypto/keyvaultApi.js";
|
|
7
|
+
import { MIN_EXPORT_PASSPHRASE_LENGTH } from "../../../../lib/crypto/keyExport.js";
|
|
8
|
+
import { exportOpenPgpSecretKey } from "../../../../lib/crypto/openPgpKey.js";
|
|
9
|
+
import { getMailbox, type Mailbox, updateMailbox } from "../../../../lib/mail/mailApi.js";
|
|
10
|
+
import { ApiRequestError } from "../../../../lib/util/api.js";
|
|
11
|
+
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
12
|
+
import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
13
|
+
import Button from "../../../../lib/components/buttons/Button.js";
|
|
14
|
+
import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
15
|
+
import PolicyLockedNotice from "./PolicyLockedNotice.js";
|
|
16
|
+
import { useSettingsShell } from "./layout/SettingsShell.js";
|
|
17
|
+
|
|
18
|
+
export interface OpenPgpKeySettingProps {
|
|
19
|
+
mailbox: Mailbox;
|
|
20
|
+
/** The caller owns the mailbox and isn't impersonating: only the owner may export its secret key. */
|
|
21
|
+
canManage: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const INPUT_CLASS = "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface";
|
|
25
|
+
|
|
26
|
+
/** How long a download's object URL is kept, since some browsers (WebKit, older Firefox) still read it after the click returns. */
|
|
27
|
+
const DOWNLOAD_URL_LIFETIME_MS = 60_000;
|
|
28
|
+
|
|
29
|
+
/** Hands `text` to the browser as a file download. */
|
|
30
|
+
function saveText(text: string, filename: string): void {
|
|
31
|
+
const url = URL.createObjectURL(new Blob([text], { type: "application/pgp-keys" }));
|
|
32
|
+
const link = document.createElement("a");
|
|
33
|
+
link.href = url;
|
|
34
|
+
link.download = filename;
|
|
35
|
+
document.body.appendChild(link);
|
|
36
|
+
link.click();
|
|
37
|
+
link.remove();
|
|
38
|
+
setTimeout(() => URL.revokeObjectURL(url), DOWNLOAD_URL_LIFETIME_MS);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `preference` advertising `format` - S/MIME is the default, so it is advertised by saying nothing. */
|
|
42
|
+
function withPreferredFormat(preference: EncryptionPreference | undefined, format: EndToEndFormat): EncryptionPreference {
|
|
43
|
+
const { preferFormat: _previous, ...rest } = preference ?? { preferEncrypt: "nopreference" as const };
|
|
44
|
+
return format === "openpgp" ? { ...rest, preferFormat: "openpgp" } : rest;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A fingerprint in groups of four, for reading aloud. */
|
|
48
|
+
function groupFingerprint(fingerprint: string): string {
|
|
49
|
+
return fingerprint.toUpperCase().replace(/(.{4})/g, "$1 ").trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The mailbox's OpenPGP key (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*): what it is for, its
|
|
54
|
+
* fingerprint, a download of its public key (to give a correspondent by hand), and an export of its secret key, protected by a
|
|
55
|
+
* passphrase, for reading this mailbox's OpenPGP mail in another app. Shows nothing while the mailbox has no OpenPGP key.
|
|
56
|
+
*/
|
|
57
|
+
export default function OpenPgpKeySetting({ mailbox, canManage }: OpenPgpKeySettingProps) {
|
|
58
|
+
const client = useApiClient();
|
|
59
|
+
const { patchMailbox } = useSettingsShell();
|
|
60
|
+
const [open, setOpen] = useState(false);
|
|
61
|
+
const [password, setPassword] = useState("");
|
|
62
|
+
const [passphrase, setPassphrase] = useState("");
|
|
63
|
+
const [confirmPassphrase, setConfirmPassphrase] = useState("");
|
|
64
|
+
const [exporting, setExporting] = useState(false);
|
|
65
|
+
const [error, setError] = useState<string | null>(null);
|
|
66
|
+
const [format, setFormat] = useState<EndToEndFormat>(mailbox.encryptPreference?.preferFormat === "openpgp" ? "openpgp" : "smime");
|
|
67
|
+
const [savingFormat, setSavingFormat] = useState(false);
|
|
68
|
+
const key = findActivePublicKey(mailbox.keys ?? [], "encrypt", "openpgp");
|
|
69
|
+
if (!key) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const address = mailbox.primarySmtpAddress;
|
|
73
|
+
|
|
74
|
+
function reset() {
|
|
75
|
+
setOpen(false);
|
|
76
|
+
setPassword("");
|
|
77
|
+
setPassphrase("");
|
|
78
|
+
setConfirmPassphrase("");
|
|
79
|
+
setError(null);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Advertises `next` as the format this mailbox would rather receive (restapi *Choosing the format*), to RapidMX servers through
|
|
83
|
+
* key discovery and the `RapidMX-Key` header. */
|
|
84
|
+
async function saveFormat(next: EndToEndFormat) {
|
|
85
|
+
const previous = format;
|
|
86
|
+
setFormat(next);
|
|
87
|
+
setSavingFormat(true);
|
|
88
|
+
try {
|
|
89
|
+
const write = async (current: Pick<Mailbox, "version" | "encryptPreference">): Promise<Pick<Mailbox, "version" | "encryptPreference">> => {
|
|
90
|
+
const encryptPreference: EncryptionPreference = withPreferredFormat(current.encryptPreference, next);
|
|
91
|
+
const saved = await updateMailbox({ uid: mailbox.uid, version: current.version, encryptPreference }, client);
|
|
92
|
+
return { version: saved.version, encryptPreference: saved.encryptPreference ?? encryptPreference };
|
|
93
|
+
};
|
|
94
|
+
let saved: Pick<Mailbox, "version" | "encryptPreference">;
|
|
95
|
+
try {
|
|
96
|
+
saved = await write(mailbox);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
// Enrolling a key moves the mailbox's version on: read it again and retry once.
|
|
99
|
+
if (!(err instanceof ApiRequestError && err.status === 409)) {
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
saved = await write(await getMailbox(mailbox.uid, {}, client));
|
|
103
|
+
}
|
|
104
|
+
// Kept in the settings shell's copy of the mailbox, which this page is mounted from again on a switch back to it.
|
|
105
|
+
patchMailbox?.(mailbox.uid, saved);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
setFormat(previous);
|
|
108
|
+
notifyApiError(err, "Couldn't save your preferred encryption format");
|
|
109
|
+
} finally {
|
|
110
|
+
setSavingFormat(false);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function downloadPublicKey() {
|
|
115
|
+
const { armorPublicKey, readPublicKey } = await import("../../../../lib/crypto/openpgp.js");
|
|
116
|
+
const certificate = await readPublicKey(key!.publicKey);
|
|
117
|
+
if (certificate) {
|
|
118
|
+
saveText(armorPublicKey(certificate), `${address}-openpgp-public.asc`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function handleExport(e: React.FormEvent) {
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
if (passphrase.length < MIN_EXPORT_PASSPHRASE_LENGTH) {
|
|
125
|
+
setError(`The file's passphrase must be at least ${MIN_EXPORT_PASSPHRASE_LENGTH} characters.`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (passphrase !== confirmPassphrase) {
|
|
129
|
+
setError("The passphrases don't match.");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
setExporting(true);
|
|
133
|
+
setError(null);
|
|
134
|
+
try {
|
|
135
|
+
const { armored } = await exportOpenPgpSecretKey(mailbox.uid, password, passphrase, client);
|
|
136
|
+
saveText(armored, `${address}-openpgp-secret.asc`);
|
|
137
|
+
reset();
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (err instanceof ApiRequestError) {
|
|
140
|
+
notifyApiError(err, "Couldn't export your OpenPGP key");
|
|
141
|
+
} else {
|
|
142
|
+
setError("Incorrect password.");
|
|
143
|
+
}
|
|
144
|
+
} finally {
|
|
145
|
+
setExporting(false);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div>
|
|
151
|
+
<h2 className="text-sm font-semibold mb-2">OpenPGP key</h2>
|
|
152
|
+
<p className="text-sm text-text-muted mb-2">
|
|
153
|
+
People who use Proton Mail, Thunderbird or another OpenPGP app can send you encrypted mail with this key, and you can
|
|
154
|
+
send it to them. RapidMX publishes it for you; you can also give it to someone yourself. RapidMX uses S/MIME whenever a
|
|
155
|
+
recipient can receive it.
|
|
156
|
+
</p>
|
|
157
|
+
<p className="text-sm mb-2">
|
|
158
|
+
Fingerprint: <code className="text-xs break-all">{groupFingerprint(key.fingerprint)}</code>
|
|
159
|
+
</p>
|
|
160
|
+
<fieldset className="mb-3" disabled={!canManage || savingFormat}>
|
|
161
|
+
<legend className="text-sm font-semibold mb-1">Preferred format</legend>
|
|
162
|
+
<p className="text-xs text-text-muted mb-1">
|
|
163
|
+
What other RapidMX servers use to encrypt to you when they could use either. Choose OpenPGP if you mostly read this
|
|
164
|
+
mailbox in an OpenPGP app.
|
|
165
|
+
</p>
|
|
166
|
+
{(
|
|
167
|
+
[
|
|
168
|
+
["smime", "S/MIME (recommended)"],
|
|
169
|
+
["openpgp", "OpenPGP"],
|
|
170
|
+
] as const
|
|
171
|
+
).map(([value, label]) => (
|
|
172
|
+
<label key={value} className="flex items-center gap-2 text-sm">
|
|
173
|
+
<input type="radio" name={`preferred-format-${mailbox.uid}`} checked={format === value} onChange={() => void saveFormat(value)} />
|
|
174
|
+
{label}
|
|
175
|
+
</label>
|
|
176
|
+
))}
|
|
177
|
+
</fieldset>
|
|
178
|
+
<div className="flex flex-wrap gap-2 mb-2">
|
|
179
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={() => void downloadPublicKey()}>
|
|
180
|
+
Download public key
|
|
181
|
+
</Button>
|
|
182
|
+
{canManage && !open && (
|
|
183
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={() => setOpen(true)}>
|
|
184
|
+
Export secret key…
|
|
185
|
+
</Button>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
{!canManage ? (
|
|
189
|
+
<PolicyLockedNotice>Only this mailbox’s owner, signed in as themselves, can export its secret key.</PolicyLockedNotice>
|
|
190
|
+
) : (
|
|
191
|
+
open && (
|
|
192
|
+
<form onSubmit={(e) => void handleExport(e)} className="flex flex-col gap-2">
|
|
193
|
+
<p className="text-sm text-text-muted">
|
|
194
|
+
Anyone with the exported file and its passphrase can read this mailbox’s OpenPGP mail and sign as you.
|
|
195
|
+
</p>
|
|
196
|
+
<input
|
|
197
|
+
type="password"
|
|
198
|
+
aria-label="Encryption password"
|
|
199
|
+
placeholder="Encryption password"
|
|
200
|
+
className={INPUT_CLASS}
|
|
201
|
+
value={password}
|
|
202
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
203
|
+
disabled={exporting}
|
|
204
|
+
autoComplete="current-password"
|
|
205
|
+
/>
|
|
206
|
+
<input
|
|
207
|
+
type="password"
|
|
208
|
+
aria-label="Key passphrase"
|
|
209
|
+
placeholder={`Key passphrase (at least ${MIN_EXPORT_PASSPHRASE_LENGTH} characters)`}
|
|
210
|
+
className={INPUT_CLASS}
|
|
211
|
+
value={passphrase}
|
|
212
|
+
onChange={(e) => setPassphrase(e.target.value)}
|
|
213
|
+
disabled={exporting}
|
|
214
|
+
autoComplete="new-password"
|
|
215
|
+
/>
|
|
216
|
+
<input
|
|
217
|
+
type="password"
|
|
218
|
+
aria-label="Confirm key passphrase"
|
|
219
|
+
placeholder="Confirm key passphrase"
|
|
220
|
+
className={INPUT_CLASS}
|
|
221
|
+
value={confirmPassphrase}
|
|
222
|
+
onChange={(e) => setConfirmPassphrase(e.target.value)}
|
|
223
|
+
disabled={exporting}
|
|
224
|
+
autoComplete="new-password"
|
|
225
|
+
/>
|
|
226
|
+
{error && <Alert>{error}</Alert>}
|
|
227
|
+
<div className="flex gap-2">
|
|
228
|
+
<Button type="submit" className="!w-auto" loading={exporting} disabled={exporting || !password}>
|
|
229
|
+
Export
|
|
230
|
+
</Button>
|
|
231
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={reset} disabled={exporting}>
|
|
232
|
+
Cancel
|
|
233
|
+
</Button>
|
|
234
|
+
</div>
|
|
235
|
+
</form>
|
|
236
|
+
)
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
@@ -8,7 +8,12 @@ import { getMailbox, type Mailbox, updateMailbox } from "../../../../lib/mail/ma
|
|
|
8
8
|
import { ApiRequestError } from "../../../../lib/util/api.js";
|
|
9
9
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
10
10
|
import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
11
|
+
import { useSettingsShell } from "./layout/SettingsShell.js";
|
|
11
12
|
import PolicyLockedNotice from "./PolicyLockedNotice.js";
|
|
13
|
+
import ZeroAccessTypes from "./ZeroAccessTypes.js";
|
|
14
|
+
|
|
15
|
+
/** The data types this client can protect with zero-access encryption - each joins as its protection is implemented end to end. */
|
|
16
|
+
export const ZERO_ACCESS_SUPPORTED_TYPES = ["mail", "calendar", "contacts", "tasks"] as const;
|
|
12
17
|
|
|
13
18
|
/** Why zero-access encryption can't be changed here, when it can't - mirrors restapi's `ZeroAccessStatus` reasons that the user can't fix
|
|
14
19
|
* by ticking the box. */
|
|
@@ -28,6 +33,7 @@ export interface ZeroAccessSettingProps {
|
|
|
28
33
|
*/
|
|
29
34
|
export default function ZeroAccessSetting({ mailbox, canManage }: ZeroAccessSettingProps) {
|
|
30
35
|
const client = useApiClient();
|
|
36
|
+
const { patchMailbox } = useSettingsShell();
|
|
31
37
|
const [enabled, setEnabled] = useState(mailbox.zeroAccessEnabled !== false);
|
|
32
38
|
const [blocker, setBlocker] = useState<Blocker | null | undefined>(undefined);
|
|
33
39
|
const [saving, setSaving] = useState(false);
|
|
@@ -57,16 +63,20 @@ export default function ZeroAccessSetting({ mailbox, canManage }: ZeroAccessSett
|
|
|
57
63
|
setEnabled(next);
|
|
58
64
|
try {
|
|
59
65
|
const write = (version: number) => updateMailbox({ uid: mailbox.uid, version, zeroAccessEnabled: next }, client);
|
|
66
|
+
let saved: Mailbox;
|
|
60
67
|
try {
|
|
61
|
-
await write(mailbox.version);
|
|
68
|
+
saved = await write(mailbox.version);
|
|
62
69
|
} catch (err) {
|
|
63
70
|
// Enrolling a key (which happens on unlock) moves the mailbox's version on, so the copy this page was given may be
|
|
64
71
|
// behind: read it again and retry once.
|
|
65
72
|
if (!(err instanceof ApiRequestError && err.status === 409)) {
|
|
66
73
|
throw err;
|
|
67
74
|
}
|
|
68
|
-
await write((await getMailbox(mailbox.uid, {}, client)).version);
|
|
75
|
+
saved = await write((await getMailbox(mailbox.uid, {}, client)).version);
|
|
69
76
|
}
|
|
77
|
+
// The server's answer: turning it on decides every type the owner left undecided (restapi `settleZeroAccessTypes()`), which
|
|
78
|
+
// "What to protect" then shows.
|
|
79
|
+
patchMailbox?.(mailbox.uid, { zeroAccessEnabled: next, zeroAccessTypes: saved.zeroAccessTypes ?? mailbox.zeroAccessTypes, version: saved.version });
|
|
70
80
|
} catch (err) {
|
|
71
81
|
setEnabled(previous);
|
|
72
82
|
notifyApiError(err, "Couldn't save the zero-access encryption setting");
|
|
@@ -97,6 +107,7 @@ export default function ZeroAccessSetting({ mailbox, canManage }: ZeroAccessSett
|
|
|
97
107
|
</span>
|
|
98
108
|
</span>
|
|
99
109
|
</label>
|
|
110
|
+
{!locked && enabled && <ZeroAccessTypes mailbox={mailbox} types={ZERO_ACCESS_SUPPORTED_TYPES} canManage={canManage} disabled={saving} />}
|
|
100
111
|
{blocker === "disabled_by_admin" && (
|
|
101
112
|
<PolicyLockedNotice>
|
|
102
113
|
Your administrator has turned off zero-access encryption for this server, so new mail is stored without it. Mail
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useState } from "react";
|
|
6
|
+
import { getMailbox, type Mailbox, updateMailbox, type ZeroAccessDataType, type ZeroAccessTypes as ChosenTypes } from "../../../../lib/mail/mailApi.js";
|
|
7
|
+
import { ApiRequestError } from "../../../../lib/util/api.js";
|
|
8
|
+
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
9
|
+
import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
10
|
+
import { useSettingsShell } from "./layout/SettingsShell.js";
|
|
11
|
+
|
|
12
|
+
/** What each data type's protection covers, what stays readable and why, and what it breaks on a phone or Outlook - the copy restapi
|
|
13
|
+
* `specs/search.md` §3 *What It Covers* requires settings to state plainly. */
|
|
14
|
+
export const ZERO_ACCESS_TYPE_INFO: Record<ZeroAccessDataType, { label: string; encrypted: string; readable: string; onDevices: string }> = {
|
|
15
|
+
mail: {
|
|
16
|
+
label: "Mail",
|
|
17
|
+
encrypted: "Message bodies, subjects, previews and attachments",
|
|
18
|
+
readable: "Sender, recipients, dates, size, flags and folder - to deliver, thread and search your mail",
|
|
19
|
+
onDevices: "can't open protected mail until you install your exported keys on it (Export keys below)",
|
|
20
|
+
},
|
|
21
|
+
calendar: {
|
|
22
|
+
label: "Calendar",
|
|
23
|
+
encrypted: "Event titles, locations and descriptions",
|
|
24
|
+
readable: "Times, repeats, time zone, attendees, responses and busy status - for free/busy, invitations and reminders",
|
|
25
|
+
onDevices:
|
|
26
|
+
"shows protected events only as busy blocks named “Protected event”, and attendees aren't told when it changes a meeting (a phone) or sends one (Outlook) - send those from RapidMX",
|
|
27
|
+
},
|
|
28
|
+
contacts: {
|
|
29
|
+
label: "Contacts",
|
|
30
|
+
encrypted: "Names, company, job title, phone numbers, postal addresses, notes, categories and photos",
|
|
31
|
+
readable: "Email addresses - the server needs them to deliver mail and to find each contact's encryption keys",
|
|
32
|
+
onDevices: "shows protected contacts by their email address only",
|
|
33
|
+
},
|
|
34
|
+
tasks: {
|
|
35
|
+
label: "Tasks",
|
|
36
|
+
encrypted: "Task titles and notes",
|
|
37
|
+
readable: "Due dates, reminders, priority, completion and list",
|
|
38
|
+
onDevices: "shows protected tasks as “Protected task”",
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Whether `type` is included: the owner's choice, else (undecided) mail on and the rest off - restapi's `zeroAccessTypeIncluded()`. */
|
|
43
|
+
export function typeIncluded(types: ChosenTypes | undefined, type: ZeroAccessDataType): boolean {
|
|
44
|
+
const chosen = types?.[type];
|
|
45
|
+
return typeof chosen === "boolean" ? chosen : type === "mail";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The names of the owner's ActiveSync devices that sync `type`, and Outlook when it syncs the mailbox. */
|
|
49
|
+
export function devicesSyncing(mailbox: Pick<Mailbox, "syncClients">, type: ZeroAccessDataType): string[] {
|
|
50
|
+
const names = (mailbox.syncClients?.activesync?.devices ?? []).filter((device) => device.types.includes(type)).map((device) => device.model || "A phone");
|
|
51
|
+
return [...new Set(names), ...(mailbox.syncClients?.mapi ? ["Outlook"] : [])];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ZeroAccessTypesProps {
|
|
55
|
+
mailbox: Mailbox;
|
|
56
|
+
/** The data types this client can protect, in order - each one's switch appears once its protection is implemented. */
|
|
57
|
+
types: readonly ZeroAccessDataType[];
|
|
58
|
+
/** The owner, not impersonating: only they change it. */
|
|
59
|
+
canManage: boolean;
|
|
60
|
+
/** Zero-access is being turned on: the server is still deciding the undecided types, so nothing is changed before it has. */
|
|
61
|
+
disabled?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* "What to protect": one switch per data type zero-access encryption covers, each saying what it encrypts and - for a type one of the
|
|
66
|
+
* owner's ActiveSync devices or Outlook syncs - plainly what that device will no longer show (restapi `specs/search.md` §3 *What It
|
|
67
|
+
* Covers*). Then the table of what stays readable to the server and why.
|
|
68
|
+
*/
|
|
69
|
+
export default function ZeroAccessTypes({ mailbox, types, canManage, disabled = false }: ZeroAccessTypesProps) {
|
|
70
|
+
const client = useApiClient();
|
|
71
|
+
const { patchMailbox } = useSettingsShell();
|
|
72
|
+
const [chosen, setChosen] = useState<ChosenTypes>(mailbox.zeroAccessTypes ?? {});
|
|
73
|
+
const [saving, setSaving] = useState(false);
|
|
74
|
+
// What the server saved, once the mailbox has it (turning zero-access on decides the undecided types there): shown instead of what this
|
|
75
|
+
// was mounted with - set while rendering, so the stale switches are never drawn.
|
|
76
|
+
const storedTypes = JSON.stringify(mailbox.zeroAccessTypes ?? {});
|
|
77
|
+
const [shownTypes, setShownTypes] = useState(storedTypes);
|
|
78
|
+
if (shownTypes !== storedTypes) {
|
|
79
|
+
setShownTypes(storedTypes);
|
|
80
|
+
setChosen(JSON.parse(storedTypes) as ChosenTypes);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function save(type: ZeroAccessDataType, on: boolean) {
|
|
84
|
+
const previous = chosen;
|
|
85
|
+
const next = { ...chosen, [type]: on };
|
|
86
|
+
setChosen(next);
|
|
87
|
+
setSaving(true);
|
|
88
|
+
try {
|
|
89
|
+
const write = (version: number) => updateMailbox({ uid: mailbox.uid, version, zeroAccessTypes: { [type]: on } }, client);
|
|
90
|
+
let saved: Mailbox;
|
|
91
|
+
try {
|
|
92
|
+
saved = await write(mailbox.version);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// The server records devices on the mailbox, moving its version on: read it again and retry once.
|
|
95
|
+
if (!(err instanceof ApiRequestError && err.status === 409)) {
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
saved = await write((await getMailbox(mailbox.uid, {}, client)).version);
|
|
99
|
+
}
|
|
100
|
+
// Kept by the shell, so turning zero-access off and on again (which mounts this anew) shows what was saved: the server's merge of
|
|
101
|
+
// this choice into the stored one.
|
|
102
|
+
const merged = saved.zeroAccessTypes ?? next;
|
|
103
|
+
setChosen(merged);
|
|
104
|
+
patchMailbox?.(mailbox.uid, { zeroAccessTypes: merged, version: saved.version });
|
|
105
|
+
} catch (err) {
|
|
106
|
+
setChosen(previous);
|
|
107
|
+
notifyApiError(err, "Couldn't save what zero-access encryption protects");
|
|
108
|
+
} finally {
|
|
109
|
+
setSaving(false);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className="mt-3">
|
|
115
|
+
<h3 className="text-sm font-semibold mb-1">What to protect</h3>
|
|
116
|
+
<ul className="flex flex-col gap-2">
|
|
117
|
+
{types.map((type) => {
|
|
118
|
+
const info = ZERO_ACCESS_TYPE_INFO[type];
|
|
119
|
+
const devices = devicesSyncing(mailbox, type);
|
|
120
|
+
const on = typeIncluded(chosen, type);
|
|
121
|
+
const keyInstalled = type === "mail" && !!mailbox.zeroAccessKeysExportedAt;
|
|
122
|
+
return (
|
|
123
|
+
<li key={type}>
|
|
124
|
+
<label className="flex items-start gap-2 text-sm">
|
|
125
|
+
<input
|
|
126
|
+
type="checkbox"
|
|
127
|
+
className="mt-1"
|
|
128
|
+
checked={on}
|
|
129
|
+
disabled={!canManage || saving || disabled}
|
|
130
|
+
onChange={(e) => void save(type, e.target.checked)}
|
|
131
|
+
/>
|
|
132
|
+
<span>
|
|
133
|
+
<span className="font-semibold block">{info.label}</span>
|
|
134
|
+
<span className="text-xs text-text-muted block">{info.encrypted}.</span>
|
|
135
|
+
{devices.length > 0 && !keyInstalled && (
|
|
136
|
+
<span role={on ? "alert" : undefined} className={`text-xs block ${on ? "text-danger" : "text-text-muted"}`}>
|
|
137
|
+
{on ? "Breaks: " : "Left off because "}
|
|
138
|
+
{devices.join(", ")} {devices.length === 1 ? "syncs" : "sync"} this - {on ? "it " : "protected, it "}
|
|
139
|
+
{info.onDevices}.
|
|
140
|
+
</span>
|
|
141
|
+
)}
|
|
142
|
+
</span>
|
|
143
|
+
</label>
|
|
144
|
+
</li>
|
|
145
|
+
);
|
|
146
|
+
})}
|
|
147
|
+
</ul>
|
|
148
|
+
<details className="mt-3 text-sm">
|
|
149
|
+
<summary className="cursor-pointer font-semibold">What’s encrypted, and what isn’t</summary>
|
|
150
|
+
<table className="mt-2 w-full text-xs border-collapse">
|
|
151
|
+
<thead>
|
|
152
|
+
<tr className="text-left">
|
|
153
|
+
<th className="py-1 pr-2">Type</th>
|
|
154
|
+
<th className="py-1 pr-2">Encrypted</th>
|
|
155
|
+
<th className="py-1">Readable by the server, and why</th>
|
|
156
|
+
</tr>
|
|
157
|
+
</thead>
|
|
158
|
+
<tbody>
|
|
159
|
+
{types.map((type) => (
|
|
160
|
+
<tr key={type} className="border-t border-border align-top">
|
|
161
|
+
<td className="py-1 pr-2 font-semibold">{ZERO_ACCESS_TYPE_INFO[type].label}</td>
|
|
162
|
+
<td className="py-1 pr-2">{typeIncluded(chosen, type) ? ZERO_ACCESS_TYPE_INFO[type].encrypted : "Nothing - not protected"}</td>
|
|
163
|
+
<td className="py-1">{ZERO_ACCESS_TYPE_INFO[type].readable}</td>
|
|
164
|
+
</tr>
|
|
165
|
+
))}
|
|
166
|
+
</tbody>
|
|
167
|
+
</table>
|
|
168
|
+
<p className="mt-2 text-xs text-text-muted">
|
|
169
|
+
For every type, the server can also see folder and list names, how many items there are and when they change. Zero-access
|
|
170
|
+
encryption protects what is stored: the server sees what you save while it saves it, then keeps only the encrypted copy.
|
|
171
|
+
</p>
|
|
172
|
+
</details>
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import React, { createContext, PropsWithChildren, ReactNode, useContext, useEffect, useMemo, useState } from "react";
|
|
5
|
+
import React, { createContext, PropsWithChildren, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
6
6
|
import type { IconType } from "react-icons";
|
|
7
7
|
import {
|
|
8
8
|
HiOutlineArrowUturnLeft,
|
|
@@ -82,6 +82,9 @@ export interface SettingsShellContextValue {
|
|
|
82
82
|
* every settings section so far (Automatic Replies) edits mailbox-scoped fields. */
|
|
83
83
|
mailboxUid?: string;
|
|
84
84
|
mailboxes: Mailbox[];
|
|
85
|
+
/** Merges what a section just saved of a mailbox (its new `version` included) into the shell's copy, so a section mounted again for
|
|
86
|
+
* it - after the mailbox switcher, say - starts from what was saved rather than from the list loaded once. */
|
|
87
|
+
patchMailbox?: (mailboxUid: string, patch: Partial<Mailbox>) => void;
|
|
85
88
|
}
|
|
86
89
|
|
|
87
90
|
const SettingsShellContext = createContext<SettingsShellContextValue>({ mailboxes: [] });
|
|
@@ -151,7 +154,11 @@ export default function SettingsShell({
|
|
|
151
154
|
(requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
|
|
152
155
|
primaryMailboxUid(mailboxes, userUid);
|
|
153
156
|
|
|
154
|
-
const
|
|
157
|
+
const patchMailbox = useCallback(
|
|
158
|
+
(uid: string, patch: Partial<Mailbox>) => setMailboxes((list) => list.map((mb) => (mb.uid === uid ? { ...mb, ...patch } : mb))),
|
|
159
|
+
[],
|
|
160
|
+
);
|
|
161
|
+
const contextValue = useMemo<SettingsShellContextValue>(() => ({ mailboxUid, mailboxes, patchMailbox }), [mailboxUid, mailboxes, patchMailbox]);
|
|
155
162
|
|
|
156
163
|
// A full-screen takeover, not nested inside the rest of the app's chrome — same as ContactsShell/
|
|
157
164
|
// TasksShell, there's nothing else for a mailbox-less caller to configure here yet.
|