@rapidmx/web-client 0.35.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/escrow/matters/[uid].tsx +4 -3
- package/apps/shared/calendar/calendarReminders.ts +35 -0
- package/apps/shared/calendar/sealedEvents.ts +113 -0
- package/apps/shared/calendar/useCalendarReminders.ts +28 -9
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +89 -3
- package/apps/shared/components/calendar/EventDetails.tsx +13 -2
- package/apps/shared/components/calendar/EventEditor.tsx +124 -17
- package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
- package/apps/shared/components/calendar/EventModal.tsx +19 -2
- package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
- package/apps/shared/components/calendar/eventForm.ts +2 -0
- package/apps/shared/components/contacts/ContactCard.tsx +13 -1
- package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
- package/apps/shared/components/contacts/ContactForm.tsx +56 -7
- package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
- package/apps/shared/components/contacts/contactCardData.ts +8 -4
- package/apps/shared/components/contacts/contactKeys.ts +3 -3
- package/apps/shared/components/layout/AppShell.tsx +34 -5
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +30 -1
- package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +122 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
- package/apps/shared/components/mail/ConversationList.tsx +1 -1
- package/apps/shared/components/mail/InviteCard.tsx +11 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +59 -26
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
- package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
- package/apps/shared/components/mail/compose/draftResume.ts +28 -3
- package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
- package/apps/shared/components/mail/compose/quotedBody.ts +4 -3
- package/apps/shared/components/mail/invite/inviteStore.ts +17 -1
- package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
- package/apps/shared/components/mail/pinnedSigners.ts +14 -0
- package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
- package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
- package/apps/shared/components/settings/KeyExportSetting.tsx +173 -0
- package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
- package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
- package/apps/shared/components/settings/ZeroAccessSetting.tsx +128 -0
- package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
- package/apps/shared/mail/decryptedMessages.ts +11 -3
- package/apps/shared/mail/outbox/sendJob.ts +349 -50
- package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
- package/apps/shared/mail/reportNotices.ts +3 -1
- package/apps/shared/mail/zeroAccessSource.ts +27 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +84 -24
- package/apps/shared/search/localIndexBuilder.ts +57 -20
- package/apps/shared/search/localIndexRpcClient.ts +96 -12
- package/apps/shared/search/localIndexSchema.ts +20 -4
- package/apps/shared/search/localIndexTransport.ts +6 -2
- package/apps/shared/search/localIndexWorker.ts +37 -18
- package/apps/shared/search/searchTier2.ts +5 -0
- package/apps/shared/search/zeroAccessIndexSync.ts +679 -0
- package/apps/shared/search/zeroAccessSyncState.ts +70 -0
- package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
- package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
- package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
- package/apps/www/_shell.tsx +2 -0
- package/apps/www/calendar/index.tsx +225 -61
- package/apps/www/contacts/index.tsx +67 -11
- package/apps/www/index.tsx +19 -3
- package/apps/www/settings/encryption/index.tsx +21 -1
- package/apps/www/settings/privacy/index.tsx +89 -39
- package/apps/www/tasks/index.tsx +24 -6
- package/dist/apps/escrow/matters/[uid].js +2 -2
- package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
- package/dist/apps/shared/calendar/calendarReminders.js +26 -0
- package/dist/apps/shared/calendar/sealedEvents.d.ts +14 -0
- package/dist/apps/shared/calendar/sealedEvents.js +85 -0
- package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
- package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +25 -2
- package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
- package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
- package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
- package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
- package/dist/apps/shared/components/calendar/EventModal.js +15 -2
- package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
- package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
- package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
- package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
- package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
- package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
- package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
- package/dist/apps/shared/components/contacts/contactCardData.js +8 -4
- package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
- package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
- package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
- package/dist/apps/shared/components/layout/AppShell.js +17 -6
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +27 -2
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +17 -0
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +98 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
- package/dist/apps/shared/components/mail/ConversationList.js +1 -1
- package/dist/apps/shared/components/mail/InviteCard.js +11 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +46 -21
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
- package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
- package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
- package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +1 -1
- package/dist/apps/shared/components/mail/compose/quotedBody.js +4 -3
- package/dist/apps/shared/components/mail/invite/inviteStore.js +16 -1
- package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
- package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
- package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
- package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
- package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
- package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
- package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
- package/dist/apps/shared/components/settings/KeyExportSetting.js +90 -0
- package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
- package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
- package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
- package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +16 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.js +80 -0
- package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
- package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
- package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
- package/dist/apps/shared/mail/decryptedMessages.js +11 -3
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
- package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
- package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
- package/dist/apps/shared/mail/reportNotices.js +3 -1
- package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
- package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +74 -24
- package/dist/apps/shared/search/localIndexBuilder.d.ts +9 -1
- package/dist/apps/shared/search/localIndexBuilder.js +50 -17
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
- package/dist/apps/shared/search/localIndexRpcClient.js +129 -11
- package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
- package/dist/apps/shared/search/localIndexSchema.js +18 -4
- package/dist/apps/shared/search/localIndexTransport.d.ts +6 -2
- package/dist/apps/shared/search/localIndexWorker.d.ts +11 -0
- package/dist/apps/shared/search/localIndexWorker.js +22 -15
- package/dist/apps/shared/search/searchTier2.js +5 -0
- package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +102 -0
- package/dist/apps/shared/search/zeroAccessIndexSync.js +544 -0
- package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
- package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
- package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
- package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
- package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
- package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
- package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
- package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
- package/dist/apps/www/_shell.d.ts +1 -1
- package/dist/apps/www/_shell.js +2 -2
- package/dist/apps/www/calendar/index.js +88 -20
- package/dist/apps/www/contacts/index.js +64 -11
- package/dist/apps/www/index.js +19 -3
- package/dist/apps/www/settings/encryption/index.js +16 -4
- package/dist/apps/www/settings/privacy/index.js +47 -6
- package/dist/apps/www/tasks/index.js +21 -7
- package/dist/lib/admin/domainsApi.d.ts +1 -1
- package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
- package/dist/lib/calendar/calendarApi.d.ts +52 -6
- package/dist/lib/calendar/calendarApi.js +110 -17
- package/dist/lib/calendar/calendarMutations.d.ts +20 -7
- package/dist/lib/calendar/calendarMutations.js +73 -14
- package/dist/lib/calendar/inviteApi.d.ts +17 -3
- package/dist/lib/calendar/inviteApi.js +86 -7
- package/dist/lib/calendar/protectedEvents.d.ts +31 -0
- package/dist/lib/calendar/protectedEvents.js +113 -0
- package/dist/lib/calendar/recurrence.d.ts +2 -0
- package/dist/lib/calendar/recurrence.js +16 -0
- package/dist/lib/calendar/sealedInvites.d.ts +9 -0
- package/dist/lib/calendar/sealedInvites.js +65 -0
- package/dist/lib/contacts/contactsApi.d.ts +29 -3
- package/dist/lib/contacts/contactsApi.js +23 -7
- package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
- package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
- package/dist/lib/crypto/composeSecurity.d.ts +11 -3
- package/dist/lib/crypto/composeSecurity.js +13 -5
- package/dist/lib/crypto/keyExport.d.ts +54 -0
- package/dist/lib/crypto/keyExport.js +161 -0
- package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
- package/dist/lib/crypto/keyPairMatch.js +65 -0
- package/dist/lib/crypto/keySession.d.ts +64 -3
- package/dist/lib/crypto/keySession.js +179 -12
- package/dist/lib/crypto/keyvaultApi.d.ts +129 -8
- package/dist/lib/crypto/keyvaultApi.js +45 -3
- package/dist/lib/crypto/meetingKeys.d.ts +25 -0
- package/dist/lib/crypto/meetingKeys.js +87 -0
- package/dist/lib/crypto/messageSecurity.d.ts +56 -3
- package/dist/lib/crypto/messageSecurity.js +201 -13
- package/dist/lib/crypto/mime.d.ts +5 -0
- package/dist/lib/crypto/mime.js +25 -0
- package/dist/lib/crypto/openPgpKey.d.ts +39 -0
- package/dist/lib/crypto/openPgpKey.js +89 -0
- package/dist/lib/crypto/openpgp.d.ts +47 -0
- package/dist/lib/crypto/openpgp.js +88 -0
- package/dist/lib/crypto/pgpMessage.d.ts +51 -0
- package/dist/lib/crypto/pgpMessage.js +271 -0
- package/dist/lib/crypto/sharedMailboxKeys.d.ts +62 -0
- package/dist/lib/crypto/sharedMailboxKeys.js +337 -0
- package/dist/lib/crypto/smimeMessage.d.ts +21 -0
- package/dist/lib/crypto/smimeMessage.js +11 -7
- package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
- package/dist/lib/crypto/zeroAccessKey.js +71 -0
- package/dist/lib/mail/conversationsApi.d.ts +6 -0
- package/dist/lib/mail/mailApi.d.ts +63 -2
- package/dist/lib/mail/mailApi.js +5 -1
- package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
- package/dist/lib/mail/mailboxImportApi.js +15 -0
- package/dist/lib/search/zeroAccessIndexApi.d.ts +85 -0
- package/dist/lib/search/zeroAccessIndexApi.js +35 -0
- package/dist/lib/tasks/tasksApi.d.ts +11 -1
- package/dist/lib/tasks/tasksApi.js +10 -4
- package/dist/lib/util/clientPlatform.d.ts +14 -0
- package/dist/lib/util/clientPlatform.js +26 -0
- package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
- package/dist/lib/videoconf/protectedMeetings.js +181 -0
- package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
- package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
- package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
- package/dist/lib/zeroAccess/sealedMoves.js +63 -0
- package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
- package/dist/lib/zeroAccess/sealedRecords.js +136 -0
- package/lib/admin/domainsApi.ts +1 -1
- package/lib/admin/escrowAccessRequestsApi.ts +6 -0
- package/lib/calendar/calendarApi.ts +143 -17
- package/lib/calendar/calendarMutations.ts +79 -12
- package/lib/calendar/inviteApi.ts +101 -8
- package/lib/calendar/protectedEvents.ts +143 -0
- package/lib/calendar/recurrence.ts +17 -0
- package/lib/calendar/sealedInvites.ts +73 -0
- package/lib/contacts/contactsApi.ts +43 -17
- package/lib/contacts/useContactPhotoSrc.ts +103 -13
- package/lib/crypto/composeSecurity.ts +20 -5
- package/lib/crypto/keyExport.ts +229 -0
- package/lib/crypto/keyPairMatch.ts +69 -0
- package/lib/crypto/keySession.ts +216 -12
- package/lib/crypto/keyvaultApi.ts +174 -10
- package/lib/crypto/meetingKeys.ts +118 -0
- package/lib/crypto/messageSecurity.ts +284 -16
- package/lib/crypto/mime.ts +26 -0
- package/lib/crypto/openPgpKey.ts +118 -0
- package/lib/crypto/openpgp.ts +115 -0
- package/lib/crypto/pgpMessage.ts +336 -0
- package/lib/crypto/sharedMailboxKeys.ts +402 -0
- package/lib/crypto/smimeMessage.ts +11 -7
- package/lib/crypto/zeroAccessKey.ts +102 -0
- package/lib/mail/conversationsApi.ts +3 -0
- package/lib/mail/mailApi.ts +60 -3
- package/lib/mail/mailboxImportApi.ts +24 -0
- package/lib/search/zeroAccessIndexApi.ts +123 -0
- package/lib/tasks/tasksApi.ts +19 -5
- package/lib/util/clientPlatform.ts +50 -0
- package/lib/videoconf/protectedMeetings.ts +221 -0
- package/lib/videoconf/videoMeetingsApi.ts +15 -4
- package/lib/zeroAccess/sealedMoves.ts +79 -0
- package/lib/zeroAccess/sealedRecords.ts +153 -0
- package/package.json +14 -5
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
* expansion happens entirely client-side (see `apps/shared/lib/recurrence.ts`) — the backend stores/returns
|
|
8
8
|
* `RecurrenceRule` as-is and does no RFC5545 expansion of its own. */
|
|
9
9
|
|
|
10
|
-
import { ApiClient, withClient } from "../util/api.js";
|
|
10
|
+
import { ApiClient, ApiRequestError, withClient } from "../util/api.js";
|
|
11
11
|
import { ListParams, buildQuery } from "../util/apiQuery.js";
|
|
12
|
+
import { INVALID_REQUEST_CODE, guardSealedMove } from "../zeroAccess/sealedMoves.js";
|
|
13
|
+
import { isOpenedRecord, isProtectedRecord, openSealedRecord, openSealedRecords } from "../zeroAccess/sealedRecords.js";
|
|
14
|
+
import { EVENT_CONTENT_FIELDS, type InvitationContent, invitationContentOf, isSealedContentOpened, markSealedContentOpened, protectedEventWrite } from "./protectedEvents.js";
|
|
15
|
+
import { meetingLinkSecrets } from "../videoconf/protectedMeetings.js";
|
|
12
16
|
|
|
13
17
|
export type AttendeeRole = "required" | "optional" | "resource";
|
|
14
18
|
export type AttendeeResponseStatus = "needsAction" | "accepted" | "declined" | "tentative";
|
|
@@ -135,6 +139,13 @@ export interface CalendarEvent {
|
|
|
135
139
|
* `mailto` only). Still to be rendered through `sanitizeEventDescriptionHtml()` (`eventDescription.ts`), never trusted as it stands. `null` or absent:
|
|
136
140
|
* the description, if any, is plain text. */
|
|
137
141
|
descriptionHtml?: string | null;
|
|
142
|
+
/** Response-only. An event made from an invitation that reached a zero-access mailbox: its title, location and description are only
|
|
143
|
+
* sealed to the mailbox's zero-access key (`title` is `[...]`). `apps/shared/calendar/sealedEvents.ts` opens them. */
|
|
144
|
+
sealedContent?: { keyFingerprint: string; sealed: string };
|
|
145
|
+
/** Response-only. An event of a calendar zero-access encryption protects: its title, location and description, each sealed to the
|
|
146
|
+
* mailbox's zero-access key (the readable `title` is "Protected event"). Every function here returns events with them opened when the
|
|
147
|
+
* mailbox is unlocked (`lib/zeroAccess/sealedRecords.ts`); writes go through `protectedEvents.ts`. */
|
|
148
|
+
sealedFields?: Record<string, string> | null;
|
|
138
149
|
/** Who may see the event's details - absent (or `null`, on a row stored before this existed) reads as `"default"`; see `visibilityOf()`. */
|
|
139
150
|
visibility?: EventVisibility | null;
|
|
140
151
|
/** Whether the guests may ask for the event to change (default `false`). Absent or `null` reads as the default; see `guestPermissionsOf()`. */
|
|
@@ -170,6 +181,22 @@ const EVENT_SORT = JSON.stringify({ uid: "ASC" });
|
|
|
170
181
|
* `LIST_CALENDAR_EVENTS_MAX_PAGES` pages guards against looping forever should a server ever ignore
|
|
171
182
|
* `page` and keep returning full pages.
|
|
172
183
|
*/
|
|
184
|
+
/** What a list opens of a protected event: what the calendar views draw. Each field is one decryption, and a calendar lists every event
|
|
185
|
+
* it has - the rest (the description) is opened by the view that shows or edits it, by a search, and by a save. */
|
|
186
|
+
export const EVENT_LIST_FIELDS = ["title", "location"] as const;
|
|
187
|
+
|
|
188
|
+
/** `event` with every protected field opened, when only some were (a listed event) - for a write, which sends only what changed and
|
|
189
|
+
* invites with the whole content. The title of one whose `sealedContent` was opened stays: a row can hold both, and its per-field copy
|
|
190
|
+
* (`sealedFields.title`) is then an older title the sealed content's replaced (renamed since) - or, sealed without one, the same title
|
|
191
|
+
* `openSealedEvents()` already shows. */
|
|
192
|
+
export async function fullyOpenedEvent<T extends CalendarEvent | undefined>(event: T): Promise<T> {
|
|
193
|
+
if (!event || !isProtectedRecord(event) || isOpenedRecord(event)) {
|
|
194
|
+
return event;
|
|
195
|
+
}
|
|
196
|
+
const full = await openSealedRecord(event);
|
|
197
|
+
return isSealedContentOpened(event) ? { ...full, title: event.title } : full;
|
|
198
|
+
}
|
|
199
|
+
|
|
173
200
|
export async function listCalendarEvents(folderUid: string, client?: ApiClient): Promise<CalendarEvent[]> {
|
|
174
201
|
const events: CalendarEvent[] = [];
|
|
175
202
|
for (let page = 0; page < LIST_CALENDAR_EVENTS_MAX_PAGES; page++) {
|
|
@@ -182,11 +209,14 @@ export async function listCalendarEvents(folderUid: string, client?: ApiClient):
|
|
|
182
209
|
break;
|
|
183
210
|
}
|
|
184
211
|
}
|
|
185
|
-
return events;
|
|
212
|
+
return openSealedRecords(events, EVENT_LIST_FIELDS);
|
|
186
213
|
}
|
|
187
214
|
|
|
188
|
-
|
|
189
|
-
|
|
215
|
+
/** An event the server returned, opened. */
|
|
216
|
+
const opened = (event: CalendarEvent): Promise<CalendarEvent> => openSealedRecord(event);
|
|
217
|
+
|
|
218
|
+
export async function getCalendarEvent(uid: string, client?: ApiClient): Promise<CalendarEvent> {
|
|
219
|
+
return opened(await withClient<CalendarEvent>(client, `/mail/calendar-events/${encodeURIComponent(uid)}`));
|
|
190
220
|
}
|
|
191
221
|
|
|
192
222
|
export interface CalendarEventInput {
|
|
@@ -218,10 +248,18 @@ export interface CalendarEventInput {
|
|
|
218
248
|
guestsCanModify?: boolean;
|
|
219
249
|
guestsCanInviteOthers?: boolean;
|
|
220
250
|
guestsCanSeeGuestList?: boolean;
|
|
251
|
+
/**
|
|
252
|
+
* What the server sends a protected event's save-time invitations with (restapi `BaseCalendarEventRoute.takeInvitationContent()`).
|
|
253
|
+
* On a create, `{ defer: true }` holds them back for the save that follows - the event editor's, which attaches the video meeting and
|
|
254
|
+
* its link keys first - so attendees get one invitation, the one with the link. On an update, `{ defer: true }` sends none with this
|
|
255
|
+
* save either (they stay pending), and a content (`title`...) is what `updateCalendarEvent()` invites with when `current` couldn't be
|
|
256
|
+
* opened on this device - the content it opened wins otherwise.
|
|
257
|
+
*/
|
|
258
|
+
invitationContent?: Partial<InvitationContent> & { defer?: boolean };
|
|
221
259
|
}
|
|
222
260
|
|
|
223
|
-
export function createCalendarEvent(input: CalendarEventInput, client?: ApiClient): Promise<CalendarEvent> {
|
|
224
|
-
|
|
261
|
+
export async function createCalendarEvent(input: CalendarEventInput, client?: ApiClient): Promise<CalendarEvent> {
|
|
262
|
+
const created = await withClient<CalendarEvent>(client, "/mail/calendar-events", {
|
|
225
263
|
method: "POST",
|
|
226
264
|
body: JSON.stringify({
|
|
227
265
|
allDay: false,
|
|
@@ -233,6 +271,7 @@ export function createCalendarEvent(input: CalendarEventInput, client?: ApiClien
|
|
|
233
271
|
...input,
|
|
234
272
|
}),
|
|
235
273
|
});
|
|
274
|
+
return opened(created);
|
|
236
275
|
}
|
|
237
276
|
|
|
238
277
|
export interface UpdateCalendarEventInput extends Partial<CalendarEventInput> {
|
|
@@ -240,10 +279,87 @@ export interface UpdateCalendarEventInput extends Partial<CalendarEventInput> {
|
|
|
240
279
|
version: number;
|
|
241
280
|
}
|
|
242
281
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
282
|
+
/** Saves `input`. `current` is the event as this device shows it: for an event of a protected calendar, the write then sends only the
|
|
283
|
+
* content that changed, and the content the server invites with (`protectedEventWrite()`; `input.invitationContent` when `current` isn't
|
|
284
|
+
* opened, none with `{ defer: true }`). For an event whose video meeting is protected,
|
|
285
|
+
* every attendee's link key goes with it (`meetingLinkSecrets()`), for the server to add to their links in the invitations it sends. */
|
|
286
|
+
export function updateCalendarEvent(input: UpdateCalendarEventInput, client?: ApiClient, current?: CalendarEvent): Promise<CalendarEvent> {
|
|
287
|
+
// A move of a protected event to another mailbox is refused (`SealedMoveError`), before anything is opened or sent when `current` says so.
|
|
288
|
+
return guardSealedMove(current, input.mailboxUid, () => saveCalendarEvent(input, client, current));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The title restapi shows for an event whose title is sealed (its `PROTECTED_SUBJECT`). */
|
|
292
|
+
const PLACEHOLDER_TITLE = "[...]";
|
|
293
|
+
|
|
294
|
+
/** What `SealedEventContentError` says. */
|
|
295
|
+
export const SEALED_EVENT_CONTENT_MESSAGE =
|
|
296
|
+
"This event came from an invitation to a zero-access mailbox and its details are sealed, so its location or description can only be changed together with its title. Unlock your mailbox, open the event again and make the change there.";
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* restapi's refusal (409) of a location or description change to an event made from a zero-access invitation (`sealedContent`) sent
|
|
300
|
+
* with its placeholder title or none: resealing would lose the title. `saveCalendarEvent()` leaves out the placeholders of one it hasn't
|
|
301
|
+
* opened, so this comes back only when this device didn't know the event was sealed (it was filed sealed after it was loaded). An
|
|
302
|
+
* `ApiRequestError` (409), as `SealedMoveError` is, so the event dialog and the drag handler show its message.
|
|
303
|
+
*/
|
|
304
|
+
export class SealedEventContentError extends ApiRequestError {
|
|
305
|
+
constructor() {
|
|
306
|
+
super(SEALED_EVENT_CONTENT_MESSAGE, 409, "sealed-event-content");
|
|
307
|
+
this.name = "SealedEventContentError";
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Whether `err` is restapi's refusal of `body`, a write carrying a location or description without a real title. */
|
|
312
|
+
function isSealedContentTitleRefusal(err: unknown, body: Record<string, any>): boolean {
|
|
313
|
+
const hasValue = (field: string): boolean => body[field] !== undefined && body[field] !== null && body[field] !== "";
|
|
314
|
+
return (
|
|
315
|
+
err instanceof ApiRequestError &&
|
|
316
|
+
err.status === 409 &&
|
|
317
|
+
err.code === INVALID_REQUEST_CODE &&
|
|
318
|
+
(body.title === undefined || body.title === PLACEHOLDER_TITLE) &&
|
|
319
|
+
["location", "description", "descriptionHtml"].some(hasValue)
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function saveCalendarEvent(input: UpdateCalendarEventInput, client: ApiClient | undefined, current: CalendarEvent | undefined): Promise<CalendarEvent> {
|
|
324
|
+
current = await fullyOpenedEvent(current);
|
|
325
|
+
const { invitationContent: given, ...changes } = input;
|
|
326
|
+
const body: Record<string, any> = protectedEventWrite(current, changes);
|
|
327
|
+
if (given?.defer) {
|
|
328
|
+
body.invitationContent = { defer: true };
|
|
329
|
+
} else if (given?.title && !body.invitationContent && isProtectedRecord(current)) {
|
|
330
|
+
body.invitationContent = { title: given.title, location: given.location, description: given.description, descriptionHtml: given.descriptionHtml };
|
|
331
|
+
}
|
|
332
|
+
// A save that defers its invitations sends no link keys either: they go with the save that invites.
|
|
333
|
+
const secrets = given?.defer
|
|
334
|
+
? undefined
|
|
335
|
+
: await meetingLinkSecrets(
|
|
336
|
+
{ videoMeetingUid: input.videoMeetingUid ?? current?.videoMeetingUid, attendees: input.attendees ?? current?.attendees, organizer: current?.organizer ?? input.organizer },
|
|
337
|
+
client,
|
|
338
|
+
);
|
|
339
|
+
if (secrets) {
|
|
340
|
+
body.invitationContent = { ...(body.invitationContent ?? {}), meetingLinkSecrets: secrets };
|
|
341
|
+
}
|
|
342
|
+
let updated: CalendarEvent;
|
|
343
|
+
try {
|
|
344
|
+
updated = await withClient<CalendarEvent>(client, `/mail/calendar-events/${encodeURIComponent(input.uid)}`, {
|
|
345
|
+
method: "PUT",
|
|
346
|
+
body: JSON.stringify(body),
|
|
347
|
+
});
|
|
348
|
+
} catch (err) {
|
|
349
|
+
throw isSealedContentTitleRefusal(err, body) ? new SealedEventContentError() : err;
|
|
350
|
+
}
|
|
351
|
+
if (!updated.sealedContent || !isSealedContentOpened(current)) {
|
|
352
|
+
return opened(updated);
|
|
353
|
+
}
|
|
354
|
+
// An event made from a zero-access invitation, opened here, comes back with its placeholders: shown with the content this save sealed
|
|
355
|
+
// again (`protectedEventWrite()` sends all of it, or none and the sealed content stays), so a save that follows still writes it whole.
|
|
356
|
+
const content = Object.fromEntries(EVENT_CONTENT_FIELDS.map((field) => [field, field in body ? body[field] : current![field]]));
|
|
357
|
+
return markSealedContentOpened({
|
|
358
|
+
...(await opened(updated)),
|
|
359
|
+
title: content.title ?? "",
|
|
360
|
+
location: content.location ?? undefined,
|
|
361
|
+
description: content.description ?? null,
|
|
362
|
+
descriptionHtml: content.descriptionHtml ?? null,
|
|
247
363
|
});
|
|
248
364
|
}
|
|
249
365
|
|
|
@@ -262,17 +378,21 @@ export type AttendeeResponseInput = Exclude<AttendeeResponseStatus, "needsAction
|
|
|
262
378
|
* `"accepted"`/`"tentative"` the whole updated `CalendarEvent` comes back, with only the calling
|
|
263
379
|
* attendee's own `responseStatus` changed. On `"declined"`, the backend instead soft-deletes the
|
|
264
380
|
* mailbox's own copy of the event and returns only `{ uid }` — the caller should treat a decline the
|
|
265
|
-
* same as a delete rather than expecting an updated event back.
|
|
381
|
+
* same as a delete rather than expecting an updated event back. `current` (the event as this device shows it) names a protected event's
|
|
382
|
+
* reply by its real title (`invitationContentOf()`).
|
|
266
383
|
*/
|
|
267
|
-
export function respondToEvent(
|
|
384
|
+
export async function respondToEvent(
|
|
268
385
|
uid: string,
|
|
269
386
|
responseStatus: AttendeeResponseInput,
|
|
270
387
|
client?: ApiClient,
|
|
388
|
+
current?: CalendarEvent,
|
|
271
389
|
): Promise<CalendarEvent | { uid: string }> {
|
|
272
|
-
|
|
390
|
+
const invitationContent = invitationContentOf(await fullyOpenedEvent(current));
|
|
391
|
+
const result = await withClient<CalendarEvent | { uid: string }>(client, `/mail/calendar-events/${encodeURIComponent(uid)}/respond`, {
|
|
273
392
|
method: "POST",
|
|
274
|
-
body: JSON.stringify({ responseStatus }),
|
|
393
|
+
body: JSON.stringify(invitationContent ? { responseStatus, invitationContent } : { responseStatus }),
|
|
275
394
|
});
|
|
395
|
+
return "mailboxUid" in result ? opened(result) : result;
|
|
276
396
|
}
|
|
277
397
|
|
|
278
398
|
/** A change a guest asks the organizer for (`requestEventChange()`): only what is to differ. */
|
|
@@ -301,11 +421,17 @@ export interface EventChangeRequestResult {
|
|
|
301
421
|
* Asks the organizer to change an event, as a guest (not the organizer) whose copy allows it: the title, location, description or time need
|
|
302
422
|
* `guestsCanModify`, added guests need `guestsCanInviteOthers` (403 otherwise; 400 when nothing differs from what the event has, or a value is invalid).
|
|
303
423
|
* Nothing changes on this mailbox's own copy - the request is mailed to the organizer, and when the change is allowed it is applied there and
|
|
304
|
-
* arrives back later as an ordinary update of the invitation.
|
|
424
|
+
* arrives back later as an ordinary update of the invitation. `invitationContent` (the event's real content, `invitationContentOf()`)
|
|
425
|
+
* names a protected event in the request by its real title.
|
|
305
426
|
*/
|
|
306
|
-
export function requestEventChange(
|
|
427
|
+
export function requestEventChange(
|
|
428
|
+
uid: string,
|
|
429
|
+
request: EventChangeRequest,
|
|
430
|
+
client?: ApiClient,
|
|
431
|
+
invitationContent?: InvitationContent,
|
|
432
|
+
): Promise<EventChangeRequestResult> {
|
|
307
433
|
return withClient(client, `/mail/calendar-events/${encodeURIComponent(uid)}/request-change`, {
|
|
308
434
|
method: "POST",
|
|
309
|
-
body: JSON.stringify(request),
|
|
435
|
+
body: JSON.stringify(invitationContent ? { ...request, invitationContent } : request),
|
|
310
436
|
});
|
|
311
437
|
}
|
|
@@ -22,12 +22,17 @@ import {
|
|
|
22
22
|
UpdateCalendarEventInput,
|
|
23
23
|
createCalendarEvent,
|
|
24
24
|
deleteCalendarEvent,
|
|
25
|
+
fullyOpenedEvent,
|
|
25
26
|
getCalendarEvent,
|
|
26
27
|
listCalendarEvents,
|
|
27
28
|
updateCalendarEvent,
|
|
28
29
|
} from "./calendarApi.js";
|
|
29
|
-
import { CalendarOccurrence, fromEventWallClock, toEventWallClock } from "./recurrence.js";
|
|
30
|
-
import { ApiClient } from "../util/api.js";
|
|
30
|
+
import { CalendarOccurrence, fromEventWallClock, seriesEndDate, toEventWallClock } from "./recurrence.js";
|
|
31
|
+
import { ApiClient, ApiRequestError } from "../util/api.js";
|
|
32
|
+
import { KeysLockedError } from "../crypto/masterKey.js";
|
|
33
|
+
import { isOpenedRecord, isProtectedRecord } from "../zeroAccess/sealedRecords.js";
|
|
34
|
+
import { followMeetingKeyExpiry } from "../videoconf/protectedMeetings.js";
|
|
35
|
+
import { isSealedContentOpened, markSealedContentOpened } from "./protectedEvents.js";
|
|
31
36
|
|
|
32
37
|
/** `client`, given by every function below, is an explicit `ApiClient` from `createApiClient()` (e.g. one
|
|
33
38
|
* account of a multi-account app) to call instead of the default global `apiFetch()` - see `withClient()`'s
|
|
@@ -36,6 +41,24 @@ import { ApiClient } from "../util/api.js";
|
|
|
36
41
|
|
|
37
42
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
38
43
|
|
|
44
|
+
/** What `SealedOccurrenceError` says. */
|
|
45
|
+
export const SEALED_OCCURRENCE_MESSAGE =
|
|
46
|
+
"This event came from an invitation to a zero-access mailbox, so one occurrence of it can't be changed on its own when it moves to another calendar or the server can't keep it protected. Change the entire series, or ask the organizer to.";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One occurrence of an event made from an invitation that reached a zero-access mailbox (`sealedContent`) that `detachOccurrence()` can't
|
|
50
|
+
* split off without its content being stored readable: one moved to another calendar (restapi re-seals a detached occurrence's content
|
|
51
|
+
* only in the calendar of the series it belongs to; it refuses to move a sealed event for the same reason), or one a server stored
|
|
52
|
+
* unsealed (one from before restapi re-sealed such a create - the copy is deleted at once). An `ApiRequestError` (409), as
|
|
53
|
+
* `SealedMoveError` is, so the event dialog and the drag handler show its message.
|
|
54
|
+
*/
|
|
55
|
+
export class SealedOccurrenceError extends ApiRequestError {
|
|
56
|
+
constructor() {
|
|
57
|
+
super(SEALED_OCCURRENCE_MESSAGE, 409, "sealed-occurrence");
|
|
58
|
+
this.name = "SealedOccurrenceError";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
39
62
|
function positiveModulo(value: number, modulus: number): number {
|
|
40
63
|
return ((value % modulus) + modulus) % modulus;
|
|
41
64
|
}
|
|
@@ -49,6 +72,7 @@ function addException(event: CalendarEvent, occurrenceStart: string, client?: Ap
|
|
|
49
72
|
recurrenceRule: { ...rule, exceptions: [...rule.exceptions, occurrenceStart] },
|
|
50
73
|
},
|
|
51
74
|
client,
|
|
75
|
+
event,
|
|
52
76
|
);
|
|
53
77
|
}
|
|
54
78
|
|
|
@@ -84,7 +108,7 @@ export async function saveEventSeries(
|
|
|
84
108
|
// master's existing (unchanged) instant onto a different wall clock, exactly as the doc comment above
|
|
85
109
|
// promises - so all three fields, not just startDate, gate entry into the shift path below.
|
|
86
110
|
if (fields.startDate === undefined && fields.timezone === undefined && fields.allDay === undefined) {
|
|
87
|
-
return updateCalendarEvent(update, client);
|
|
111
|
+
return updateCalendarEvent(update, client, occurrence);
|
|
88
112
|
}
|
|
89
113
|
const master = await getCalendarEvent(occurrence.uid, client);
|
|
90
114
|
const newTimezone = fields.timezone ?? master.timezone;
|
|
@@ -93,7 +117,7 @@ export async function saveEventSeries(
|
|
|
93
117
|
const newStartInstant = Date.parse(fields.startDate ?? master.startDate);
|
|
94
118
|
const deltaWallMs = toEventWallClock(newStartInstant, newTimezone, newAllDay) - oldStartWall;
|
|
95
119
|
if (deltaWallMs === 0 && newTimezone === master.timezone && newAllDay === master.allDay) {
|
|
96
|
-
return updateCalendarEvent(update, client);
|
|
120
|
+
return updateCalendarEvent(update, client, occurrence);
|
|
97
121
|
}
|
|
98
122
|
// An occurrence is identified by its nominal wall-clock start (the rule's date + the master's time of day),
|
|
99
123
|
// not by the instant's own wall time: one that fell in a DST gap was pushed forward (02:30 -> 03:30 EDT),
|
|
@@ -112,7 +136,7 @@ export async function saveEventSeries(
|
|
|
112
136
|
if (rule) {
|
|
113
137
|
update.recurrenceRule = { ...rule, exceptions: rule.exceptions.map(shift) };
|
|
114
138
|
}
|
|
115
|
-
const saved = await updateCalendarEvent(update, client);
|
|
139
|
+
const saved = await updateCalendarEvent(update, client, occurrence);
|
|
116
140
|
|
|
117
141
|
let detachedOccurrenceSyncFailed = false;
|
|
118
142
|
try {
|
|
@@ -126,6 +150,7 @@ export async function saveEventSeries(
|
|
|
126
150
|
await updateCalendarEvent(
|
|
127
151
|
{ uid: event.uid, version: event.version, recurrenceId: shift(event.recurrenceId!) } as UpdateCalendarEventInput,
|
|
128
152
|
client,
|
|
153
|
+
event,
|
|
129
154
|
);
|
|
130
155
|
} catch {
|
|
131
156
|
detachedOccurrenceSyncFailed = true;
|
|
@@ -148,12 +173,34 @@ export async function saveEventSeries(
|
|
|
148
173
|
* original start as its `recurrenceId` (the RFC5545 way of identifying a modified instance), and never
|
|
149
174
|
* carries the series' `recurrenceRule` — even if `fields` includes one (e.g. the event modal's full
|
|
150
175
|
* field set).
|
|
176
|
+
*
|
|
177
|
+
* A protected occurrence (zero-access) is copied from its opened content: one that can't be opened here - its mailbox is locked - holds
|
|
178
|
+
* only placeholders, which the new event would store as its real title, location and description. It throws `KeysLockedError` instead,
|
|
179
|
+
* before anything is written. One of an event made from a zero-access invitation (`sealedContent`) is sent opened too: restapi's create
|
|
180
|
+
* seals it again to the series' key (`prepareCreate()` finds the series by `icalUid` in the same mailbox), storing only placeholders, and
|
|
181
|
+
* the event returned is marked opened with the content sent. Moved to another calendar it throws `SealedOccurrenceError` before anything
|
|
182
|
+
* is sent (the series isn't there to seal it to), and a copy the server returns unsealed is deleted and refused the same way.
|
|
151
183
|
*/
|
|
152
184
|
export async function detachOccurrence(
|
|
153
185
|
occurrence: CalendarOccurrence,
|
|
154
186
|
fields: Partial<CalendarEventInput>,
|
|
155
187
|
client?: ApiClient,
|
|
156
188
|
): Promise<CalendarEvent> {
|
|
189
|
+
occurrence = await fullyOpenedEvent(occurrence);
|
|
190
|
+
// Nor one of an event made from an invitation to a zero-access mailbox (`sealedContent`) whose content wasn't opened - asked of the
|
|
191
|
+
// mark, not of its title: an untitled one opens to an empty title, and a title can be anything.
|
|
192
|
+
if ((isProtectedRecord(occurrence) && !isOpenedRecord(occurrence)) || (occurrence.sealedContent && !isSealedContentOpened(occurrence))) {
|
|
193
|
+
throw new KeysLockedError("This event is protected and your mailbox is locked. Unlock it to change one occurrence of the event.");
|
|
194
|
+
}
|
|
195
|
+
const sealedContent: boolean = !!occurrence.sealedContent;
|
|
196
|
+
// Sealed again only in the series' own calendar (see `SealedOccurrenceError`).
|
|
197
|
+
if (
|
|
198
|
+
sealedContent &&
|
|
199
|
+
((fields.mailboxUid !== undefined && fields.mailboxUid !== occurrence.mailboxUid) ||
|
|
200
|
+
(fields.folderUid !== undefined && fields.folderUid !== occurrence.folderUid))
|
|
201
|
+
) {
|
|
202
|
+
throw new SealedOccurrenceError();
|
|
203
|
+
}
|
|
157
204
|
const originalStart = occurrence.recurrenceId!;
|
|
158
205
|
// `icalUid`/`recurrenceId` aren't part of `CalendarEventInput`, but `@rapidmx/restapi`'s
|
|
159
206
|
// `CalendarEvent` model accepts both on create (and `createCalendarEvent` already sends `icalUid`).
|
|
@@ -186,13 +233,27 @@ export async function detachOccurrence(
|
|
|
186
233
|
recurrenceId: originalStart,
|
|
187
234
|
};
|
|
188
235
|
const created = await createCalendarEvent(input, client);
|
|
236
|
+
if (sealedContent && !created.sealedContent) {
|
|
237
|
+
// A server that stored the opened content readable: taken back at once, and the occurrence left as it was.
|
|
238
|
+
await deleteCalendarEvent(created.uid, created.version, client).catch(() => undefined);
|
|
239
|
+
throw new SealedOccurrenceError();
|
|
240
|
+
}
|
|
189
241
|
try {
|
|
190
242
|
await addException(occurrence, originalStart, client);
|
|
191
243
|
} catch (err) {
|
|
192
244
|
await deleteCalendarEvent(created.uid, created.version, client).catch(() => undefined);
|
|
193
245
|
throw err;
|
|
194
246
|
}
|
|
195
|
-
|
|
247
|
+
// Stored with placeholders: shown with the content it was given, as an opened event.
|
|
248
|
+
return sealedContent
|
|
249
|
+
? markSealedContentOpened({
|
|
250
|
+
...created,
|
|
251
|
+
title: input.title,
|
|
252
|
+
location: input.location,
|
|
253
|
+
description: input.description ?? null,
|
|
254
|
+
descriptionHtml: input.descriptionHtml ?? null,
|
|
255
|
+
})
|
|
256
|
+
: created;
|
|
196
257
|
}
|
|
197
258
|
|
|
198
259
|
/** Deletes an entire recurring series (or a genuinely non-recurring event) outright. */
|
|
@@ -211,14 +272,22 @@ export function deleteEventOccurrence(occurrence: CalendarOccurrence, client?: A
|
|
|
211
272
|
* drag behavior, which never silently reschedules an entire series from a single dragged instance;
|
|
212
273
|
* moving the whole series is only ever done explicitly, via the event modal's "entire series" option.
|
|
213
274
|
*/
|
|
275
|
+
/** Saves new times of non-recurring `occurrence`, then moves its video meeting's key expiry to follow the new end: a protected meeting's key is
|
|
276
|
+
* discarded an hour after the end it was made for, so a meeting dragged later couldn't be joined. Best effort - it never fails the move. */
|
|
277
|
+
async function reschedule(occurrence: CalendarOccurrence, times: { startDate?: string; endDate: string }, client: ApiClient | undefined): Promise<CalendarEvent> {
|
|
278
|
+
const saved = await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...times }, client, occurrence);
|
|
279
|
+
if (saved.videoMeetingUid) {
|
|
280
|
+
await followMeetingKeyExpiry(saved.videoMeetingUid, seriesEndDate(saved), client);
|
|
281
|
+
}
|
|
282
|
+
return saved;
|
|
283
|
+
}
|
|
284
|
+
|
|
214
285
|
export function moveOccurrence(occurrence: CalendarOccurrence, deltaMs: number, client?: ApiClient): Promise<CalendarEvent> {
|
|
215
286
|
const fields = {
|
|
216
287
|
startDate: new Date(new Date(occurrence.startDate).getTime() + deltaMs).toISOString(),
|
|
217
288
|
endDate: new Date(new Date(occurrence.endDate).getTime() + deltaMs).toISOString(),
|
|
218
289
|
};
|
|
219
|
-
return occurrence.isRecurringOccurrence
|
|
220
|
-
? detachOccurrence(occurrence, fields, client)
|
|
221
|
-
: updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...fields }, client);
|
|
290
|
+
return occurrence.isRecurringOccurrence ? detachOccurrence(occurrence, fields, client) : reschedule(occurrence, fields, client);
|
|
222
291
|
}
|
|
223
292
|
|
|
224
293
|
/**
|
|
@@ -230,7 +299,5 @@ export function resizeOccurrenceEnd(occurrence: CalendarOccurrence, newEndDate:
|
|
|
230
299
|
const start = new Date(occurrence.startDate);
|
|
231
300
|
const minEnd = new Date(start.getTime() + 15 * 60_000);
|
|
232
301
|
const endDate = (newEndDate.getTime() > minEnd.getTime() ? newEndDate : minEnd).toISOString();
|
|
233
|
-
return occurrence.isRecurringOccurrence
|
|
234
|
-
? detachOccurrence(occurrence, { endDate }, client)
|
|
235
|
-
: updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, endDate }, client);
|
|
302
|
+
return occurrence.isRecurringOccurrence ? detachOccurrence(occurrence, { endDate }, client) : reschedule(occurrence, { endDate }, client);
|
|
236
303
|
}
|
|
@@ -9,7 +9,19 @@
|
|
|
9
9
|
* it now stands, ready to draw. */
|
|
10
10
|
|
|
11
11
|
import { ApiClient, ApiRequestError, withClient } from "../util/api.js";
|
|
12
|
-
import
|
|
12
|
+
import { followMeetingKeyExpiry, meetingLinkSecrets } from "../videoconf/protectedMeetings.js";
|
|
13
|
+
import { isProtectedRecord } from "../zeroAccess/sealedRecords.js";
|
|
14
|
+
import { type CalendarEvent, type EventVisibility, type GuestPermissions, getCalendarEvent } from "./calendarApi.js";
|
|
15
|
+
import { type InvitationContent, invitationContentOf } from "./protectedEvents.js";
|
|
16
|
+
import { seriesEndDate } from "./recurrence.js";
|
|
17
|
+
import { sealedInviteCalendar } from "./sealedInvites.js";
|
|
18
|
+
|
|
19
|
+
/** The calendar file to send for `messageUid` - a zero-access message this device decrypted (`sealedInvites.ts`) - as the request body's
|
|
20
|
+
* `ics`, or nothing for a message the server reads itself. */
|
|
21
|
+
function sealedSource(messageUid: string): { ics?: string } {
|
|
22
|
+
const ics = sealedInviteCalendar(messageUid);
|
|
23
|
+
return ics !== undefined ? { ics } : {};
|
|
24
|
+
}
|
|
13
25
|
|
|
14
26
|
/** What the reader can answer an invitation with. */
|
|
15
27
|
export type InviteResponse = "accepted" | "tentative" | "declined";
|
|
@@ -104,8 +116,15 @@ export interface MessageInvite {
|
|
|
104
116
|
* common, expected answer for a message that merely has an attachment - and rejects for any other failure.
|
|
105
117
|
*/
|
|
106
118
|
export async function getMessageInvite(messageUid: string, client?: ApiClient): Promise<MessageInvite | null> {
|
|
119
|
+
const source = sealedSource(messageUid);
|
|
107
120
|
try {
|
|
108
|
-
|
|
121
|
+
// A zero-access message's invitation is read from the calendar file decrypted here (`POST .../view`).
|
|
122
|
+
return source.ics !== undefined
|
|
123
|
+
? await withClient<MessageInvite>(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/view`, {
|
|
124
|
+
method: "POST",
|
|
125
|
+
body: JSON.stringify(source),
|
|
126
|
+
})
|
|
127
|
+
: await withClient<MessageInvite>(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}`);
|
|
109
128
|
} catch (err) {
|
|
110
129
|
if (err instanceof ApiRequestError && err.status === 404) {
|
|
111
130
|
return null;
|
|
@@ -122,13 +141,17 @@ export async function getMessageInvite(messageUid: string, client?: ApiClient):
|
|
|
122
141
|
export function respondToMessageInvite(messageUid: string, responseStatus: InviteResponse, client?: ApiClient): Promise<MessageInvite> {
|
|
123
142
|
return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/respond`, {
|
|
124
143
|
method: "POST",
|
|
125
|
-
body: JSON.stringify({ responseStatus }),
|
|
144
|
+
body: JSON.stringify({ responseStatus, ...sealedSource(messageUid) }),
|
|
126
145
|
});
|
|
127
146
|
}
|
|
128
147
|
|
|
129
148
|
/** Takes a canceled meeting off the reader's calendar. Resolves with the invitation as it now stands. */
|
|
130
149
|
export function removeMessageInvite(messageUid: string, client?: ApiClient): Promise<MessageInvite> {
|
|
131
|
-
|
|
150
|
+
const source = sealedSource(messageUid);
|
|
151
|
+
return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/remove`, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
...(source.ics !== undefined ? { body: JSON.stringify(source) } : {}),
|
|
154
|
+
});
|
|
132
155
|
}
|
|
133
156
|
|
|
134
157
|
/** A different time to suggest to the organizer. */
|
|
@@ -144,11 +167,81 @@ export interface ProposedTime {
|
|
|
144
167
|
export function proposeNewTime(messageUid: string, proposal: ProposedTime, client?: ApiClient): Promise<MessageInvite> {
|
|
145
168
|
return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/propose`, {
|
|
146
169
|
method: "POST",
|
|
147
|
-
body: JSON.stringify(proposal),
|
|
170
|
+
body: JSON.stringify({ ...proposal, ...sealedSource(messageUid) }),
|
|
148
171
|
});
|
|
149
172
|
}
|
|
150
173
|
|
|
151
|
-
/**
|
|
152
|
-
|
|
153
|
-
|
|
174
|
+
/** Opens an event's `sealedContent` with the mailbox's unlocked keys (`apps/shared/calendar/sealedEvents.ts` `openSealedEvent()`), marking it
|
|
175
|
+
* opened, or returns it as it is when it can't. */
|
|
176
|
+
export type SealedContentOpener = (event: CalendarEvent) => Promise<CalendarEvent>;
|
|
177
|
+
|
|
178
|
+
/** What `acceptProposal()` refuses with, before sending anything, for a protected meeting this device can't open (its mailbox is locked). */
|
|
179
|
+
export const PROTECTED_PROPOSAL_MESSAGE = "This meeting is protected. Unlock your mailbox to accept the proposed time, so its guests can be sent the new time.";
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* What the server needs to send the moved meeting's invitations itself (`invitationContent`): for a meeting of a protected calendar
|
|
183
|
+
* (`sealedFields`), or made from a zero-access invitation (`sealedContent`, opened with `openSealedContent`), its real title, location and
|
|
184
|
+
* description, and for a meeting protected by a per-meeting key every guest's link key
|
|
185
|
+
* (`meetingLinkSecrets`) - with the event as read. No content for a meeting that needs neither (the server's scheduling job invites for it),
|
|
186
|
+
* and nothing at all for one that isn't on the calendar or can't be read. A protected meeting this device can't open is refused (`PROTECTED_PROPOSAL_MESSAGE`) rather than left to the server's 400.
|
|
187
|
+
*/
|
|
188
|
+
async function proposalInvitations(
|
|
189
|
+
calendarEventUid: string | undefined,
|
|
190
|
+
client: ApiClient | undefined,
|
|
191
|
+
openSealedContent: SealedContentOpener | undefined,
|
|
192
|
+
): Promise<{ event?: CalendarEvent; invitationContent?: Partial<InvitationContent> & { meetingLinkSecrets?: Record<string, string> } }> {
|
|
193
|
+
if (!calendarEventUid) {
|
|
194
|
+
return {};
|
|
195
|
+
}
|
|
196
|
+
let event: CalendarEvent;
|
|
197
|
+
try {
|
|
198
|
+
event = await getCalendarEvent(calendarEventUid, client);
|
|
199
|
+
} catch {
|
|
200
|
+
// Unreadable here: the server, which reads it, decides whether the proposal can be accepted without them.
|
|
201
|
+
return {};
|
|
202
|
+
}
|
|
203
|
+
// A meeting made from a zero-access invitation keeps its content sealed whole (`sealedContent`), which `getCalendarEvent()` doesn't
|
|
204
|
+
// open: opened here, it is invited for with its real title, location and description.
|
|
205
|
+
if (event.sealedContent && openSealedContent) {
|
|
206
|
+
event = await openSealedContent(event);
|
|
207
|
+
}
|
|
208
|
+
const content = invitationContentOf(event);
|
|
209
|
+
if ((isProtectedRecord(event) || event.sealedContent) && !content && (event as { encryptionOrigin?: string }).encryptionOrigin !== "originated") {
|
|
210
|
+
throw new ApiRequestError(PROTECTED_PROPOSAL_MESSAGE, 400);
|
|
211
|
+
}
|
|
212
|
+
const secrets = await meetingLinkSecrets({ videoMeetingUid: event.videoMeetingUid, attendees: event.attendees, organizer: event.organizer }, client);
|
|
213
|
+
if (!content && !secrets) {
|
|
214
|
+
return { event };
|
|
215
|
+
}
|
|
216
|
+
return { event, invitationContent: { ...(content ?? {}), ...(secrets ? { meetingLinkSecrets: secrets } : {}) } };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Accepts an attendee's proposed time (a `COUNTER`) for a meeting the reader organizes (`calendarEventUid`, the invitation's
|
|
221
|
+
* `calendarEventUid`): the server moves the event and sends the attendees the update. For a protected meeting - of a calendar zero-access
|
|
222
|
+
* encryption protects, or with a per-meeting key - the server can't compose the invitations by itself: this sends the decrypted title,
|
|
223
|
+
* location and description and the guests' link keys with it (`invitationContent`). Without them (the mailbox locked, or the meeting's key
|
|
224
|
+
* doesn't open here) the server answers 400 and nothing moves. A meeting made from a zero-access invitation (`sealedContent`) is opened
|
|
225
|
+
* with `openSealedContent` (the app passes `openSealedEvent()`); one that can't be is refused before anything is sent. A meeting with a
|
|
226
|
+
* video meeting has its key expiry moved to follow the new time (`followMeetingKeyExpiry()`, best effort). Resolves with the invitation as
|
|
227
|
+
* it now stands.
|
|
228
|
+
*/
|
|
229
|
+
export async function acceptProposal(
|
|
230
|
+
messageUid: string,
|
|
231
|
+
client?: ApiClient,
|
|
232
|
+
calendarEventUid?: string,
|
|
233
|
+
openSealedContent?: SealedContentOpener,
|
|
234
|
+
): Promise<MessageInvite> {
|
|
235
|
+
const source = sealedSource(messageUid);
|
|
236
|
+
const { event, invitationContent } = await proposalInvitations(calendarEventUid, client, openSealedContent);
|
|
237
|
+
const body = { ...source, ...(invitationContent ? { invitationContent } : {}) };
|
|
238
|
+
const accepted = await withClient<MessageInvite>(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/accept-proposal`, {
|
|
239
|
+
method: "POST",
|
|
240
|
+
...(Object.keys(body).length > 0 ? { body: JSON.stringify(body) } : {}),
|
|
241
|
+
});
|
|
242
|
+
// A `COUNTER`'s `startDate`/`endDate` are the proposed time, which the meeting now has.
|
|
243
|
+
if (event?.videoMeetingUid && accepted.startDate && accepted.endDate) {
|
|
244
|
+
await followMeetingKeyExpiry(event.videoMeetingUid, seriesEndDate({ ...event, startDate: accepted.startDate, endDate: accepted.endDate }), client);
|
|
245
|
+
}
|
|
246
|
+
return accepted;
|
|
154
247
|
}
|