@rapidmx/web-client 0.34.0 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/escrow/matters/[uid].tsx +4 -3
- package/apps/shared/calendar/calendarLiveUpdates.ts +2 -5
- package/apps/shared/calendar/sealedEvents.ts +80 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +61 -3
- package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -1
- package/apps/shared/components/contacts/contactCardData.ts +8 -4
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +23 -1
- package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +91 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
- package/apps/shared/components/mail/ConversationList.tsx +1 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +91 -20
- package/apps/shared/components/mail/compose/draftResume.ts +28 -3
- package/apps/shared/components/mail/compose/quotedBody.ts +31 -15
- package/apps/shared/components/mail/invite/inviteStore.ts +7 -1
- package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
- package/apps/shared/components/mail/reading/MessageBody.tsx +4 -1
- package/apps/shared/components/mail/reading/bodyContent.ts +27 -17
- package/apps/shared/components/mail/reading/conversationPrint.ts +7 -5
- package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
- package/apps/shared/components/settings/KeyExportSetting.tsx +170 -0
- package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
- package/apps/shared/components/settings/ZeroAccessSetting.tsx +117 -0
- package/apps/shared/live/useFolderLiveUpdates.ts +59 -0
- package/apps/shared/mail/decryptedMessages.ts +11 -3
- package/apps/shared/mail/outbox/sendJob.ts +2 -2
- package/apps/shared/mail/outbox/sendOutcomes.ts +1 -3
- package/apps/shared/mail/reportNotices.ts +3 -1
- package/apps/shared/mail/zeroAccessSource.ts +27 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +31 -0
- package/apps/shared/search/localIndexBuilder.ts +27 -1
- package/apps/shared/search/localIndexRpcClient.ts +8 -4
- package/apps/shared/search/localIndexSchema.ts +2 -2
- package/apps/shared/search/localIndexTransport.ts +2 -1
- package/apps/shared/search/localIndexWorker.ts +6 -4
- package/apps/shared/search/zeroAccessIndexSync.ts +487 -0
- package/apps/www/calendar/index.tsx +180 -58
- package/apps/www/contacts/index.tsx +58 -2
- package/apps/www/index.tsx +10 -3
- package/apps/www/settings/encryption/index.tsx +5 -0
- package/apps/www/settings/privacy/index.tsx +2 -1
- package/apps/www/tasks/index.tsx +42 -0
- package/dist/apps/escrow/matters/[uid].js +2 -2
- package/dist/apps/shared/calendar/calendarLiveUpdates.js +2 -5
- package/dist/apps/shared/calendar/sealedEvents.d.ts +13 -0
- package/dist/apps/shared/calendar/sealedEvents.js +54 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +18 -2
- package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
- package/dist/apps/shared/components/contacts/contactCardData.d.ts +2 -2
- package/dist/apps/shared/components/contacts/contactCardData.js +8 -4
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +21 -2
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +15 -0
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +72 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
- package/dist/apps/shared/components/mail/ConversationList.js +1 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +73 -17
- package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
- package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +4 -3
- package/dist/apps/shared/components/mail/compose/quotedBody.js +28 -15
- package/dist/apps/shared/components/mail/invite/inviteStore.js +6 -1
- package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
- package/dist/apps/shared/components/mail/reading/MessageBody.js +4 -1
- package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +4 -2
- package/dist/apps/shared/components/mail/reading/bodyContent.js +27 -16
- package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +3 -2
- package/dist/apps/shared/components/mail/reading/conversationPrint.js +7 -5
- package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
- package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
- package/dist/apps/shared/components/settings/KeyExportSetting.js +88 -0
- package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
- package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +14 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.js +71 -0
- package/dist/apps/shared/live/useFolderLiveUpdates.d.ts +19 -0
- package/dist/apps/shared/live/useFolderLiveUpdates.js +41 -0
- package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
- package/dist/apps/shared/mail/decryptedMessages.js +11 -3
- package/dist/apps/shared/mail/outbox/sendJob.js +2 -2
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +1 -3
- package/dist/apps/shared/mail/reportNotices.js +3 -1
- package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
- package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +28 -0
- package/dist/apps/shared/search/localIndexBuilder.d.ts +5 -0
- package/dist/apps/shared/search/localIndexBuilder.js +24 -2
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +1 -1
- package/dist/apps/shared/search/localIndexRpcClient.js +6 -4
- package/dist/apps/shared/search/localIndexSchema.d.ts +1 -1
- package/dist/apps/shared/search/localIndexSchema.js +2 -2
- package/dist/apps/shared/search/localIndexTransport.d.ts +2 -1
- package/dist/apps/shared/search/localIndexWorker.d.ts +2 -0
- package/dist/apps/shared/search/localIndexWorker.js +4 -4
- package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +71 -0
- package/dist/apps/shared/search/zeroAccessIndexSync.js +382 -0
- package/dist/apps/www/calendar/index.js +48 -22
- package/dist/apps/www/contacts/index.js +49 -3
- package/dist/apps/www/index.js +10 -3
- package/dist/apps/www/settings/encryption/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.js +1 -1
- package/dist/apps/www/tasks/index.js +29 -1
- package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
- package/dist/lib/calendar/calendarApi.d.ts +6 -0
- package/dist/lib/calendar/inviteApi.js +27 -5
- package/dist/lib/calendar/sealedInvites.d.ts +11 -0
- package/dist/lib/calendar/sealedInvites.js +28 -0
- package/dist/lib/crypto/keyExport.d.ts +54 -0
- package/dist/lib/crypto/keyExport.js +161 -0
- package/dist/lib/crypto/keySession.d.ts +45 -1
- package/dist/lib/crypto/keySession.js +93 -2
- package/dist/lib/crypto/keyvaultApi.d.ts +88 -3
- package/dist/lib/crypto/keyvaultApi.js +32 -0
- package/dist/lib/crypto/messageSecurity.d.ts +30 -1
- package/dist/lib/crypto/messageSecurity.js +80 -4
- package/dist/lib/crypto/mime.d.ts +5 -0
- package/dist/lib/crypto/mime.js +25 -0
- package/dist/lib/crypto/sharedMailboxKeys.d.ts +50 -0
- package/dist/lib/crypto/sharedMailboxKeys.js +264 -0
- package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
- package/dist/lib/crypto/zeroAccessKey.js +66 -0
- package/dist/lib/mail/conversationsApi.d.ts +6 -0
- package/dist/lib/mail/mailApi.d.ts +30 -2
- package/dist/lib/mail/mailApi.js +5 -1
- package/dist/lib/mail/pushClient.d.ts +10 -3
- package/dist/lib/mail/pushClient.js +18 -4
- package/dist/lib/search/zeroAccessIndexApi.d.ts +81 -0
- package/dist/lib/search/zeroAccessIndexApi.js +35 -0
- package/dist/lib/tasks/tasksApi.d.ts +1 -0
- package/dist/lib/tasks/tasksApi.js +3 -0
- package/lib/admin/escrowAccessRequestsApi.ts +6 -0
- package/lib/calendar/calendarApi.ts +3 -0
- package/lib/calendar/inviteApi.ts +28 -5
- package/lib/calendar/sealedInvites.ts +33 -0
- package/lib/crypto/keyExport.ts +229 -0
- package/lib/crypto/keySession.ts +117 -3
- package/lib/crypto/keyvaultApi.ts +125 -3
- package/lib/crypto/messageSecurity.ts +110 -3
- package/lib/crypto/mime.ts +26 -0
- package/lib/crypto/sharedMailboxKeys.ts +324 -0
- package/lib/crypto/zeroAccessKey.ts +97 -0
- package/lib/mail/conversationsApi.ts +3 -0
- package/lib/mail/mailApi.ts +33 -3
- package/lib/mail/pushClient.ts +18 -4
- package/lib/search/zeroAccessIndexApi.ts +119 -0
- package/lib/tasks/tasksApi.ts +4 -0
- package/package.json +3 -3
package/apps/www/tasks/index.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
TaskPriority,
|
|
12
12
|
createTask,
|
|
13
13
|
deleteTask,
|
|
14
|
+
getTask,
|
|
14
15
|
listTasks,
|
|
15
16
|
setTaskCompleted,
|
|
16
17
|
setTaskMyDay,
|
|
@@ -34,6 +35,13 @@ import { SHORTCUTS } from "../../shared/keyboard/keymap.js";
|
|
|
34
35
|
import { useShortcut } from "../../shared/keyboard/useShortcut.js";
|
|
35
36
|
import { useShortcutProps } from "../../shared/keyboard/useShortcutProps.js";
|
|
36
37
|
import { notifyApiError } from "../../shared/notifications/apiErrors.js";
|
|
38
|
+
import { changedItemOf, useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
|
|
39
|
+
|
|
40
|
+
/** The published model class name of a task on either database (`TaskMongo`, `TaskSQL`) - not a `TaskList`'s. */
|
|
41
|
+
const TASK_MODEL = /^Task(Mongo|SQL)$/;
|
|
42
|
+
|
|
43
|
+
/** The push client channel group (`PushClient.setChannels()`) the page's tasks folder is subscribed under. */
|
|
44
|
+
const TASKS_CHANNEL_GROUP = "tasks";
|
|
37
45
|
|
|
38
46
|
const INPUT_CLASS =
|
|
39
47
|
"text-sm py-1.5 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
@@ -146,6 +154,40 @@ function TasksContent() {
|
|
|
146
154
|
void reload();
|
|
147
155
|
}, [folderUid]);
|
|
148
156
|
|
|
157
|
+
// A task created, changed or deleted anywhere - another tab, a phone over ActiveSync - is announced on its folder's channel. It is fetched again by its
|
|
158
|
+
// uid and replaces (or joins) the list while it is still in this folder; one that is gone (a delete, a 404, or moved to another folder) leaves it. A
|
|
159
|
+
// failed fetch changes nothing: the next load shows it as it is.
|
|
160
|
+
useFolderLiveUpdates(
|
|
161
|
+
TASKS_CHANNEL_GROUP,
|
|
162
|
+
folderUid ? [folderUid] : [],
|
|
163
|
+
(event) => changedItemOf(event, TASK_MODEL),
|
|
164
|
+
(change) => {
|
|
165
|
+
const remove = () => setTasks((previous) => previous.filter((existing) => existing.uid !== change.uid));
|
|
166
|
+
if (change.deleted) {
|
|
167
|
+
remove();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
void getTask(change.uid, client).then(
|
|
171
|
+
(fresh) => {
|
|
172
|
+
if (fresh.folderUid !== folderUid) {
|
|
173
|
+
remove();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
setTasks((previous) =>
|
|
177
|
+
previous.some((existing) => existing.uid === fresh.uid)
|
|
178
|
+
? previous.map((existing) => (existing.uid === fresh.uid ? fresh : existing))
|
|
179
|
+
: [...previous, fresh],
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
(err: unknown) => {
|
|
183
|
+
if (err instanceof ApiRequestError && err.status === 404) {
|
|
184
|
+
remove();
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
|
|
149
191
|
useEffect(() => {
|
|
150
192
|
setTargetMailboxUid(undefined);
|
|
151
193
|
setAddedElsewhere(null);
|
|
@@ -182,7 +182,7 @@ function MatterDetailContent({ uid }) {
|
|
|
182
182
|
try {
|
|
183
183
|
const material = await getAccessRequestMaterial(request.uid);
|
|
184
184
|
if (seq === materialSeq.current)
|
|
185
|
-
setMaterialText(JSON.stringify(material
|
|
185
|
+
setMaterialText(JSON.stringify(material, null, 2));
|
|
186
186
|
}
|
|
187
187
|
catch (err) {
|
|
188
188
|
if (seq === materialSeq.current) {
|
|
@@ -270,5 +270,5 @@ function MatterDetailContent({ uid }) {
|
|
|
270
270
|
? "bg-surface-alt text-text-muted"
|
|
271
271
|
: "bg-success text-white",
|
|
272
272
|
].join(" "), children: request.status })] }), actionErrors[request.uid] && _jsx(Alert, { children: actionErrors[request.uid] }), _jsxs("div", { className: "flex gap-3 mt-3", children: [!matter.closedAt && request.status === "pending" && (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", className: "!w-auto", loading: actingOn[request.uid], disabled: actingOn[request.uid], onClick: () => setApproveTarget(request), children: "Approve" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: actingOn[request.uid], disabled: actingOn[request.uid], onClick: () => setDenyTarget(request), children: "Deny" })] })), !matter.closedAt && (request.status === "approved" || request.status === "fulfilled") && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => handleGetMaterial(request), children: "Get material" }))] })] }, request.uid))) })), _jsx(LoadMoreButton, { list: requests, label: "Load more access requests" })] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("div", { className: "flex items-center justify-between mb-3", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide", children: "Export this matter" }), !matter.closedAt && (_jsx(Button, { type: "button", className: "!w-auto", loading: creatingExport, disabled: creatingExport, onClick: handleCreateExport, children: "+ New export" }))] }), _jsx("p", { className: "text-xs text-text-muted mb-3", children: "Exports messages (narrowed to this matter\u2019s own date range), plus full contacts, calendar events, tasks, and notes, from every custodian mailbox actually assigned to this matter\u2019s escrow scope, as one combined NDJSON file." }), exportError && _jsx(Alert, { children: exportError }), exportRequests.loadError && _jsx(Alert, { children: exportRequests.loadError }), matterExportRequests.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: exportRequests.loading ? "Loading…" : "No export requests yet." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: matterExportRequests.map((request) => (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsxs("span", { children: [new Date(request.dateCreated).toLocaleString(), request.status === "failed" && request.errorMessage && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.errorMessage] }))] }), _jsxs("span", { className: "flex items-center gap-3", children: [_jsx("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${statusBadgeClass(request.status)}`, children: statusLabel(request.status) }), !matter.closedAt && request.status === "ready" && (_jsx("a", { href: matterExportRequestDownloadUrl(request.uid), className: "text-primary-dark hover:underline font-medium", children: "Download" }))] })] }, request.uid))) }))] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-3", children: "Search this matter\u2019s custodians" }), matter.closedAt ? (_jsx("p", { className: "text-sm text-text-muted", children: "This matter is closed, so its custodians can no longer be searched or exported." })) : (_jsxs("form", { onSubmit: handleSearch, className: "flex gap-3 mb-4", children: [_jsx("input", { "aria-label": "Search this matter", className: INPUT_CLASS, value: searchText, onChange: (e) => setSearchText(e.target.value), placeholder: "Free text or an operator filter, e.g. from:alice@example.com" }), _jsx(Button, { type: "submit", className: "!w-auto shrink-0", loading: searching, disabled: searching || !searchText.trim(), children: "Search" })] })), searchError && _jsx(Alert, { children: searchError }), searchResults &&
|
|
273
|
-
(Object.keys(searchResults).length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No custodian mailboxes could be searched." })) : (_jsx("div", { className: "flex flex-col gap-4", children: Object.entries(searchResults).map(([mailboxUid, page]) => (_jsxs("div", { children: [_jsx("h3", { className: "text-sm font-semibold mb-2", children: mailboxUid }), page.results.length === 0 ? (_jsx("p", { className: "text-xs text-text-muted", children: "No matches." })) : (_jsx("ul", { className: "flex flex-col gap-1", children: page.results.map((result) => (_jsxs("li", { className: "text-sm py-1 px-2 bg-surface-alt rounded-sm", children: [result.entityType, " \u00B7 ", result.entityUid, result.snippet && _jsxs("span", { className: "text-text-muted", children: [" \u2014 ", result.snippet] })] }, `${result.entityType}-${result.entityUid}`))) }))] }, mailboxUid))) })))] }), _jsxs(Modal, { open: confirmingClose, onClose: closeCloseModal, title: "Close matter", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Close ", _jsx("strong", { children: matter.name }), "? Once closed, no new access requests, exports, searches, or key material reads are allowed under it. This cannot be reopened here."] }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm mb-4", children: [_jsx("dt", { className: "text-text-muted", children: "Custodians" }), _jsx("dd", { className: "break-all", children: matter.custodianMailboxUids.join(", ") }), _jsx("dt", { className: "text-text-muted", children: "Date range" }), _jsx("dd", { children: formatDateRange(matter) })] }), closeError && _jsx(Alert, { children: closeError }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: closing, onClick: closeCloseModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: closing, disabled: closing, onClick: handleClose, children: "Close matter" })] })] }), _jsxs(Modal, { open: approveTarget !== null, onClose: () => setApproveTarget(null), title: "Approve access request", children: [_jsx("p", { className: "text-sm mb-3", children: "Approving lets holders of this matter\u2019s escrow scope read the key material for this mailbox once enough holders have approved." }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm mb-4", children: [_jsx("dt", { className: "text-text-muted", children: "Matter" }), _jsx("dd", { children: matter.name }), _jsx("dt", { className: "text-text-muted", children: "Mailbox" }), _jsx("dd", { className: "break-all", children: approveTarget?.mailboxUid }), _jsx("dt", { className: "text-text-muted", children: "Date range" }), _jsx("dd", { children: formatDateRange(matter) }), _jsx("dt", { className: "text-text-muted", children: "Approvals" }), _jsxs("dd", { children: [approveTarget?.approvals.length, " of ", approveTarget?.requiredHoldersAtCreation, " so far, including requester ", approveTarget?.requestedByUserUid] })] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setApproveTarget(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => void handleApprove(), children: "Approve request" })] })] }), _jsxs(Modal, { open: denyTarget !== null, onClose: () => setDenyTarget(null), title: "Deny access request", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Denying refuses this request for good: it can\u2019t be approved afterwards, and holders can\u2019t read the key material for mailbox ", _jsx("span", { className: "break-all font-semibold", children: denyTarget?.mailboxUid }), " through it. A new request has to be made to ask again."] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setDenyTarget(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: () => void handleDeny(), children: "Deny request" })] })] }), _jsx(Modal, { open: showNewRequest, onClose: () => setShowNewRequest(false), title: "New access request", children: _jsxs("form", { onSubmit: handleCreateRequest, className: "flex flex-col gap-4", children: [newRequestError && _jsx(Alert, { children: newRequestError }), _jsx(FormField, { label: "Mailbox uid", htmlFor: "newRequestMailboxUid", children: _jsx("input", { id: "newRequestMailboxUid", type: "text", className: INPUT_CLASS, value: newRequestMailboxUid, onChange: (e) => setNewRequestMailboxUid(e.target.value) }) }), _jsx("p", { className: "text-xs text-text-muted", children: "Must be one of this matter's own custodian mailboxes, currently assigned to this matter's escrow scope. Your own creation counts as the first approval." }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: creatingRequest, onClick: () => setShowNewRequest(false), children: "Cancel" }), _jsx(Button, { type: "submit", className: "!w-auto", loading: creatingRequest, disabled: creatingRequest, children: "Create request" })] })] }) }), _jsx(Modal, { open: materialFor !== null, onClose: closeMaterialModal, title: "Escrow key material", children: loadingMaterial ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : materialError ? (_jsx(Alert, { children: materialError })) : (_jsxs(_Fragment, { children: [_jsxs("p", { className: "text-sm text-text-muted mb-3", children: ["Still-encrypted
|
|
273
|
+
(Object.keys(searchResults).length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No custodian mailboxes could be searched." })) : (_jsx("div", { className: "flex flex-col gap-4", children: Object.entries(searchResults).map(([mailboxUid, page]) => (_jsxs("div", { children: [_jsx("h3", { className: "text-sm font-semibold mb-2", children: mailboxUid }), page.results.length === 0 ? (_jsx("p", { className: "text-xs text-text-muted", children: "No matches." })) : (_jsx("ul", { className: "flex flex-col gap-1", children: page.results.map((result) => (_jsxs("li", { className: "text-sm py-1 px-2 bg-surface-alt rounded-sm", children: [result.entityType, " \u00B7 ", result.entityUid, result.snippet && _jsxs("span", { className: "text-text-muted", children: [" \u2014 ", result.snippet] })] }, `${result.entityType}-${result.entityUid}`))) }))] }, mailboxUid))) })))] }), _jsxs(Modal, { open: confirmingClose, onClose: closeCloseModal, title: "Close matter", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Close ", _jsx("strong", { children: matter.name }), "? Once closed, no new access requests, exports, searches, or key material reads are allowed under it. This cannot be reopened here."] }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm mb-4", children: [_jsx("dt", { className: "text-text-muted", children: "Custodians" }), _jsx("dd", { className: "break-all", children: matter.custodianMailboxUids.join(", ") }), _jsx("dt", { className: "text-text-muted", children: "Date range" }), _jsx("dd", { children: formatDateRange(matter) })] }), closeError && _jsx(Alert, { children: closeError }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: closing, onClick: closeCloseModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: closing, disabled: closing, onClick: handleClose, children: "Close matter" })] })] }), _jsxs(Modal, { open: approveTarget !== null, onClose: () => setApproveTarget(null), title: "Approve access request", children: [_jsx("p", { className: "text-sm mb-3", children: "Approving lets holders of this matter\u2019s escrow scope read the key material for this mailbox once enough holders have approved." }), _jsxs("dl", { className: "grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm mb-4", children: [_jsx("dt", { className: "text-text-muted", children: "Matter" }), _jsx("dd", { children: matter.name }), _jsx("dt", { className: "text-text-muted", children: "Mailbox" }), _jsx("dd", { className: "break-all", children: approveTarget?.mailboxUid }), _jsx("dt", { className: "text-text-muted", children: "Date range" }), _jsx("dd", { children: formatDateRange(matter) }), _jsx("dt", { className: "text-text-muted", children: "Approvals" }), _jsxs("dd", { children: [approveTarget?.approvals.length, " of ", approveTarget?.requiredHoldersAtCreation, " so far, including requester ", approveTarget?.requestedByUserUid] })] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setApproveTarget(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => void handleApprove(), children: "Approve request" })] })] }), _jsxs(Modal, { open: denyTarget !== null, onClose: () => setDenyTarget(null), title: "Deny access request", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Denying refuses this request for good: it can\u2019t be approved afterwards, and holders can\u2019t read the key material for mailbox ", _jsx("span", { className: "break-all font-semibold", children: denyTarget?.mailboxUid }), " through it. A new request has to be made to ask again."] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setDenyTarget(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: () => void handleDeny(), children: "Deny request" })] })] }), _jsx(Modal, { open: showNewRequest, onClose: () => setShowNewRequest(false), title: "New access request", children: _jsxs("form", { onSubmit: handleCreateRequest, className: "flex flex-col gap-4", children: [newRequestError && _jsx(Alert, { children: newRequestError }), _jsx(FormField, { label: "Mailbox uid", htmlFor: "newRequestMailboxUid", children: _jsx("input", { id: "newRequestMailboxUid", type: "text", className: INPUT_CLASS, value: newRequestMailboxUid, onChange: (e) => setNewRequestMailboxUid(e.target.value) }) }), _jsx("p", { className: "text-xs text-text-muted", children: "Must be one of this matter's own custodian mailboxes, currently assigned to this matter's escrow scope. Your own creation counts as the first approval." }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: creatingRequest, onClick: () => setShowNewRequest(false), children: "Cancel" }), _jsx(Button, { type: "submit", className: "!w-auto", loading: creatingRequest, disabled: creatingRequest, children: "Create request" })] })] }) }), _jsx(Modal, { open: materialFor !== null, onClose: closeMaterialModal, title: "Escrow key material", children: loadingMaterial ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : materialError ? (_jsx(Alert, { children: materialError })) : (_jsxs(_Fragment, { children: [_jsxs("p", { className: "text-sm text-text-muted mb-3", children: ["Still-encrypted key material for ", _jsx("strong", { children: materialFor?.mailboxUid }), ": the escrow master key wrap, and the private keys that read its mail, sealed under that master key. This server cannot decrypt any of it either. Copy the JSON below into your own offline tooling."] }), _jsx("pre", { className: "text-xs font-mono bg-surface-alt border border-border rounded-sm p-3 overflow-x-auto whitespace-pre-wrap break-all", children: materialText })] })) })] }));
|
|
274
274
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
/** Pure helper behind the calendar page's live updates: which push events say "an event changed". */
|
|
6
|
+
import { changedItemOf } from "../live/useFolderLiveUpdates.js";
|
|
6
7
|
/** The published model name of a calendar event on either database (`CalendarEventMongo`, `CalendarEventSQL`) - and not the reminder's `"CalendarEvent"`,
|
|
7
8
|
* which has its own action. */
|
|
8
9
|
const EVENT_MODEL = /^CalendarEvent(Mongo|SQL)$/;
|
|
@@ -16,9 +17,5 @@ const EVENT_MODEL = /^CalendarEvent(Mongo|SQL)$/;
|
|
|
16
17
|
* changed event is handled that way, an ordinary one (an invitation arriving, an edit on another device) included.
|
|
17
18
|
*/
|
|
18
19
|
export function changedEventOf(event) {
|
|
19
|
-
|
|
20
|
-
return undefined;
|
|
21
|
-
}
|
|
22
|
-
const data = event.data;
|
|
23
|
-
return typeof data?.uid === "string" ? { uid: data.uid, deleted: event.action === "delete" } : undefined;
|
|
20
|
+
return changedItemOf(event, EVENT_MODEL);
|
|
24
21
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calendar events the server made from invitations that reached a zero-access mailbox keep their title, location and description only
|
|
3
|
+
* sealed to the mailbox's zero-access key (`CalendarEvent.sealedContent`; the row's `title` is `[...]`). These are opened here with the
|
|
4
|
+
* mailbox's unlocked keys so the calendar shows the real ones; while the mailbox is locked the event keeps its placeholder. The
|
|
5
|
+
* decryption code is loaded only when a sealed event is actually seen.
|
|
6
|
+
*/
|
|
7
|
+
import type { CalendarEvent } from "../../../lib/calendar/calendarApi.js";
|
|
8
|
+
/** Forgets every opened event (sign-out, or the test seam). */
|
|
9
|
+
export declare function clearSealedEvents(): void;
|
|
10
|
+
/** `events` with every sealed one this device can open shown with its real title, location and description. Never rejects. */
|
|
11
|
+
export declare function openSealedEvents(events: CalendarEvent[]): Promise<CalendarEvent[]>;
|
|
12
|
+
/** `openSealedEvents()` for one event. */
|
|
13
|
+
export declare function openSealedEvent(event: CalendarEvent): Promise<CalendarEvent>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { getUnlockedKeys } from "../../../lib/crypto/keySession.js";
|
|
2
|
+
import { fromBase64 } from "../../../lib/crypto/encoding.js";
|
|
3
|
+
import { onSigningOut } from "../components/mail/compose/composeFlushRegistry.js";
|
|
4
|
+
/** Opened fields by sealed text - the same sealed content always opens to the same fields. Cleared with `clearSealedEvents()`. */
|
|
5
|
+
const opened = new Map();
|
|
6
|
+
/** Forgets every opened event (sign-out, or the test seam). */
|
|
7
|
+
export function clearSealedEvents() {
|
|
8
|
+
opened.clear();
|
|
9
|
+
}
|
|
10
|
+
async function openFields(event) {
|
|
11
|
+
const sealed = event.sealedContent.sealed;
|
|
12
|
+
const known = opened.get(sealed);
|
|
13
|
+
if (known) {
|
|
14
|
+
return known;
|
|
15
|
+
}
|
|
16
|
+
const keys = getUnlockedKeys(event.mailboxUid)?.zeroAccessKeys;
|
|
17
|
+
if (!keys?.length) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const { decryptEnvelopedDataWithKeys } = await import("../../../lib/crypto/smime.js");
|
|
22
|
+
const fields = JSON.parse(new TextDecoder().decode(await decryptEnvelopedDataWithKeys(fromBase64(sealed), keys)));
|
|
23
|
+
const text = (value) => (typeof value === "string" ? value : undefined);
|
|
24
|
+
const result = {
|
|
25
|
+
title: text(fields.title) ?? event.title,
|
|
26
|
+
location: text(fields.location),
|
|
27
|
+
description: text(fields.description) ?? null,
|
|
28
|
+
descriptionHtml: text(fields.descriptionHtml) ?? null,
|
|
29
|
+
};
|
|
30
|
+
opened.set(sealed, result);
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** `events` with every sealed one this device can open shown with its real title, location and description. Never rejects. */
|
|
38
|
+
export async function openSealedEvents(events) {
|
|
39
|
+
if (!events.some((event) => event.sealedContent)) {
|
|
40
|
+
return events;
|
|
41
|
+
}
|
|
42
|
+
return Promise.all(events.map(async (event) => {
|
|
43
|
+
if (!event.sealedContent) {
|
|
44
|
+
return event;
|
|
45
|
+
}
|
|
46
|
+
const fields = await openFields(event);
|
|
47
|
+
return fields ? { ...event, ...fields } : event;
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
/** `openSealedEvents()` for one event. */
|
|
51
|
+
export async function openSealedEvent(event) {
|
|
52
|
+
return (await openSealedEvents([event]))[0];
|
|
53
|
+
}
|
|
54
|
+
onSigningOut(clearSealedEvents);
|
|
@@ -36,10 +36,14 @@ export default function EncryptionPolicyForm({ policy, onChange, onDirtyChange,
|
|
|
36
36
|
const [saved, setSaved] = useState(false);
|
|
37
37
|
// Edits are unsaved until they match what was loaded or last saved.
|
|
38
38
|
const [savedValues, setSavedValues] = useState(policy);
|
|
39
|
-
|
|
39
|
+
// A server without zero-access encryption sends no `allowZeroAccess`; the checkbox is then not shown at all.
|
|
40
|
+
const zeroAccessSupported = policy.allowZeroAccess !== undefined;
|
|
41
|
+
const dirty = TIERS.some((tier) => values[tier.key] !== savedValues[tier.key]) || (zeroAccessSupported && values.allowZeroAccess !== savedValues.allowZeroAccess);
|
|
40
42
|
useEffect(() => onDirtyChange?.(dirty), [dirty]);
|
|
41
43
|
// Tiers this save would newly set to "Never encrypt", which switches encryption off for everyone's mail of that kind: confirmed first.
|
|
42
44
|
const [confirmingProhibit, setConfirmingProhibit] = useState(null);
|
|
45
|
+
// Turning zero-access encryption off makes new mail readable by this server again for every mailbox: confirmed first.
|
|
46
|
+
const [confirmingZeroAccessOff, setConfirmingZeroAccessOff] = useState(false);
|
|
43
47
|
function handleSubmit(e) {
|
|
44
48
|
e.preventDefault();
|
|
45
49
|
const newlyProhibited = TIERS.filter((tier) => values[tier.key] === "prohibited" && savedValues[tier.key] !== "prohibited");
|
|
@@ -47,10 +51,19 @@ export default function EncryptionPolicyForm({ policy, onChange, onDirtyChange,
|
|
|
47
51
|
setConfirmingProhibit(newlyProhibited.map((tier) => tier.label));
|
|
48
52
|
return;
|
|
49
53
|
}
|
|
54
|
+
confirmZeroAccessThenSave();
|
|
55
|
+
}
|
|
56
|
+
function confirmZeroAccessThenSave() {
|
|
57
|
+
setConfirmingProhibit(null);
|
|
58
|
+
if (zeroAccessSupported && values.allowZeroAccess === false && savedValues.allowZeroAccess !== false) {
|
|
59
|
+
setConfirmingZeroAccessOff(true);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
50
62
|
void save();
|
|
51
63
|
}
|
|
52
64
|
async function save() {
|
|
53
65
|
setConfirmingProhibit(null);
|
|
66
|
+
setConfirmingZeroAccessOff(false);
|
|
54
67
|
setError(null);
|
|
55
68
|
setSaved(false);
|
|
56
69
|
setSaving(true);
|
|
@@ -70,5 +83,8 @@ export default function EncryptionPolicyForm({ policy, onChange, onDirtyChange,
|
|
|
70
83
|
return (_jsxs("div", { className: "max-w-xl", children: [embedded ? (_jsx("h3", { className: "text-base font-bold uppercase tracking-wide mb-1", children: "End-to-end encryption" })) : (_jsx("h2", { className: "text-lg font-bold uppercase tracking-wide mb-1", children: "End-to-end encryption" })), _jsx("p", { className: "text-sm text-text-muted mb-5", children: "Encrypted messages can only be read by their senders and recipients - not by this server or its administrators. Choose how encryption applies to each kind of mail." }), error && _jsx(Alert, { children: error }), saved && !error && _jsx("div", { className: "mb-4 text-sm text-success font-medium", children: "Saved." }), _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [TIERS.map((tier) => (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: tier.label }), _jsx("select", { "aria-label": tier.label, className: SELECT_CLASS, value: values[tier.key], onChange: (e) => {
|
|
71
84
|
setSaved(false);
|
|
72
85
|
setValues({ ...values, [tier.key]: e.target.value });
|
|
73
|
-
}, children: STATES.map((state) => (_jsx("option", { value: state.value, children: state.label }, state.value))) }), _jsx("span", { className: "text-xs text-text-muted", children: tier.help })] }, tier.key))),
|
|
86
|
+
}, children: STATES.map((state) => (_jsx("option", { value: state.value, children: state.label }, state.value))) }), _jsx("span", { className: "text-xs text-text-muted", children: tier.help })] }, tier.key))), zeroAccessSupported && (_jsxs("div", { className: "mt-2 pt-4 border-t border-border", children: [_jsx("span", { className: "font-semibold text-sm block mb-1", children: "Zero-access encryption" }), _jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "checkbox", className: "mt-1", checked: values.allowZeroAccess !== false, onChange: (e) => {
|
|
87
|
+
setSaved(false);
|
|
88
|
+
setValues({ ...values, allowZeroAccess: e.target.checked });
|
|
89
|
+
} }), _jsxs("span", { children: [_jsx("span", { className: "font-semibold block", children: "Allow zero-access encryption" }), _jsx("span", { className: "text-xs text-text-muted", children: "Mail that arrives unencrypted is encrypted the moment it is received, to a key only the mailbox\u2019s owner can unlock, and this server keeps no readable copy - administrators can\u2019t read it either, except through an escrow scope. People can still turn it off for their own mailbox. Search of message contents then happens in the app instead of on the server." })] })] })] })), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }) })] }), _jsxs(Modal, { open: confirmingProhibit !== null, onClose: () => setConfirmingProhibit(null), title: "Turn off encryption?", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Saving sets ", _jsx("strong", { children: confirmingProhibit?.join(", ") }), " to Never encrypt. Nobody on this server can then encrypt that mail end to end, whatever they have chosen for themselves, until an administrator allows it again."] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingProhibit(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: confirmZeroAccessThenSave, children: "Save and turn off encryption" })] })] }), _jsxs(Modal, { open: confirmingZeroAccessOff, onClose: () => setConfirmingZeroAccessOff(false), title: "Turn off zero-access encryption?", children: [_jsx("p", { className: "text-sm mb-3", children: "Mail that arrives from now on will be stored in a form this server can read, for every mailbox. Mail that is already encrypted stays encrypted - this server can\u2019t decrypt it. Everyone sees in their settings that an administrator turned it off." }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingZeroAccessOff(false), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: () => void save(), children: "Save and turn off zero-access encryption" })] })] })] }));
|
|
74
90
|
}
|
|
@@ -2,6 +2,8 @@ import React from "react";
|
|
|
2
2
|
import { Contact } from "../../../../lib/contacts/contactsApi.js";
|
|
3
3
|
export type ContactsView = {
|
|
4
4
|
type: "all";
|
|
5
|
+
} | {
|
|
6
|
+
type: "everyone";
|
|
5
7
|
} | {
|
|
6
8
|
type: "favorites";
|
|
7
9
|
} | {
|
|
@@ -35,7 +37,7 @@ export interface ContactsSidebarProps {
|
|
|
35
37
|
refreshToken?: number;
|
|
36
38
|
}
|
|
37
39
|
/**
|
|
38
|
-
* Contacts' Outlook-style left sidebar: "Your contacts" (
|
|
40
|
+
* Contacts' Outlook-style left sidebar: "All contacts" (the mailbox's own and its suggested ones together, when there are suggested ones), "Your contacts" (the mailbox's own, with count), "Favorites", "Suggested contacts" (the people the mailbox has corresponded with, when there are any), "Your contact
|
|
39
41
|
* lists" (real `ContactList` records, fetched here), "Deleted", and "Categories" (derived from the
|
|
40
42
|
* distinct `categories` values across the loaded contacts — no separate category registry exists
|
|
41
43
|
* server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
|
|
@@ -39,7 +39,7 @@ function NavItem({ label, count, active, onClick, swatch, }) {
|
|
|
39
39
|
].join(" "), children: [_jsxs("span", { className: "flex items-center gap-2 min-w-0", children: [swatch && _jsx("span", { "aria-hidden": "true", className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: swatch } }), _jsx("span", { className: "truncate", children: label })] }), count !== undefined && _jsx("span", { className: "text-xs text-text-muted shrink-0", children: count })] }));
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
42
|
-
* Contacts' Outlook-style left sidebar: "Your contacts" (
|
|
42
|
+
* Contacts' Outlook-style left sidebar: "All contacts" (the mailbox's own and its suggested ones together, when there are suggested ones), "Your contacts" (the mailbox's own, with count), "Favorites", "Suggested contacts" (the people the mailbox has corresponded with, when there are any), "Your contact
|
|
43
43
|
* lists" (real `ContactList` records, fetched here), "Deleted", and "Categories" (derived from the
|
|
44
44
|
* distinct `categories` values across the loaded contacts — no separate category registry exists
|
|
45
45
|
* server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
|
|
@@ -104,6 +104,6 @@ export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect
|
|
|
104
104
|
// A function, not a plain JSX constant — rendered both in the desktop `<nav>` and the mobile
|
|
105
105
|
// `Drawer` (see MailShell/ContactsShell for the identical pattern and the duplicate-id reasoning
|
|
106
106
|
// behind the `idPrefix`).
|
|
107
|
-
const navContent = (idPrefix) => (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx(NavItem, { label: "Your contacts", count: contacts.length, active: active.type === "all", onClick: () => handleSelect({ type: "all" }) }), _jsx(NavItem, { label: "Favorites", count: favoriteCount, active: active.type === "favorites", onClick: () => handleSelect({ type: "favorites" }) }), suggestedCount !== undefined && (_jsx(NavItem, { label: "Suggested contacts", count: suggestedCount, active: active.type === "suggested", onClick: () => handleSelect({ type: "suggested" }) })), showDeleted && _jsx(NavItem, { label: "Deleted", active: active.type === "deleted", onClick: () => handleSelect({ type: "deleted" }) })] }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-2.5 mb-1", children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "Your contact lists" }), _jsx("button", { type: "button", onClick: () => setAddingList(true), "aria-label": "New contact list", className: "text-xs font-bold text-primary-dark hover:underline", children: "+" })] }), listsError && _jsx("p", { className: "px-2.5 text-xs text-danger", children: listsError }), _jsx("div", { className: "flex flex-col gap-0.5", children: lists.map((list) => (_jsx(NavItem, { label: list.name, count: listCounts.get(list.uid) ?? 0, active: active.type === "list" && active.uid === list.uid, onClick: () => handleSelect({ type: "list", uid: list.uid, name: list.name }) }, list.uid))) }), addingList && (_jsxs("form", { onSubmit: handleAddList, className: "flex items-center gap-1 px-2.5 mt-1", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New list name", id: `${idPrefix}-new-list-name`, value: newListName, onChange: (e) => setNewListName(e.target.value), className: "flex-1 text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("button", { type: "submit", className: "text-xs font-semibold text-primary-dark", children: "Add" })] }))] }), categories.length > 0 && (_jsxs("div", { children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted px-2.5 mb-1", children: "Categories" }), _jsx("div", { className: "flex flex-col gap-0.5", children: categories.map((name) => (_jsx(NavItem, { label: name, swatch: categoryColor(name), active: active.type === "category" && active.name === name, onClick: () => handleSelect({ type: "category", name }) }, name))) })] }))] }));
|
|
107
|
+
const navContent = (idPrefix) => (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col gap-0.5", children: [suggestedCount !== undefined && (_jsx(NavItem, { label: "All contacts", count: contacts.length + suggestedCount, active: active.type === "everyone", onClick: () => handleSelect({ type: "everyone" }) })), _jsx(NavItem, { label: "Your contacts", count: contacts.length, active: active.type === "all", onClick: () => handleSelect({ type: "all" }) }), _jsx(NavItem, { label: "Favorites", count: favoriteCount, active: active.type === "favorites", onClick: () => handleSelect({ type: "favorites" }) }), suggestedCount !== undefined && (_jsx(NavItem, { label: "Suggested contacts", count: suggestedCount, active: active.type === "suggested", onClick: () => handleSelect({ type: "suggested" }) })), showDeleted && _jsx(NavItem, { label: "Deleted", active: active.type === "deleted", onClick: () => handleSelect({ type: "deleted" }) })] }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-2.5 mb-1", children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "Your contact lists" }), _jsx("button", { type: "button", onClick: () => setAddingList(true), "aria-label": "New contact list", className: "text-xs font-bold text-primary-dark hover:underline", children: "+" })] }), listsError && _jsx("p", { className: "px-2.5 text-xs text-danger", children: listsError }), _jsx("div", { className: "flex flex-col gap-0.5", children: lists.map((list) => (_jsx(NavItem, { label: list.name, count: listCounts.get(list.uid) ?? 0, active: active.type === "list" && active.uid === list.uid, onClick: () => handleSelect({ type: "list", uid: list.uid, name: list.name }) }, list.uid))) }), addingList && (_jsxs("form", { onSubmit: handleAddList, className: "flex items-center gap-1 px-2.5 mt-1", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New list name", id: `${idPrefix}-new-list-name`, value: newListName, onChange: (e) => setNewListName(e.target.value), className: "flex-1 text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("button", { type: "submit", className: "text-xs font-semibold text-primary-dark", children: "Add" })] }))] }), categories.length > 0 && (_jsxs("div", { children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted px-2.5 mb-1", children: "Categories" }), _jsx("div", { className: "flex flex-col gap-0.5", children: categories.map((name) => (_jsx(NavItem, { label: name, swatch: categoryColor(name), active: active.type === "category" && active.name === name, onClick: () => handleSelect({ type: "category", name }) }, name))) })] }))] }));
|
|
108
108
|
return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "lg:hidden m-3 mb-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open contacts menu", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("nav", { "aria-label": "Contacts", className: "hidden lg:flex w-56 shrink-0 border-r border-border flex-col gap-4 p-3 overflow-y-auto", children: navContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Contacts", fullScreen: true, children: _jsx("div", { className: "flex flex-col gap-4", children: navContent("mobile") }) })] }));
|
|
109
109
|
}
|
|
@@ -26,8 +26,8 @@ export declare function findContact(folders: readonly Folder[], address: string,
|
|
|
26
26
|
export declare function loadRecentMessages(mailboxes: readonly Mailbox[], address: string, client?: ApiClient): Promise<Message[]>;
|
|
27
27
|
/** The next `CARD_LIST_SIZE` events within `UPCOMING_DAYS` from `now` that `address` organizes or is invited to, soonest first. */
|
|
28
28
|
export declare function loadUpcomingEvents(folders: readonly Folder[], address: string, now: Date, client?: ApiClient): Promise<CalendarOccurrence[]>;
|
|
29
|
-
/** A vCard attachment's text. */
|
|
30
|
-
export declare function fetchVCardText(attachmentUid: string): Promise<string>;
|
|
29
|
+
/** A vCard attachment's text - through `client` (its origin and token) when given, else the cookie request it always was. */
|
|
30
|
+
export declare function fetchVCardText(attachmentUid: string, client?: ApiClient): Promise<string>;
|
|
31
31
|
/**
|
|
32
32
|
* The vCard of the context message that describes `participant`: the first attachment holding a card with their address, else - only for the
|
|
33
33
|
* message's sender, whose own vCard it presumably is - the first card there is. `undefined` when there is none, and for any failure to read one:
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { addDays } from "date-fns";
|
|
11
11
|
import { listCalendarEvents } from "../../../../lib/calendar/calendarApi.js";
|
|
12
|
+
import { openSealedEvents } from "../../calendar/sealedEvents.js";
|
|
12
13
|
import { expandAllOccurrences } from "../../../../lib/calendar/recurrence.js";
|
|
13
14
|
import { createContact, listContactsInFolders } from "../../../../lib/contacts/contactsApi.js";
|
|
14
15
|
import { parseVCards } from "../../../../lib/contacts/vcard.js";
|
|
@@ -53,7 +54,7 @@ export async function loadRecentMessages(mailboxes, address, client) {
|
|
|
53
54
|
export async function loadUpcomingEvents(folders, address, now, client) {
|
|
54
55
|
const wanted = normalizeAddress(address);
|
|
55
56
|
const calendars = folders.filter((folder) => folder.type === "calendar");
|
|
56
|
-
const lists = await Promise.all(calendars.map((folder) => listCalendarEvents(folder.uid, client)));
|
|
57
|
+
const lists = await Promise.all(calendars.map((folder) => listCalendarEvents(folder.uid, client).then(openSealedEvents)));
|
|
57
58
|
const involved = lists
|
|
58
59
|
.flat()
|
|
59
60
|
.filter((event) => event.status !== "cancelled" &&
|
|
@@ -63,8 +64,11 @@ export async function loadUpcomingEvents(folders, address, now, client) {
|
|
|
63
64
|
.sort((a, b) => Date.parse(a.startDate) - Date.parse(b.startDate))
|
|
64
65
|
.slice(0, CARD_LIST_SIZE);
|
|
65
66
|
}
|
|
66
|
-
/** A vCard attachment's text. */
|
|
67
|
-
export async function fetchVCardText(attachmentUid) {
|
|
67
|
+
/** A vCard attachment's text - through `client` (its origin and token) when given, else the cookie request it always was. */
|
|
68
|
+
export async function fetchVCardText(attachmentUid, client) {
|
|
69
|
+
if (client) {
|
|
70
|
+
return (await client.fetchBlob(`/mail/attachments/${encodeURIComponent(attachmentUid)}/content`)).text();
|
|
71
|
+
}
|
|
68
72
|
const res = await fetch(attachmentContentUrl(attachmentUid), { credentials: "include" });
|
|
69
73
|
if (!res.ok) {
|
|
70
74
|
throw new ApiRequestError(res.statusText || "Could not load this contact card.", res.status);
|
|
@@ -87,7 +91,7 @@ export async function loadParticipantCard(participant, context, client) {
|
|
|
87
91
|
const isSender = !!context.message && normalizeAddress(context.message.from.address) === normalizeAddress(participant.address);
|
|
88
92
|
let fallback;
|
|
89
93
|
for (const attachment of await vCardAttachments(context, client)) {
|
|
90
|
-
const cards = parseVCards(await fetchVCardText(attachment.uid));
|
|
94
|
+
const cards = parseVCards(await fetchVCardText(attachment.uid, client));
|
|
91
95
|
const match = cardForAddress(cards, participant.address, false);
|
|
92
96
|
if (match) {
|
|
93
97
|
return match;
|
|
@@ -12,7 +12,8 @@ import { enrollKey, getKeyVault, VaultAlreadyInitializedError } from "../../../.
|
|
|
12
12
|
import { UnopenableKeysNotice, unlockErrorMessage } from "./UnlockPromptProvider.js";
|
|
13
13
|
import { FrameTakeover } from "../../navigation/frameContext.js";
|
|
14
14
|
import { MIN_PASSWORD_LENGTH, RecoveryFollowUpModal, UnlockModeToggle, startRecoveryUnlock, } from "./RecoveryCodeUnlock.js";
|
|
15
|
-
import { ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, getUnlockedKeys, unlockWithPassword, } from "../../../../lib/crypto/keySession.js";
|
|
15
|
+
import { ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE, getUnlockedKeys, subscribeKeySession, unlockWithPassword, } from "../../../../lib/crypto/keySession.js";
|
|
16
|
+
import { ensureZeroAccessKey, provisionZeroAccessKey } from "../../../../lib/crypto/zeroAccessKey.js";
|
|
16
17
|
import { buildAad, generateMasterKey, sealWithKey } from "../../../../lib/crypto/masterKey.js";
|
|
17
18
|
import { buildPasswordWrap, buildRecoveryWraps } from "../../../../lib/crypto/masterKeyWraps.js";
|
|
18
19
|
import { exportPrivateKeyPkcs8, generateKeyPairWithCsr } from "../../../../lib/crypto/keys.js";
|
|
@@ -27,12 +28,15 @@ async function provisionEncryptionKey(mailboxUid, mailboxAddress, password) {
|
|
|
27
28
|
const { keyPair, csrPem } = await generateKeyPairWithCsr(mailboxAddress, "encrypt");
|
|
28
29
|
const privateKeyRaw = await exportPrivateKeyPkcs8(keyPair.privateKey);
|
|
29
30
|
const wrappedKeySealed = await sealWithKey(mk, privateKeyRaw, buildAad(mailboxUid, ENCRYPTION_PRIVATE_KEY_AAD_PURPOSE));
|
|
30
|
-
await enrollKey(mailboxUid, {
|
|
31
|
+
const vault = await enrollKey(mailboxUid, {
|
|
31
32
|
useType: "encrypt",
|
|
32
33
|
csr: csrPem,
|
|
33
34
|
wrappedKey: { ciphertext: wrappedKeySealed.ciphertext, nonce: wrappedKeySealed.nonce, algorithm: "AES-256-GCM" },
|
|
34
35
|
masterKeyWraps: [passwordWrap, ...recoveryWraps],
|
|
35
36
|
});
|
|
37
|
+
// The zero-access key (restapi specs/search.md §3), under the same master key. Not fatal: setup is done once the
|
|
38
|
+
// encryption key is in, and a mailbox without a zero-access key gets one on its next unlock (`ensureZeroAccessKey()`).
|
|
39
|
+
await provisionZeroAccessKey(mailboxUid, mailboxAddress, mk, vault.masterKeyGeneration).catch(() => undefined);
|
|
36
40
|
return { recoveryCodes };
|
|
37
41
|
}
|
|
38
42
|
/**
|
|
@@ -90,6 +94,21 @@ export default function KeyEnrollmentGate({ mailboxUid, mailboxAddress, mailboxK
|
|
|
90
94
|
cancelled = true;
|
|
91
95
|
};
|
|
92
96
|
}, [mailboxUid, canProvision]);
|
|
97
|
+
// A mailbox set up before zero-access encryption existed has no zero-access key: give it one whenever its owner has
|
|
98
|
+
// it unlocked - now, or on a later unlock from anywhere (this gate, an unlock prompt, recovery). Best effort: a
|
|
99
|
+
// failure is retried on the next unlock.
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!mailboxUid || !mailboxAddress || !canProvision) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const ensure = () => void ensureZeroAccessKey(mailboxUid, mailboxAddress).catch(() => undefined);
|
|
105
|
+
ensure();
|
|
106
|
+
return subscribeKeySession((event) => {
|
|
107
|
+
if (event.mailboxUid === mailboxUid && event.state === "unlocked") {
|
|
108
|
+
ensure();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}, [mailboxUid, mailboxAddress, canProvision]);
|
|
93
112
|
async function handleUnlock(e) {
|
|
94
113
|
e.preventDefault();
|
|
95
114
|
setError(null);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Mailbox } from "../../../../lib/mail/mailApi.js";
|
|
2
|
+
/** How often the shared mailboxes are looked after again while some own mailbox stays unlocked. */
|
|
3
|
+
export declare const SHARED_MAILBOX_CHECK_INTERVAL_MS: number;
|
|
4
|
+
export interface SharedMailboxKeyManagerProps {
|
|
5
|
+
/** Every mailbox the user can open. */
|
|
6
|
+
mailboxes: Mailbox[];
|
|
7
|
+
userUid?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Looks after the zero-access keys of the user's shared mailboxes (`lib/crypto/sharedMailboxKeys.ts`) whenever a mailbox of their own is
|
|
11
|
+
* unlocked, and every `SHARED_MAILBOX_CHECK_INTERVAL_MS` after that: opens each mailbox shared with them through their member key wraps (no
|
|
12
|
+
* password), and - for a mailbox they own or manage - provisions, rotates and shares its key with members still waiting. Best effort and
|
|
13
|
+
* silent: a pass that fails is tried again on the next. Renders nothing; mounted by `MailShell`.
|
|
14
|
+
*/
|
|
15
|
+
export default function SharedMailboxKeyManager({ mailboxes, userUid }: SharedMailboxKeyManagerProps): null;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect, useRef } from "react";
|
|
6
|
+
import { getUnlockedKeys, personallyUnlockedKeys, subscribeKeySession } from "../../../../lib/crypto/keySession.js";
|
|
7
|
+
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
8
|
+
/** How often the shared mailboxes are looked after again while some own mailbox stays unlocked. */
|
|
9
|
+
export const SHARED_MAILBOX_CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
|
10
|
+
/** The shared mailbox key code (pkijs and friends), loaded only once an own mailbox is unlocked. */
|
|
11
|
+
let loaded;
|
|
12
|
+
const loadSharedKeys = () => (loaded ?? (loaded = import("../../../../lib/crypto/sharedMailboxKeys.js").catch((err) => {
|
|
13
|
+
loaded = undefined;
|
|
14
|
+
throw err;
|
|
15
|
+
})));
|
|
16
|
+
/**
|
|
17
|
+
* Looks after the zero-access keys of the user's shared mailboxes (`lib/crypto/sharedMailboxKeys.ts`) whenever a mailbox of their own is
|
|
18
|
+
* unlocked, and every `SHARED_MAILBOX_CHECK_INTERVAL_MS` after that: opens each mailbox shared with them through their member key wraps (no
|
|
19
|
+
* password), and - for a mailbox they own or manage - provisions, rotates and shares its key with members still waiting. Best effort and
|
|
20
|
+
* silent: a pass that fails is tried again on the next. Renders nothing; mounted by `MailShell`.
|
|
21
|
+
*/
|
|
22
|
+
export default function SharedMailboxKeyManager({ mailboxes, userUid }) {
|
|
23
|
+
const client = useApiClient();
|
|
24
|
+
const latest = useRef({ mailboxes, userUid, client });
|
|
25
|
+
latest.current = { mailboxes, userUid, client };
|
|
26
|
+
const running = useRef(false);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
async function pass() {
|
|
29
|
+
if (running.current || personallyUnlockedKeys().length === 0) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
running.current = true;
|
|
33
|
+
try {
|
|
34
|
+
const { maintainSharedMailbox, unlockSharedMailbox } = await loadSharedKeys();
|
|
35
|
+
const { mailboxes: current, userUid: me, client: apiClient } = latest.current;
|
|
36
|
+
for (const mailbox of current) {
|
|
37
|
+
await look(mailbox, me, apiClient, unlockSharedMailbox, maintainSharedMailbox).catch(() => undefined);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Nothing to do until the next pass.
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
running.current = false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
void pass();
|
|
48
|
+
const unsubscribe = subscribeKeySession((event) => {
|
|
49
|
+
if (event.state === "unlocked" && !getUnlockedKeys(event.mailboxUid)?.shared) {
|
|
50
|
+
void pass();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
const interval = setInterval(() => void pass(), SHARED_MAILBOX_CHECK_INTERVAL_MS);
|
|
54
|
+
return () => {
|
|
55
|
+
unsubscribe();
|
|
56
|
+
clearInterval(interval);
|
|
57
|
+
};
|
|
58
|
+
}, []);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
async function look(mailbox, me, client, unlockSharedMailbox, maintainSharedMailbox) {
|
|
62
|
+
const owned = !!mailbox.ownerUserUid && mailbox.ownerUserUid === me;
|
|
63
|
+
if (!owned && !getUnlockedKeys(mailbox.uid)) {
|
|
64
|
+
await unlockSharedMailbox(mailbox.uid, client);
|
|
65
|
+
}
|
|
66
|
+
const unlocked = getUnlockedKeys(mailbox.uid);
|
|
67
|
+
// 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
|
+
// without an owner may have no key to open).
|
|
69
|
+
if (owned ? unlocked && !unlocked.shared : true) {
|
|
70
|
+
await maintainSharedMailbox(mailbox.uid, mailbox.primarySmtpAddress, client);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -36,6 +36,9 @@ interface UnlockPromptContextValue {
|
|
|
36
36
|
* mailbox happens to resolve - see `KeyEnrollmentGate`'s `blocking` prop, which `MailShell` now sets to
|
|
37
37
|
* `false` for exactly this reason.
|
|
38
38
|
*/
|
|
39
|
+
/** Messages for a shared mailbox `openShared()` can't open. */
|
|
40
|
+
export declare const SHARED_ACCESS_PENDING_MESSAGE = "This mailbox is shared with you, and is waiting for another member to share its key with you. Try again shortly.";
|
|
41
|
+
export declare const SHARED_OWN_KEYS_LOCKED_MESSAGE = "Unlock your own mailbox first: it holds the key that opens the mailboxes shared with you.";
|
|
39
42
|
export declare function UnlockPromptProvider({ children }: {
|
|
40
43
|
children: React.ReactNode;
|
|
41
44
|
}): React.JSX.Element;
|
|
@@ -9,6 +9,7 @@ import Modal from "../../../../lib/components/overlays/Modal.js";
|
|
|
9
9
|
import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
10
10
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
11
11
|
import FormField from "../../../../lib/components/forms/FormField.js";
|
|
12
|
+
import { notify } from "../../notifications/store.js";
|
|
12
13
|
import { RECOVERY_CODE_ERROR, RecoveryFollowUpModal, UnlockModeToggle, startRecoveryUnlock, } from "./RecoveryCodeUnlock.js";
|
|
13
14
|
/** Shown instead of "Incorrect password" when the password was right but the mailbox's encryption key itself
|
|
14
15
|
* won't open (`UnopenableEncryptionKeyError`) - retrying can't fix that, so it must not read as a typo. */
|
|
@@ -44,6 +45,33 @@ const UnlockPromptContext = createContext(null);
|
|
|
44
45
|
* mailbox happens to resolve - see `KeyEnrollmentGate`'s `blocking` prop, which `MailShell` now sets to
|
|
45
46
|
* `false` for exactly this reason.
|
|
46
47
|
*/
|
|
48
|
+
/** Messages for a shared mailbox `openShared()` can't open. */
|
|
49
|
+
export const SHARED_ACCESS_PENDING_MESSAGE = "This mailbox is shared with you, and is waiting for another member to share its key with you. Try again shortly.";
|
|
50
|
+
export const SHARED_OWN_KEYS_LOCKED_MESSAGE = "Unlock your own mailbox first: it holds the key that opens the mailboxes shared with you.";
|
|
51
|
+
/**
|
|
52
|
+
* Opens `mailboxUid` through the user's member key wraps when it is a mailbox shared with them (`sharedMailboxKeys.ts`): its keys, or
|
|
53
|
+
* `undefined` when it isn't one of theirs (the password prompt applies). Rejects, after saying why, when it is shared with them but can't
|
|
54
|
+
* open yet. The shared key code is loaded only here.
|
|
55
|
+
*/
|
|
56
|
+
async function openShared(mailboxUid) {
|
|
57
|
+
let outcome;
|
|
58
|
+
try {
|
|
59
|
+
const { unlockSharedMailbox } = await import("../../../../lib/crypto/sharedMailboxKeys.js");
|
|
60
|
+
outcome = await unlockSharedMailbox(mailboxUid);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
if (outcome === "unlocked") {
|
|
66
|
+
return getUnlockedKeys(mailboxUid);
|
|
67
|
+
}
|
|
68
|
+
if (outcome === "pending" || outcome === "own_keys_locked") {
|
|
69
|
+
const message = outcome === "pending" ? SHARED_ACCESS_PENDING_MESSAGE : SHARED_OWN_KEYS_LOCKED_MESSAGE;
|
|
70
|
+
notify({ kind: "info", title: "Shared mailbox", message });
|
|
71
|
+
throw new Error(message);
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
47
75
|
export function UnlockPromptProvider({ children }) {
|
|
48
76
|
const [pending, setPendingState] = useState(null);
|
|
49
77
|
// Mirrors `pending` synchronously, so two requests issued in the same tick see each other.
|
|
@@ -66,6 +94,10 @@ export function UnlockPromptProvider({ children }) {
|
|
|
66
94
|
if (existing) {
|
|
67
95
|
return Promise.resolve(existing);
|
|
68
96
|
}
|
|
97
|
+
// A mailbox shared with the user opens through their member key wraps, not a password - and its password, if it has one, isn't theirs.
|
|
98
|
+
return openShared(mailboxUid).then((shared) => shared ?? promptForPassword(mailboxUid, mailboxKeys));
|
|
99
|
+
}, []);
|
|
100
|
+
const promptForPassword = useCallback((mailboxUid, mailboxKeys) => {
|
|
69
101
|
return new Promise((resolve, reject) => {
|
|
70
102
|
const current = pendingRef.current;
|
|
71
103
|
if (current?.mailboxUid === mailboxUid) {
|
|
@@ -49,7 +49,7 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
|
|
|
49
49
|
let stale = false;
|
|
50
50
|
void mapWithConcurrency(conversations.filter(conversationLooksEncrypted), 4, async (conversation) => {
|
|
51
51
|
if (!stale) {
|
|
52
|
-
await decryptForDisplay(conversation.latestMessageUid, conversation.mailboxUid ?? mailboxUid);
|
|
52
|
+
await decryptForDisplay(conversation.latestMessageUid, conversation.mailboxUid ?? mailboxUid, conversation.latestZeroAccess?.sealedSummary);
|
|
53
53
|
}
|
|
54
54
|
});
|
|
55
55
|
return () => {
|