@rapidmx/web-client 0.4.0 → 0.6.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/README.md +88 -0
- package/apps/admin/audit-log/index.tsx +35 -8
- package/apps/admin/data-requests/index.tsx +480 -485
- package/apps/admin/distribution-lists/[uid].tsx +19 -1
- package/apps/admin/escrow-scopes/[uid].tsx +348 -237
- package/apps/admin/escrow-scopes/new/index.tsx +8 -2
- package/apps/admin/index.tsx +19 -1
- package/apps/admin/ingest-queue/index.tsx +44 -5
- package/apps/admin/mailboxes/[uid].tsx +211 -184
- package/apps/admin/quarantine/index.tsx +46 -14
- package/apps/admin/transport-rules/[uid].tsx +30 -1
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
- package/apps/admin/transport-rules/index.tsx +24 -6
- package/apps/admin/transport-rules/new/index.tsx +30 -1
- package/apps/escrow/_layout.tsx +2 -3
- package/apps/escrow/audit-log/index.tsx +196 -168
- package/apps/escrow/matters/[uid].tsx +621 -521
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
- package/apps/shared/components/admin/layout/AdminShell.tsx +28 -9
- package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
- package/apps/shared/components/admin/settings/BrandingForm.tsx +15 -4
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +18 -7
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +10 -2
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +6 -0
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +45 -12
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -595
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -98
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +49 -13
- package/apps/shared/components/admin/setup/SetupWizard.tsx +150 -34
- package/apps/shared/components/admin/signOut.ts +79 -0
- package/apps/shared/components/admin/usePagedList.tsx +129 -0
- package/apps/shared/components/calendar/EventModal.tsx +717 -549
- package/apps/shared/components/calendar/MonthView.tsx +173 -139
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
- package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
- package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
- package/apps/shared/components/calendar/allDay.ts +124 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +194 -192
- package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
- package/apps/shared/components/contacts/ContactForm.tsx +377 -348
- package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
- package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
- package/apps/shared/components/contacts/contactKeys.ts +78 -0
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +199 -197
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
- package/apps/shared/components/layout/AppShell.tsx +123 -33
- package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +389 -298
- package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
- package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -128
- package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
- package/apps/shared/components/mail/MessageDetailPane.tsx +1318 -630
- package/apps/shared/components/mail/compose/ComposeContext.tsx +20 -15
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +854 -132
- package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +420 -408
- package/apps/shared/components/mail/pinnedSigners.ts +124 -0
- package/apps/shared/components/mail/verificationSeals.ts +125 -0
- package/apps/shared/components/mail/writableMailboxes.ts +101 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
- package/apps/shared/components/settings/layout/SettingsShell.tsx +23 -9
- package/apps/shared/components/tasks/layout/TasksShell.tsx +201 -199
- package/apps/shared/mail/listAllPages.ts +39 -0
- package/apps/shared/plugins/pluginNav.ts +73 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +64 -55
- package/apps/shared/search/localIndexBuilder.ts +481 -179
- package/apps/shared/search/localIndexRpcClient.ts +231 -29
- package/apps/shared/search/localIndexSchema.ts +15 -6
- package/apps/shared/search/localIndexStorage.ts +98 -0
- package/apps/shared/search/localIndexVFS.ts +32 -1
- package/apps/shared/search/localIndexWorker.ts +648 -186
- package/apps/www/calendar/index.tsx +438 -417
- package/apps/www/contacts/[uid].tsx +21 -2
- package/apps/www/contacts/index.tsx +116 -12
- package/apps/www/index.tsx +1314 -1041
- package/apps/www/messages/[uid].tsx +18 -6
- package/apps/www/settings/auto-reply/index.tsx +134 -129
- package/apps/www/settings/encryption/index.tsx +739 -216
- package/apps/www/settings/filters/[uid].tsx +171 -155
- package/apps/www/settings/filters/new/index.tsx +8 -1
- package/apps/www/settings/focused-inbox/index.tsx +150 -148
- package/apps/www/settings/privacy/index.tsx +494 -436
- package/apps/www/settings/read-receipts/index.tsx +148 -125
- package/apps/www/settings/sharing/index.tsx +17 -14
- package/apps/www/tasks/index.tsx +638 -579
- package/dist/apps/admin/_404.d.ts +2 -0
- package/dist/apps/admin/_500.d.ts +2 -0
- package/dist/apps/admin/_layout.d.ts +8 -0
- package/dist/apps/admin/audit-log/index.d.ts +11 -0
- package/dist/apps/admin/audit-log/index.js +38 -8
- package/dist/apps/admin/branding/index.d.ts +3 -0
- package/dist/apps/admin/data-requests/index.d.ts +3 -0
- package/dist/apps/admin/data-requests/index.js +52 -72
- package/dist/apps/admin/distribution-lists/[uid].d.ts +7 -0
- package/dist/apps/admin/distribution-lists/[uid].js +1 -1
- package/dist/apps/admin/distribution-lists/index.d.ts +3 -0
- package/dist/apps/admin/distribution-lists/new/index.d.ts +3 -0
- package/dist/apps/admin/domains/[uid].d.ts +7 -0
- package/dist/apps/admin/domains/index.d.ts +3 -0
- package/dist/apps/admin/domains/new/index.d.ts +3 -0
- package/dist/apps/admin/encryption-policy/index.d.ts +3 -0
- package/dist/apps/admin/escrow-scopes/[uid].d.ts +21 -0
- package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
- package/dist/apps/admin/escrow-scopes/index.d.ts +3 -0
- package/dist/apps/admin/escrow-scopes/new/index.d.ts +3 -0
- package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
- package/dist/apps/admin/index.d.ts +3 -0
- package/dist/apps/admin/index.js +4 -1
- package/dist/apps/admin/ingest-queue/index.d.ts +4 -0
- package/dist/apps/admin/ingest-queue/index.js +24 -6
- package/dist/apps/admin/mailbox-policy/index.d.ts +3 -0
- package/dist/apps/admin/mailboxes/[uid].d.ts +7 -0
- package/dist/apps/admin/mailboxes/[uid].js +20 -4
- package/dist/apps/admin/mailboxes/new/index.d.ts +3 -0
- package/dist/apps/admin/plugins/index.d.ts +3 -0
- package/dist/apps/admin/quarantine/index.d.ts +6 -0
- package/dist/apps/admin/quarantine/index.js +20 -11
- package/dist/apps/admin/retention-policy/index.d.ts +3 -0
- package/dist/apps/admin/setup/index.d.ts +3 -0
- package/dist/apps/admin/transport-rules/[uid].d.ts +7 -0
- package/dist/apps/admin/transport-rules/[uid].js +18 -2
- package/dist/apps/admin/transport-rules/_transportRuleConfig.d.ts +21 -0
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
- package/dist/apps/admin/transport-rules/index.d.ts +3 -0
- package/dist/apps/admin/transport-rules/index.js +22 -6
- package/dist/apps/admin/transport-rules/new/index.d.ts +3 -0
- package/dist/apps/admin/transport-rules/new/index.js +18 -2
- package/dist/apps/escrow/_layout.d.ts +10 -0
- package/dist/apps/escrow/_layout.js +3 -3
- package/dist/apps/escrow/audit-log/index.d.ts +9 -0
- package/dist/apps/escrow/audit-log/index.js +26 -1
- package/dist/apps/escrow/index.d.ts +3 -0
- package/dist/apps/escrow/matters/[uid].d.ts +7 -0
- package/dist/apps/escrow/matters/[uid].js +77 -42
- package/dist/apps/escrow/matters/new/index.d.ts +3 -0
- package/dist/apps/shared/components/admin/distributionLists/MemberListCard.d.ts +17 -0
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.d.ts +32 -0
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +32 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +15 -6
- package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.d.ts +12 -0
- package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
- package/dist/apps/shared/components/admin/mailboxes/MailboxTable.d.ts +6 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.d.ts +19 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +12 -0
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
- package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +9 -0
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +15 -4
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.d.ts +12 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +13 -7
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +12 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +7 -2
- package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.d.ts +9 -0
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.d.ts +19 -0
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +6 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +10 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +34 -12
- package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +4 -0
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +390 -78
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +9 -0
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +40 -11
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.d.ts +14 -0
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +29 -13
- package/dist/apps/shared/components/admin/setup/SetupWizard.d.ts +36 -0
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +87 -21
- package/dist/apps/shared/components/admin/signOut.d.ts +23 -0
- package/dist/apps/shared/components/admin/signOut.js +75 -0
- package/dist/apps/shared/components/admin/usePagedList.d.ts +39 -0
- package/dist/apps/shared/components/admin/usePagedList.js +99 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.d.ts +26 -0
- package/dist/apps/shared/components/calendar/EventModal.d.ts +46 -0
- package/dist/apps/shared/components/calendar/EventModal.js +142 -21
- package/dist/apps/shared/components/calendar/MonthView.d.ts +15 -0
- package/dist/apps/shared/components/calendar/MonthView.js +12 -5
- package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +20 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
- package/dist/apps/shared/components/calendar/ResourcePicker.d.ts +19 -0
- package/dist/apps/shared/components/calendar/SplitDayView.d.ts +28 -0
- package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
- package/dist/apps/shared/components/calendar/TimeGridView.d.ts +17 -0
- package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
- package/dist/apps/shared/components/calendar/allDay.d.ts +51 -0
- package/dist/apps/shared/components/calendar/allDay.js +111 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +40 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +3 -3
- package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +23 -0
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
- package/dist/apps/shared/components/contacts/ContactForm.d.ts +20 -0
- package/dist/apps/shared/components/contacts/ContactForm.js +32 -10
- package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +40 -0
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +25 -0
- package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +40 -0
- package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
- package/dist/apps/shared/components/contacts/contactKeys.d.ts +29 -0
- package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
- package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +21 -0
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +2 -2
- package/dist/apps/shared/components/escrow/layout/EscrowShell.d.ts +25 -0
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
- package/dist/apps/shared/components/forms/StringListField.d.ts +22 -0
- package/dist/apps/shared/components/layout/AppShell.d.ts +49 -0
- package/dist/apps/shared/components/layout/AppShell.js +97 -26
- package/dist/apps/shared/components/layout/BrandingChrome.d.ts +18 -0
- package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.d.ts +51 -0
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +49 -12
- package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +13 -0
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
- package/dist/apps/shared/components/layout/RecoveryCodeUnlock.d.ts +66 -0
- package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +45 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +73 -14
- package/dist/apps/shared/components/layout/UserMenu.d.ts +23 -0
- package/dist/apps/shared/components/mail/ConversationList.d.ts +11 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +23 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +93 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +441 -36
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +58 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.d.ts +15 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +37 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +699 -128
- package/dist/apps/shared/components/mail/compose/EmojiPicker.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/GifPicker.d.ts +13 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +34 -0
- package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.d.ts +16 -0
- package/dist/apps/shared/components/mail/compose/composeFlushRegistry.d.ts +27 -0
- package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +57 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +13 -4
- package/dist/apps/shared/components/mail/pinnedSigners.d.ts +33 -0
- package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
- package/dist/apps/shared/components/mail/verificationSeals.d.ts +22 -0
- package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
- package/dist/apps/shared/components/mail/writableMailboxes.d.ts +15 -0
- package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
- package/dist/apps/shared/components/rules/RuleBuilder.d.ts +70 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +41 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +19 -9
- package/dist/apps/shared/components/tasks/TasksSidebar.d.ts +41 -0
- package/dist/apps/shared/components/tasks/TasksToolbar.d.ts +14 -0
- package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +24 -0
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +2 -2
- package/dist/apps/shared/mail/findWellKnownFolderUid.d.ts +5 -0
- package/dist/apps/shared/mail/listAllPages.d.ts +17 -0
- package/dist/apps/shared/mail/listAllPages.js +25 -0
- package/dist/apps/shared/plugins/pluginNav.d.ts +39 -0
- package/dist/apps/shared/plugins/pluginNav.js +33 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +13 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +58 -54
- package/dist/apps/shared/search/localIndexBlockCipher.d.ts +36 -0
- package/dist/apps/shared/search/localIndexBuilder.d.ts +85 -0
- package/dist/apps/shared/search/localIndexBuilder.js +292 -37
- package/dist/apps/shared/search/localIndexKey.d.ts +4 -0
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +71 -0
- package/dist/apps/shared/search/localIndexRpcClient.js +191 -26
- package/dist/apps/shared/search/localIndexSchema.d.ts +111 -0
- package/dist/apps/shared/search/localIndexSchema.js +12 -6
- package/dist/apps/shared/search/localIndexSizePreference.d.ts +27 -0
- package/dist/apps/shared/search/localIndexStorage.d.ts +28 -0
- package/dist/apps/shared/search/localIndexStorage.js +86 -0
- package/dist/apps/shared/search/localIndexVFS.d.ts +101 -0
- package/dist/apps/shared/search/localIndexVFS.js +68 -40
- package/dist/apps/shared/search/localIndexWorker.d.ts +145 -0
- package/dist/apps/shared/search/localIndexWorker.js +519 -173
- package/dist/apps/shared/search/searchTier2.d.ts +30 -0
- package/dist/apps/www/_404.d.ts +2 -0
- package/dist/apps/www/_500.d.ts +2 -0
- package/dist/apps/www/_layout.d.ts +14 -0
- package/dist/apps/www/calendar/index.d.ts +3 -0
- package/dist/apps/www/calendar/index.js +28 -10
- package/dist/apps/www/contacts/[uid].d.ts +13 -0
- package/dist/apps/www/contacts/[uid].js +13 -2
- package/dist/apps/www/contacts/index.d.ts +3 -0
- package/dist/apps/www/contacts/index.js +80 -10
- package/dist/apps/www/index.d.ts +3 -0
- package/dist/apps/www/index.js +339 -108
- package/dist/apps/www/messages/[uid].d.ts +11 -0
- package/dist/apps/www/messages/[uid].js +18 -6
- package/dist/apps/www/settings/auto-reply/index.d.ts +4 -0
- package/dist/apps/www/settings/auto-reply/index.js +9 -4
- package/dist/apps/www/settings/encryption/index.d.ts +7 -0
- package/dist/apps/www/settings/encryption/index.js +514 -97
- package/dist/apps/www/settings/filters/[uid].d.ts +8 -0
- package/dist/apps/www/settings/filters/[uid].js +22 -7
- package/dist/apps/www/settings/filters/_mailFilterRuleConfig.d.ts +11 -0
- package/dist/apps/www/settings/filters/index.d.ts +4 -0
- package/dist/apps/www/settings/filters/new/index.d.ts +4 -0
- package/dist/apps/www/settings/filters/new/index.js +8 -2
- package/dist/apps/www/settings/focused-inbox/index.d.ts +4 -0
- package/dist/apps/www/settings/focused-inbox/index.js +3 -1
- package/dist/apps/www/settings/labels/index.d.ts +4 -0
- package/dist/apps/www/settings/privacy/index.d.ts +4 -0
- package/dist/apps/www/settings/privacy/index.js +57 -22
- package/dist/apps/www/settings/read-receipts/index.d.ts +4 -0
- package/dist/apps/www/settings/read-receipts/index.js +10 -3
- package/dist/apps/www/settings/sharing/index.d.ts +4 -0
- package/dist/apps/www/settings/sharing/index.js +7 -11
- package/dist/apps/www/settings/signatures/[uid].d.ts +8 -0
- package/dist/apps/www/settings/signatures/index.d.ts +4 -0
- package/dist/apps/www/settings/signatures/new/index.d.ts +4 -0
- package/dist/apps/www/settings/signatures/signatureDefaults.d.ts +10 -0
- package/dist/apps/www/tasks/index.d.ts +3 -0
- package/dist/apps/www/tasks/index.js +50 -10
- package/package.json +3 -3
- package/apps/shared/components/booking/AvailabilityEditor.tsx +0 -118
- package/apps/www/settings/booking-types/[uid].tsx +0 -300
- package/apps/www/settings/booking-types/index.tsx +0 -98
- package/apps/www/settings/booking-types/new/index.tsx +0 -212
- package/dist/apps/shared/components/booking/AvailabilityEditor.js +0 -45
- package/dist/apps/www/settings/booking-types/[uid].js +0 -129
- package/dist/apps/www/settings/booking-types/index.js +0 -29
- package/dist/apps/www/settings/booking-types/new/index.js +0 -86
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unlocking with a recovery code, and what has to happen after it. A recovery code is single-use: once it has opened
|
|
3
|
+
* the keys, the user may set a new encryption password (`replacePasswordWrap()`), and then the used code is removed
|
|
4
|
+
* (`consumeRecoveryCode()`). The order matters - react-shared's `replacePasswordWrap()` refuses unless another own
|
|
5
|
+
* unlock method remains, and the not-yet-consumed code is that method.
|
|
6
|
+
*
|
|
7
|
+
* `UnlockPromptProvider` and `KeyEnrollmentGate` both unlock with `startRecoveryUnlock()`, settle whatever was waiting
|
|
8
|
+
* on the unlock straight away, and then show `RecoveryFollowUpModal` for the follow-up steps.
|
|
9
|
+
*/
|
|
10
|
+
import React from "react";
|
|
11
|
+
import { type PublicKey } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
12
|
+
/** The same minimum every other "set an encryption password" form in this app uses. */
|
|
13
|
+
export declare const MIN_PASSWORD_LENGTH = 8;
|
|
14
|
+
/** Where recovery codes are regenerated. */
|
|
15
|
+
export declare const ENCRYPTION_SETTINGS_HREF = "/settings/encryption";
|
|
16
|
+
/** Deliberately generic: a wrong code and a vault with no recovery codes fail the same way. */
|
|
17
|
+
export declare const RECOVERY_CODE_ERROR = "That recovery code didn't work.";
|
|
18
|
+
export declare const REPLACE_ROTATED_MESSAGE = "Your encryption keys were changed on another device after you unlocked, so your password wasn't changed and your recovery code wasn't used. Reload the page and unlock again.";
|
|
19
|
+
export declare const REPLACE_MULTIPLE_PASSWORDS_MESSAGE = "This mailbox has more than one encryption password, so it can't be replaced here. Nothing was changed. Remove the extra password in Settings > Encryption first.";
|
|
20
|
+
export declare const REPLACE_NO_OTHER_METHOD_MESSAGE = "Your password wasn't changed, because this mailbox would be left with no other way to unlock. Your recovery code may already have been used in another window. Nothing was changed.";
|
|
21
|
+
export declare const REPLACE_ADD_FAILED_RESTORED_MESSAGE = "Your new password couldn't be saved, so your old password still works. Nothing was changed. Try again.";
|
|
22
|
+
export declare const REPLACE_ADD_FAILED_NOT_RESTORED_MESSAGE = "Your new password couldn't be saved, and your old password couldn't be put back, so this mailbox has no password right now. Your recovery code still works. Try again, or keep the code for now.";
|
|
23
|
+
export declare const REPLACE_LOCKED_MESSAGE = "Your encryption keys were locked before your new password could be set, so nothing was changed and your recovery code still works. Unlock with it again to set a new password.";
|
|
24
|
+
export declare const REPLACE_GENERIC_MESSAGE = "Your new password couldn't be saved. Nothing was changed. Try again.";
|
|
25
|
+
export declare const CONSUME_FAILED_MESSAGE = "The recovery code you used couldn't be removed, so it may still work. Regenerate your recovery codes in Settings > Encryption to make sure it can't be used again.";
|
|
26
|
+
/** What a successful recovery-code unlock hands to `RecoveryFollowUpModal`. */
|
|
27
|
+
export interface RecoveryFollowUp {
|
|
28
|
+
mailboxUid: string;
|
|
29
|
+
recoveryMethodId?: string;
|
|
30
|
+
/** The codes left once the used one is consumed. */
|
|
31
|
+
remainingRecoveryCodes: number;
|
|
32
|
+
/** The vault's generation read *before* the unlock - see `startRecoveryUnlock()`. */
|
|
33
|
+
expectedMasterKeyGeneration?: number;
|
|
34
|
+
unopenableKeys: string[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Unlocks `mailboxUid` with a recovery code (normalized by react-shared, so spacing and case don't matter). The
|
|
38
|
+
* vault's `masterKeyGeneration` is read first: if another device rotates the master key at any point after that read,
|
|
39
|
+
* the generation passed to `replacePasswordWrap()` later is stale and the replacement is refused, never written with
|
|
40
|
+
* a dead master key. A failed read just means no generation check. Rejects exactly as `unlockWithRecoveryCode()` does.
|
|
41
|
+
*/
|
|
42
|
+
export declare function startRecoveryUnlock(mailboxUid: string, mailboxKeys: PublicKey[], code: string): Promise<RecoveryFollowUp>;
|
|
43
|
+
/** A "use a recovery code instead" / "use your password instead" switch for an unlock form. */
|
|
44
|
+
export declare function UnlockModeToggle({ mode, disabled, onChange, }: {
|
|
45
|
+
mode: "password" | "recovery";
|
|
46
|
+
disabled: boolean;
|
|
47
|
+
onChange: (mode: "password" | "recovery") => void;
|
|
48
|
+
}): React.JSX.Element;
|
|
49
|
+
export interface RecoveryFollowUpModalProps {
|
|
50
|
+
followUp: RecoveryFollowUp;
|
|
51
|
+
/** `false` hides the dialog without losing its progress (e.g. while another unlock dialog is on top). */
|
|
52
|
+
open?: boolean;
|
|
53
|
+
onDone: () => void;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The steps after a recovery-code unlock, shown once the keys are already open:
|
|
57
|
+
*
|
|
58
|
+
* 1. Offer a new encryption password. Optional ("Skip") - unless this was the last code, where it's required before the
|
|
59
|
+
* code is used up (otherwise only a forgotten password would remain); then "Keep this code for now" closes without
|
|
60
|
+
* consuming anything.
|
|
61
|
+
* 2. Remove the used code. A failure is reported but never blocks anything - the keys are already open.
|
|
62
|
+
* 3. Say how many codes are left, nudging towards regenerating them at two or fewer.
|
|
63
|
+
*
|
|
64
|
+
* Mount it with a `key` per follow-up so each unlock starts from step 1.
|
|
65
|
+
*/
|
|
66
|
+
export declare function RecoveryFollowUpModal({ followUp, open, onDone }: RecoveryFollowUpModalProps): React.JSX.Element;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
/**
|
|
7
|
+
* Unlocking with a recovery code, and what has to happen after it. A recovery code is single-use: once it has opened
|
|
8
|
+
* the keys, the user may set a new encryption password (`replacePasswordWrap()`), and then the used code is removed
|
|
9
|
+
* (`consumeRecoveryCode()`). The order matters - react-shared's `replacePasswordWrap()` refuses unless another own
|
|
10
|
+
* unlock method remains, and the not-yet-consumed code is that method.
|
|
11
|
+
*
|
|
12
|
+
* `UnlockPromptProvider` and `KeyEnrollmentGate` both unlock with `startRecoveryUnlock()`, settle whatever was waiting
|
|
13
|
+
* on the unlock straight away, and then show `RecoveryFollowUpModal` for the follow-up steps.
|
|
14
|
+
*/
|
|
15
|
+
import { useState } from "react";
|
|
16
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
17
|
+
import { getUnlockedKeys, unlockWithRecoveryCode } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
18
|
+
import { getKeyVault } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
19
|
+
import { KeysLockedError } from "@rapidmx/react-shared/crypto/masterKey.js";
|
|
20
|
+
import { PasswordWrapReplaceError, consumeRecoveryCode, replacePasswordWrap, } from "@rapidmx/react-shared/crypto/masterKeyWraps.js";
|
|
21
|
+
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
22
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
23
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
24
|
+
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
25
|
+
/** The same minimum every other "set an encryption password" form in this app uses. */
|
|
26
|
+
export const MIN_PASSWORD_LENGTH = 8;
|
|
27
|
+
/** Where recovery codes are regenerated. */
|
|
28
|
+
export const ENCRYPTION_SETTINGS_HREF = "/settings/encryption";
|
|
29
|
+
/** Deliberately generic: a wrong code and a vault with no recovery codes fail the same way. */
|
|
30
|
+
export const RECOVERY_CODE_ERROR = "That recovery code didn't work.";
|
|
31
|
+
export const REPLACE_ROTATED_MESSAGE = "Your encryption keys were changed on another device after you unlocked, so your password wasn't changed and your recovery code wasn't used. Reload the page and unlock again.";
|
|
32
|
+
export const REPLACE_MULTIPLE_PASSWORDS_MESSAGE = "This mailbox has more than one encryption password, so it can't be replaced here. Nothing was changed. Remove the extra password in Settings > Encryption first.";
|
|
33
|
+
export const REPLACE_NO_OTHER_METHOD_MESSAGE = "Your password wasn't changed, because this mailbox would be left with no other way to unlock. Your recovery code may already have been used in another window. Nothing was changed.";
|
|
34
|
+
export const REPLACE_ADD_FAILED_RESTORED_MESSAGE = "Your new password couldn't be saved, so your old password still works. Nothing was changed. Try again.";
|
|
35
|
+
export const REPLACE_ADD_FAILED_NOT_RESTORED_MESSAGE = "Your new password couldn't be saved, and your old password couldn't be put back, so this mailbox has no password right now. Your recovery code still works. Try again, or keep the code for now.";
|
|
36
|
+
export const REPLACE_LOCKED_MESSAGE = "Your encryption keys were locked before your new password could be set, so nothing was changed and your recovery code still works. Unlock with it again to set a new password.";
|
|
37
|
+
export const REPLACE_GENERIC_MESSAGE = "Your new password couldn't be saved. Nothing was changed. Try again.";
|
|
38
|
+
export const CONSUME_FAILED_MESSAGE = "The recovery code you used couldn't be removed, so it may still work. Regenerate your recovery codes in Settings > Encryption to make sure it can't be used again.";
|
|
39
|
+
/**
|
|
40
|
+
* Unlocks `mailboxUid` with a recovery code (normalized by react-shared, so spacing and case don't matter). The
|
|
41
|
+
* vault's `masterKeyGeneration` is read first: if another device rotates the master key at any point after that read,
|
|
42
|
+
* the generation passed to `replacePasswordWrap()` later is stale and the replacement is refused, never written with
|
|
43
|
+
* a dead master key. A failed read just means no generation check. Rejects exactly as `unlockWithRecoveryCode()` does.
|
|
44
|
+
*/
|
|
45
|
+
export async function startRecoveryUnlock(mailboxUid, mailboxKeys, code) {
|
|
46
|
+
const expectedMasterKeyGeneration = await getKeyVault(mailboxUid).then((vault) => vault.masterKeyGeneration, () => undefined);
|
|
47
|
+
const result = await unlockWithRecoveryCode(mailboxUid, mailboxKeys, code);
|
|
48
|
+
return {
|
|
49
|
+
mailboxUid,
|
|
50
|
+
recoveryMethodId: result.recoveryMethodId,
|
|
51
|
+
remainingRecoveryCodes: result.remainingRecoveryCodes,
|
|
52
|
+
expectedMasterKeyGeneration,
|
|
53
|
+
unopenableKeys: result.unopenableKeys,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** A "use a recovery code instead" / "use your password instead" switch for an unlock form. */
|
|
57
|
+
export function UnlockModeToggle({ mode, disabled, onChange, }) {
|
|
58
|
+
return (_jsx(Button, { type: "button", variant: "text", className: "!w-auto", disabled: disabled, onClick: () => onChange(mode === "password" ? "recovery" : "password"), children: mode === "password" ? "Use a recovery code instead" : "Use your password instead" }));
|
|
59
|
+
}
|
|
60
|
+
function replaceFailure(err) {
|
|
61
|
+
if (err instanceof PasswordWrapReplaceError) {
|
|
62
|
+
switch (err.reason) {
|
|
63
|
+
case "master_key_rotated":
|
|
64
|
+
return { message: REPLACE_ROTATED_MESSAGE, blocked: { message: REPLACE_ROTATED_MESSAGE, reload: true } };
|
|
65
|
+
case "multiple_password_wraps":
|
|
66
|
+
return {
|
|
67
|
+
message: REPLACE_MULTIPLE_PASSWORDS_MESSAGE,
|
|
68
|
+
blocked: { message: REPLACE_MULTIPLE_PASSWORDS_MESSAGE, allowSecondary: true },
|
|
69
|
+
};
|
|
70
|
+
case "no_other_unlock_method":
|
|
71
|
+
return { message: REPLACE_NO_OTHER_METHOD_MESSAGE, blocked: { message: REPLACE_NO_OTHER_METHOD_MESSAGE } };
|
|
72
|
+
default:
|
|
73
|
+
return { message: err.restored ? REPLACE_ADD_FAILED_RESTORED_MESSAGE : REPLACE_ADD_FAILED_NOT_RESTORED_MESSAGE };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (err instanceof KeysLockedError) {
|
|
77
|
+
return { message: REPLACE_LOCKED_MESSAGE, blocked: { message: REPLACE_LOCKED_MESSAGE } };
|
|
78
|
+
}
|
|
79
|
+
return { message: err instanceof ApiRequestError ? `${REPLACE_GENERIC_MESSAGE} (${err.message})` : REPLACE_GENERIC_MESSAGE };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The steps after a recovery-code unlock, shown once the keys are already open:
|
|
83
|
+
*
|
|
84
|
+
* 1. Offer a new encryption password. Optional ("Skip") - unless this was the last code, where it's required before the
|
|
85
|
+
* code is used up (otherwise only a forgotten password would remain); then "Keep this code for now" closes without
|
|
86
|
+
* consuming anything.
|
|
87
|
+
* 2. Remove the used code. A failure is reported but never blocks anything - the keys are already open.
|
|
88
|
+
* 3. Say how many codes are left, nudging towards regenerating them at two or fewer.
|
|
89
|
+
*
|
|
90
|
+
* Mount it with a `key` per follow-up so each unlock starts from step 1.
|
|
91
|
+
*/
|
|
92
|
+
export function RecoveryFollowUpModal({ followUp, open = true, onDone }) {
|
|
93
|
+
const { mailboxUid, recoveryMethodId, remainingRecoveryCodes, expectedMasterKeyGeneration } = followUp;
|
|
94
|
+
const required = remainingRecoveryCodes === 0;
|
|
95
|
+
const [step, setStep] = useState("password");
|
|
96
|
+
const [newPassword, setNewPassword] = useState("");
|
|
97
|
+
const [confirmPassword, setConfirmPassword] = useState("");
|
|
98
|
+
const [error, setError] = useState(null);
|
|
99
|
+
const [blocked, setBlocked] = useState(null);
|
|
100
|
+
const [busy, setBusy] = useState(false);
|
|
101
|
+
const [passwordSet, setPasswordSet] = useState(false);
|
|
102
|
+
const [consumeFailed, setConsumeFailed] = useState(false);
|
|
103
|
+
async function consume() {
|
|
104
|
+
let failed = !recoveryMethodId;
|
|
105
|
+
if (recoveryMethodId) {
|
|
106
|
+
try {
|
|
107
|
+
await consumeRecoveryCode(mailboxUid, recoveryMethodId);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
// 404: already gone (e.g. consumed from another tab) - that's the outcome wanted.
|
|
111
|
+
failed = !(err instanceof ApiRequestError && err.status === 404);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
setConsumeFailed(failed);
|
|
115
|
+
setStep("done");
|
|
116
|
+
}
|
|
117
|
+
async function handleSetPassword(e) {
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
|
120
|
+
setError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (newPassword !== confirmPassword) {
|
|
124
|
+
setError("Passwords do not match.");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
setError(null);
|
|
128
|
+
setBusy(true);
|
|
129
|
+
try {
|
|
130
|
+
// Read now, not at unlock time: an idle timeout or a lock elsewhere may have destroyed the keys since.
|
|
131
|
+
const unlocked = getUnlockedKeys(mailboxUid);
|
|
132
|
+
if (!unlocked || unlocked.destroyed) {
|
|
133
|
+
throw new KeysLockedError();
|
|
134
|
+
}
|
|
135
|
+
// Before consuming - the unused code is the other unlock method that makes replacing safe.
|
|
136
|
+
await replacePasswordWrap(mailboxUid, unlocked, newPassword, expectedMasterKeyGeneration);
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
const failure = replaceFailure(err);
|
|
140
|
+
setBlocked(failure.blocked ?? null);
|
|
141
|
+
setError(failure.blocked ? null : failure.message);
|
|
142
|
+
setBusy(false);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
setNewPassword("");
|
|
146
|
+
setConfirmPassword("");
|
|
147
|
+
setPasswordSet(true);
|
|
148
|
+
await consume();
|
|
149
|
+
setBusy(false);
|
|
150
|
+
}
|
|
151
|
+
async function handleSkip() {
|
|
152
|
+
setError(null);
|
|
153
|
+
setBusy(true);
|
|
154
|
+
await consume();
|
|
155
|
+
setBusy(false);
|
|
156
|
+
}
|
|
157
|
+
const title = step === "done" ? "Recovery code used" : "Set a new encryption password";
|
|
158
|
+
const secondary = !blocked || !!blocked.allowSecondary;
|
|
159
|
+
function handleClose() {
|
|
160
|
+
if (busy) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// Closing the password step means the same as its secondary action - never a silent password change, and the
|
|
164
|
+
// last code is never consumed without a new password.
|
|
165
|
+
if (step === "password" && !required && secondary) {
|
|
166
|
+
void handleSkip();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
onDone();
|
|
170
|
+
}
|
|
171
|
+
return (_jsx(Modal, { open: open, onClose: handleClose, title: title, children: step === "password" ? (_jsxs(_Fragment, { children: [required ? (_jsx("p", { className: "text-sm text-text-muted mb-4", children: "That was your last recovery code. Set a new encryption password before it\u2019s used up - otherwise a password you\u2019ve forgotten would be the only way left to unlock this mailbox." })) : (_jsx("p", { className: "text-sm text-text-muted mb-4", children: "Your mailbox is unlocked. If you\u2019ve forgotten your encryption password, set a new one now. The recovery code you used will then be used up." })), blocked ? (_jsxs(_Fragment, { children: [_jsx(Alert, { children: blocked.message }), _jsxs("div", { className: "flex gap-2", children: [blocked.reload && (_jsx(Button, { type: "button", className: "!w-auto", onClick: () => window.location.reload(), children: "Reload page" })), !secondary && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onDone, children: "Close" }))] })] })) : (_jsxs("form", { onSubmit: handleSetPassword, children: [error && _jsx(Alert, { children: error }), _jsx(FormField, { label: "New encryption password", htmlFor: "recovery-new-password", children: _jsx("input", { id: "recovery-new-password", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: newPassword, onChange: (e) => setNewPassword(e.target.value), disabled: busy, autoComplete: "new-password", autoFocus: true }) }), _jsx(FormField, { label: "Confirm new password", htmlFor: "recovery-new-password-confirm", children: _jsx("input", { id: "recovery-new-password-confirm", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: confirmPassword, onChange: (e) => setConfirmPassword(e.target.value), disabled: busy, autoComplete: "new-password" }) }), _jsx(Button, { type: "submit", className: "!w-auto", loading: busy, disabled: busy, children: "Set password" })] })), secondary && (_jsx("div", { className: "mt-2", children: required ? (_jsx(Button, { type: "button", variant: "text", className: "!w-auto", disabled: busy, onClick: onDone, children: "Keep this code for now" })) : (_jsx(Button, { type: "button", variant: "text", className: "!w-auto", disabled: busy, onClick: handleSkip, children: "Skip" })) }))] })) : (_jsxs(_Fragment, { children: [passwordSet && _jsx("p", { className: "text-sm mb-3", children: "Your new encryption password is set." }), consumeFailed ? (_jsx("p", { role: "status", className: "mb-3 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: CONSUME_FAILED_MESSAGE })) : (_jsx("p", { className: "text-sm mb-3", children: "The recovery code you used has been used up and won\u2019t work again." })), _jsxs("p", { className: "text-sm mb-3", children: [remainingRecoveryCodes === 0
|
|
172
|
+
? "You have no recovery codes left."
|
|
173
|
+
: `You have ${remainingRecoveryCodes} recovery ${remainingRecoveryCodes === 1 ? "code" : "codes"} left.`, remainingRecoveryCodes <= 2 && (_jsxs(_Fragment, { children: [" ", "Generate a new set in", " ", _jsx("a", { href: ENCRYPTION_SETTINGS_HREF, className: "text-primary-dark hover:underline", children: "Settings > Encryption" }), " ", "with Regenerate recovery codes."] }))] }), _jsx(Button, { type: "button", className: "!w-auto", onClick: onDone, children: "Done" })] })) }));
|
|
174
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { UnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
3
|
+
import type { PublicKey } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
4
|
+
/** Shown instead of "Incorrect password" when the password was right but the mailbox's encryption key itself
|
|
5
|
+
* won't open (`UnopenableEncryptionKeyError`) - retrying can't fix that, so it must not read as a typo. */
|
|
6
|
+
export declare const UNOPENABLE_ENCRYPTION_KEY_MESSAGE = "Your password is correct, but one of your keys couldn't be opened, so encrypted mail can't be read or sent from this mailbox. Trying again won't help - contact support.";
|
|
7
|
+
/** `UNOPENABLE_ENCRYPTION_KEY_MESSAGE`'s counterpart for a recovery-code unlock. */
|
|
8
|
+
export declare const UNOPENABLE_ENCRYPTION_KEY_RECOVERY_MESSAGE = "Your recovery code is correct, but one of your keys couldn't be opened, so encrypted mail can't be read or sent from this mailbox. Trying again won't help - contact support.";
|
|
9
|
+
/** The error text for a failed `unlockWithPassword()` (or, with `method` `"recovery"`, `unlockWithRecoveryCode()`). */
|
|
10
|
+
export declare function unlockErrorMessage(err: unknown, method?: "password" | "recovery"): string;
|
|
11
|
+
/**
|
|
12
|
+
* A non-blocking notice after an unlock that succeeded without some signing keys (`UnlockResult.unopenableKeys`):
|
|
13
|
+
* mail can still be read, but not signed with those keys on this device.
|
|
14
|
+
*/
|
|
15
|
+
export declare function UnopenableKeysNotice({ fingerprints, onDismiss }: {
|
|
16
|
+
fingerprints: string[];
|
|
17
|
+
onDismiss: () => void;
|
|
18
|
+
}): React.JSX.Element;
|
|
19
|
+
interface UnlockPromptContextValue {
|
|
20
|
+
/**
|
|
21
|
+
* Resolves immediately with this mailbox's already-unlocked keys if it has any this session -
|
|
22
|
+
* showing no UI at all. Otherwise shows an unlock dialog on top of whatever the user is currently
|
|
23
|
+
* doing and resolves once they enter the correct password, or rejects if they dismiss it.
|
|
24
|
+
*/
|
|
25
|
+
requestUnlock(mailboxUid: string, mailboxKeys: PublicKey[]): Promise<UnlockedKeys>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The on-demand counterpart to `KeyEnrollmentGate`'s blocking, first-sign-in-only provisioning flow.
|
|
29
|
+
* Per `specs/end-to-end_encryption.md`, unlocking is only actually *required* to (1) sign or encrypt an
|
|
30
|
+
* outgoing message, (2) read an already-encrypted message, or (3) change encryption settings that touch
|
|
31
|
+
* the master key - not merely to have a mailbox with an existing vault resolve. Mounted once in
|
|
32
|
+
* `AppShell.tsx` (shared by every top-level app shell: Mail, Calendar, Contacts, Tasks, Settings), so
|
|
33
|
+
* any of those three call sites (`ComposeWindow`'s sign/encrypt toggles, `MessageDetailPane`'s encrypted-
|
|
34
|
+
* message view, `Settings > Encryption`) can request an unlock right where it's needed via
|
|
35
|
+
* `useUnlockPrompt()`, instead of the whole app being blocked behind an unlock screen the moment a
|
|
36
|
+
* mailbox happens to resolve - see `KeyEnrollmentGate`'s `blocking` prop, which `MailShell` now sets to
|
|
37
|
+
* `false` for exactly this reason.
|
|
38
|
+
*/
|
|
39
|
+
export declare function UnlockPromptProvider({ children }: {
|
|
40
|
+
children: React.ReactNode;
|
|
41
|
+
}): React.JSX.Element;
|
|
42
|
+
/** Throws outside an `<UnlockPromptProvider>` - every app shell mounts one (see `AppShell.tsx`), so
|
|
43
|
+
* reaching this from any real page is a wiring bug, not a runtime condition to degrade gracefully from. */
|
|
44
|
+
export declare function useUnlockPrompt(): UnlockPromptContextValue;
|
|
45
|
+
export {};
|
|
@@ -1,14 +1,36 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
///////////////////////////////////////////////////////////////////////////////
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
-
import { createContext, useCallback, useContext, useState } from "react";
|
|
7
|
-
import { getUnlockedKeys, unlockWithPassword } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
6
|
+
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
|
7
|
+
import { getUnlockedKeys, unlockWithPassword, UnopenableEncryptionKeyError, } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
8
8
|
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
9
9
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
10
10
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
11
11
|
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
12
|
+
import { RECOVERY_CODE_ERROR, RecoveryFollowUpModal, UnlockModeToggle, startRecoveryUnlock, } from "./RecoveryCodeUnlock.js";
|
|
13
|
+
/** Shown instead of "Incorrect password" when the password was right but the mailbox's encryption key itself
|
|
14
|
+
* won't open (`UnopenableEncryptionKeyError`) - retrying can't fix that, so it must not read as a typo. */
|
|
15
|
+
export const UNOPENABLE_ENCRYPTION_KEY_MESSAGE = "Your password is correct, but one of your keys couldn't be opened, so encrypted mail can't be read or sent from this mailbox. Trying again won't help - contact support.";
|
|
16
|
+
/** `UNOPENABLE_ENCRYPTION_KEY_MESSAGE`'s counterpart for a recovery-code unlock. */
|
|
17
|
+
export const UNOPENABLE_ENCRYPTION_KEY_RECOVERY_MESSAGE = "Your recovery code is correct, but one of your keys couldn't be opened, so encrypted mail can't be read or sent from this mailbox. Trying again won't help - contact support.";
|
|
18
|
+
/** The error text for a failed `unlockWithPassword()` (or, with `method` `"recovery"`, `unlockWithRecoveryCode()`). */
|
|
19
|
+
export function unlockErrorMessage(err, method = "password") {
|
|
20
|
+
// Deliberately generic otherwise - see `unlockWithPassword()`'s own doc comment: a wrong password and a
|
|
21
|
+
// missing password wrap fail the same way, and telling them apart would help someone guessing passwords.
|
|
22
|
+
if (method === "recovery") {
|
|
23
|
+
return err instanceof UnopenableEncryptionKeyError ? UNOPENABLE_ENCRYPTION_KEY_RECOVERY_MESSAGE : RECOVERY_CODE_ERROR;
|
|
24
|
+
}
|
|
25
|
+
return err instanceof UnopenableEncryptionKeyError ? UNOPENABLE_ENCRYPTION_KEY_MESSAGE : "Incorrect password.";
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A non-blocking notice after an unlock that succeeded without some signing keys (`UnlockResult.unopenableKeys`):
|
|
29
|
+
* mail can still be read, but not signed with those keys on this device.
|
|
30
|
+
*/
|
|
31
|
+
export function UnopenableKeysNotice({ fingerprints, onDismiss }) {
|
|
32
|
+
return (_jsxs("div", { role: "status", className: "fixed bottom-4 right-4 z-50 max-w-sm bg-surface border border-border rounded-md p-3 text-sm shadow-lg flex flex-col gap-2", children: [_jsxs("p", { children: ["Unlocked, but ", fingerprints.length === 1 ? "one of your signing keys" : `${fingerprints.length} of your signing keys`, " ", "couldn\u2019t be opened, so mail can\u2019t be signed with ", fingerprints.length === 1 ? "it" : "them", ". Contact support."] }), _jsx("p", { className: "font-mono text-xs break-all text-text-muted", children: fingerprints.join(", ") }), _jsx("div", { children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onDismiss, children: "Dismiss" }) })] }));
|
|
33
|
+
}
|
|
12
34
|
const UnlockPromptContext = createContext(null);
|
|
13
35
|
/**
|
|
14
36
|
* The on-demand counterpart to `KeyEnrollmentGate`'s blocking, first-sign-in-only provisioning flow.
|
|
@@ -23,23 +45,45 @@ const UnlockPromptContext = createContext(null);
|
|
|
23
45
|
* `false` for exactly this reason.
|
|
24
46
|
*/
|
|
25
47
|
export function UnlockPromptProvider({ children }) {
|
|
26
|
-
const [pending,
|
|
48
|
+
const [pending, setPendingState] = useState(null);
|
|
49
|
+
// Mirrors `pending` synchronously, so two requests issued in the same tick see each other.
|
|
50
|
+
const pendingRef = useRef(null);
|
|
27
51
|
const [password, setPassword] = useState("");
|
|
28
52
|
const [error, setError] = useState(null);
|
|
29
53
|
const [unlocking, setUnlocking] = useState(false);
|
|
54
|
+
const [unopenableKeys, setUnopenableKeys] = useState(null);
|
|
55
|
+
const [mode, setMode] = useState("password");
|
|
56
|
+
const [code, setCode] = useState("");
|
|
57
|
+
// The steps after a recovery-code unlock (new password, using up the code). Shown only once the waiters are
|
|
58
|
+
// already settled - see `RecoveryCodeUnlock.tsx`.
|
|
59
|
+
const [followUp, setFollowUp] = useState(null);
|
|
60
|
+
function setPending(next) {
|
|
61
|
+
pendingRef.current = next;
|
|
62
|
+
setPendingState(next);
|
|
63
|
+
}
|
|
30
64
|
const requestUnlock = useCallback((mailboxUid, mailboxKeys) => {
|
|
31
65
|
const existing = getUnlockedKeys(mailboxUid);
|
|
32
66
|
if (existing) {
|
|
33
67
|
return Promise.resolve(existing);
|
|
34
68
|
}
|
|
35
69
|
return new Promise((resolve, reject) => {
|
|
70
|
+
const current = pendingRef.current;
|
|
71
|
+
if (current?.mailboxUid === mailboxUid) {
|
|
72
|
+
current.waiters.push({ resolve, reject });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
// A different mailbox's dialog is replacing this one - settle its callers rather than leaving
|
|
76
|
+
// their promises hanging forever.
|
|
77
|
+
current?.waiters.forEach((waiter) => waiter.reject(new Error("Unlock superseded by another request.")));
|
|
36
78
|
setPassword("");
|
|
79
|
+
setCode("");
|
|
80
|
+
setMode("password");
|
|
37
81
|
setError(null);
|
|
38
|
-
setPending({ mailboxUid, mailboxKeys, resolve, reject });
|
|
82
|
+
setPending({ mailboxUid, mailboxKeys, waiters: [{ resolve, reject }] });
|
|
39
83
|
});
|
|
40
84
|
}, []);
|
|
41
85
|
function handleCancel() {
|
|
42
|
-
|
|
86
|
+
pendingRef.current?.waiters.forEach((waiter) => waiter.reject(new Error("Unlock cancelled.")));
|
|
43
87
|
setPending(null);
|
|
44
88
|
}
|
|
45
89
|
async function handleSubmit(e) {
|
|
@@ -49,22 +93,37 @@ export function UnlockPromptProvider({ children }) {
|
|
|
49
93
|
setError(null);
|
|
50
94
|
setUnlocking(true);
|
|
51
95
|
try {
|
|
52
|
-
await
|
|
53
|
-
|
|
96
|
+
const result = await (mode === "recovery"
|
|
97
|
+
? startRecoveryUnlock(request.mailboxUid, request.mailboxKeys, code)
|
|
98
|
+
: unlockWithPassword(request.mailboxUid, request.mailboxKeys, password));
|
|
99
|
+
const recovery = mode === "recovery" ? result : null;
|
|
100
|
+
// Always reachable: the unlock just populated keySession.ts's session store for
|
|
54
101
|
// this exact mailboxUid, or threw before we get here.
|
|
55
102
|
const unlocked = getUnlockedKeys(request.mailboxUid);
|
|
56
|
-
request.resolve(unlocked);
|
|
57
|
-
|
|
103
|
+
request.waiters.forEach((waiter) => waiter.resolve(unlocked));
|
|
104
|
+
if (pendingRef.current === request) {
|
|
105
|
+
setPending(null);
|
|
106
|
+
}
|
|
107
|
+
if (result.unopenableKeys.length > 0) {
|
|
108
|
+
setUnopenableKeys(result.unopenableKeys);
|
|
109
|
+
}
|
|
110
|
+
setCode("");
|
|
111
|
+
setFollowUp(recovery);
|
|
58
112
|
}
|
|
59
|
-
catch {
|
|
60
|
-
|
|
61
|
-
setError("Incorrect password.");
|
|
113
|
+
catch (err) {
|
|
114
|
+
setError(unlockErrorMessage(err, mode));
|
|
62
115
|
}
|
|
63
116
|
finally {
|
|
64
117
|
setUnlocking(false);
|
|
65
118
|
}
|
|
66
119
|
}
|
|
67
|
-
|
|
120
|
+
function handleModeChange(next) {
|
|
121
|
+
setMode(next);
|
|
122
|
+
setError(null);
|
|
123
|
+
}
|
|
124
|
+
return (_jsxs(UnlockPromptContext.Provider, { value: { requestUnlock }, children: [children, unopenableKeys && _jsx(UnopenableKeysNotice, { fingerprints: unopenableKeys, onDismiss: () => setUnopenableKeys(null) }), followUp && (_jsx(RecoveryFollowUpModal, { followUp: followUp, open: !pending, onDone: () => setFollowUp(null) }, `${followUp.mailboxUid}:${followUp.recoveryMethodId}`)), _jsxs(Modal, { open: !!pending, onClose: handleCancel, title: "Unlock your mailbox", children: [_jsx("p", { className: "text-sm text-text-muted mb-5", children: mode === "password"
|
|
125
|
+
? "Enter your encryption password to continue."
|
|
126
|
+
: "Enter one of your recovery codes to continue. Each code works once." }), error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: handleSubmit, children: [mode === "password" ? (_jsx(FormField, { label: "Encryption password", htmlFor: "unlock-prompt-password", children: _jsx("input", { id: "unlock-prompt-password", type: "password", className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: password, onChange: (e) => setPassword(e.target.value), disabled: unlocking, autoComplete: "current-password", autoFocus: true }) })) : (_jsx(FormField, { label: "Recovery code", htmlFor: "unlock-prompt-recovery-code", children: _jsx("input", { id: "unlock-prompt-recovery-code", type: "text", className: "w-full text-sm font-mono border border-border rounded-sm py-1.5 px-2 bg-surface", value: code, onChange: (e) => setCode(e.target.value), disabled: unlocking, autoComplete: "off", autoCapitalize: "characters", spellCheck: false, autoFocus: true }) })), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "submit", loading: unlocking, disabled: unlocking, children: "Unlock" }), _jsx(Button, { type: "button", variant: "secondary", onClick: handleCancel, disabled: unlocking, children: "Cancel" })] })] }), _jsx("div", { className: "mt-3", children: _jsx(UnlockModeToggle, { mode: mode, disabled: unlocking, onChange: handleModeChange }) })] })] }));
|
|
68
127
|
}
|
|
69
128
|
/** Throws outside an `<UnlockPromptProvider>` - every app shell mounts one (see `AppShell.tsx`), so
|
|
70
129
|
* reaching this from any real page is a wiring bug, not a runtime condition to degrade gracefully from. */
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export interface UserMenuProps {
|
|
3
|
+
userUid: string;
|
|
4
|
+
/** auth-server's base URL — where the caller's own profile (name/avatar) is fetched from, if configured. */
|
|
5
|
+
authServerUrl?: string;
|
|
6
|
+
onSignOut: () => void;
|
|
7
|
+
/** Shows an "Admin" item linking to `/admin`, above "Sign Out" — pass only for a trusted-role caller. */
|
|
8
|
+
showAdminLink?: boolean;
|
|
9
|
+
/** Shows a "Settings" item, above "Admin"/"Sign Out" — an account-scoped surface (like "Admin"), not
|
|
10
|
+
* a content-scoped one, so it lives here rather than as a 5th `AppShell` rail icon. Links straight to
|
|
11
|
+
* `/settings/auto-reply`, the only settings section that exists today; repoint this at a real
|
|
12
|
+
* `/settings` landing page once a second section (Mail Filters, Signatures) makes one worth building. */
|
|
13
|
+
showSettingsLink?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The top-right "who am I" menu shared by `MailShell` and `AdminShell`: an avatar-button trigger that opens a
|
|
17
|
+
* dropdown showing the caller's avatar/name, an optional "Admin" link (trusted-role callers only), and
|
|
18
|
+
* "Sign Out". Name/avatar come from auth-server's own profile endpoint (`GET /api/profiles/me`) — this
|
|
19
|
+
* service has no local user directory (see `.claude/NOTES.md`) — and fall back to the bare uid/its first
|
|
20
|
+
* letter when unset or unreachable, which is a fully valid, expected state for most password-registered
|
|
21
|
+
* accounts (only OIDC sign-in currently populates a real avatar).
|
|
22
|
+
*/
|
|
23
|
+
export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminLink, showSettingsLink }: UserMenuProps): React.JSX.Element;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ConversationSummary } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
3
|
+
export interface ConversationListProps {
|
|
4
|
+
conversations: ConversationSummary[];
|
|
5
|
+
selectedId: string | null;
|
|
6
|
+
onSelect: (conversation: ConversationSummary) => void;
|
|
7
|
+
}
|
|
8
|
+
/** The "By conversation" counterpart to the per-message list `apps/www/index.tsx` renders inline for
|
|
9
|
+
* "By date" — conversations are mailbox-wide (see `conversationsApi.ts`), so there's no per-folder
|
|
10
|
+
* empty/loading state to distinguish here beyond what the page itself already handles. */
|
|
11
|
+
export default function ConversationList({ conversations, selectedId, onSelect }: ConversationListProps): React.JSX.Element;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Folder } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
3
|
+
import { ConversationSummary } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
4
|
+
import { Label } from "@rapidmx/react-shared/mail/labelsApi.js";
|
|
5
|
+
export interface ConversationThreadPaneProps {
|
|
6
|
+
conversation: ConversationSummary | null;
|
|
7
|
+
/** The mailbox's full folder list — a conversation can span folders (e.g. an Inbox message and the
|
|
8
|
+
* Sent Items copy of its reply), so each message's own recall eligibility is looked up individually
|
|
9
|
+
* against its own `folderUid` rather than assuming one folder for the whole thread. */
|
|
10
|
+
folders: Folder[];
|
|
11
|
+
/** The mailbox's labels, fetched once by the parent (`apps/www/index.tsx`) and passed through to
|
|
12
|
+
* every expanded message's own `MessageDetailPane` — see that component's own `labels` prop. */
|
|
13
|
+
labels?: Label[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The merged, Gmail-style reading pane for "By conversation" mode — every message in the conversation
|
|
17
|
+
* fetched and stacked oldest-to-newest, all but the most recent collapsed to a one-line summary
|
|
18
|
+
* (sender + subject) until clicked. Reuses `MessageDetailPane` for each *expanded* message's own
|
|
19
|
+
* header/attachments/body-iframe rendering rather than re-implementing it; a collapsed row is its own
|
|
20
|
+
* lightweight summary, since mounting a full `MessageDetailPane` (and its iframe) for every message in
|
|
21
|
+
* a long thread up front would be wasteful.
|
|
22
|
+
*/
|
|
23
|
+
export default function ConversationThreadPane({ conversation, folders, labels }: ConversationThreadPaneProps): React.JSX.Element;
|
|
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
-
import { useEffect, useState } from "react";
|
|
6
|
+
import { useEffect, useRef, useState } from "react";
|
|
7
7
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
8
|
import { getMessage, listAttachments, setMessageRead } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
9
9
|
import MessageDetailPane from "./MessageDetailPane.js";
|
|
@@ -22,16 +22,31 @@ export default function ConversationThreadPane({ conversation, folders, labels }
|
|
|
22
22
|
const [expandedUids, setExpandedUids] = useState(new Set());
|
|
23
23
|
const [loading, setLoading] = useState(false);
|
|
24
24
|
const [error, setError] = useState(null);
|
|
25
|
+
// Bumped on every conversation switch - any in-flight load/attachments/mark-read response carrying an
|
|
26
|
+
// older generation belongs to a superseded conversation and is dropped rather than applied.
|
|
27
|
+
const generationRef = useRef(0);
|
|
28
|
+
// Message uids a mark-read / attachments request has already been issued for in the current
|
|
29
|
+
// generation, so re-running the effect below (every `messages`/`expandedUids` change) while one is
|
|
30
|
+
// still in flight never fires a duplicate - which for mark-read would also carry a stale `version`.
|
|
31
|
+
const markReadRequestedRef = useRef(new Set());
|
|
32
|
+
const attachmentsRequestedRef = useRef(new Set());
|
|
25
33
|
useEffect(() => {
|
|
34
|
+
const generation = ++generationRef.current;
|
|
35
|
+
markReadRequestedRef.current = new Set();
|
|
36
|
+
attachmentsRequestedRef.current = new Set();
|
|
37
|
+
setMessages({});
|
|
38
|
+
setAttachmentsByUid({});
|
|
39
|
+
setExpandedUids(new Set());
|
|
26
40
|
if (!conversation) {
|
|
27
|
-
|
|
28
|
-
setExpandedUids(new Set());
|
|
41
|
+
setLoading(false);
|
|
29
42
|
return;
|
|
30
43
|
}
|
|
31
44
|
setLoading(true);
|
|
32
45
|
setError(null);
|
|
33
46
|
Promise.all(conversation.messageUids.map((uid) => getMessage(uid)))
|
|
34
47
|
.then((loaded) => {
|
|
48
|
+
if (generation !== generationRef.current)
|
|
49
|
+
return;
|
|
35
50
|
setMessages(Object.fromEntries(loaded.map((m) => [m.uid, m])));
|
|
36
51
|
// `messageUids` always has at least one entry — a `ConversationSummary` only ever
|
|
37
52
|
// exists because it was grouped from real messages (see
|
|
@@ -39,32 +54,55 @@ export default function ConversationThreadPane({ conversation, folders, labels }
|
|
|
39
54
|
const latestUid = conversation.messageUids[conversation.messageUids.length - 1];
|
|
40
55
|
setExpandedUids(new Set([latestUid]));
|
|
41
56
|
})
|
|
42
|
-
.catch((err) =>
|
|
43
|
-
|
|
57
|
+
.catch((err) => {
|
|
58
|
+
if (generation !== generationRef.current)
|
|
59
|
+
return;
|
|
60
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not load this conversation.");
|
|
61
|
+
})
|
|
62
|
+
.finally(() => {
|
|
63
|
+
if (generation === generationRef.current)
|
|
64
|
+
setLoading(false);
|
|
65
|
+
});
|
|
44
66
|
}, [conversation?.conversationId]);
|
|
45
67
|
// Lazily loads attachments and marks-as-read only for messages the reader has actually expanded —
|
|
46
68
|
// mirrors `mailDetailHooks.ts`'s `useMessageAttachments`/`useMarkMessageRead`, reimplemented here
|
|
47
69
|
// (rather than called in a loop, which the rules of hooks don't allow) since a thread can expand
|
|
48
70
|
// more than one message at once, unlike the single-selected-message case those hooks were built for.
|
|
49
71
|
useEffect(() => {
|
|
72
|
+
const generation = generationRef.current;
|
|
50
73
|
for (const uid of expandedUids) {
|
|
51
74
|
const message = messages[uid];
|
|
52
75
|
if (!message)
|
|
53
76
|
continue;
|
|
54
|
-
if (message.hasAttachments && !
|
|
77
|
+
if (message.hasAttachments && !attachmentsRequestedRef.current.has(uid)) {
|
|
78
|
+
attachmentsRequestedRef.current.add(uid);
|
|
55
79
|
listAttachments(message.folderUid, uid)
|
|
56
|
-
.then((loaded) =>
|
|
80
|
+
.then((loaded) => {
|
|
81
|
+
if (generation === generationRef.current) {
|
|
82
|
+
setAttachmentsByUid((prev) => ({ ...prev, [uid]: loaded }));
|
|
83
|
+
}
|
|
84
|
+
})
|
|
57
85
|
.catch(() => {
|
|
58
86
|
// Best-effort, same as the mark-as-read catch below — `attachmentsByUid[uid] ??
|
|
59
|
-
// []` already renders no attachments while this stays unset
|
|
60
|
-
//
|
|
87
|
+
// []` already renders no attachments while this stays unset; forgetting the
|
|
88
|
+
// request just lets a later re-expand retry it.
|
|
89
|
+
attachmentsRequestedRef.current.delete(uid);
|
|
61
90
|
});
|
|
62
91
|
}
|
|
63
|
-
|
|
92
|
+
// `message` is this render's latest copy, so the request carries its current `version`.
|
|
93
|
+
if (!message.flags.read && !markReadRequestedRef.current.has(uid)) {
|
|
94
|
+
markReadRequestedRef.current.add(uid);
|
|
64
95
|
setMessageRead(message, true)
|
|
65
|
-
.then((updated) =>
|
|
96
|
+
.then((updated) => {
|
|
97
|
+
if (generation !== generationRef.current)
|
|
98
|
+
return;
|
|
99
|
+
// Never clobber a newer copy another action (label/classify/...) already patched in.
|
|
100
|
+
setMessages((prev) => (prev[uid].version > updated.version ? prev : { ...prev, [uid]: updated }));
|
|
101
|
+
})
|
|
66
102
|
.catch(() => {
|
|
67
|
-
// Best-effort — matches `useMarkMessageRead`'s own doc comment.
|
|
103
|
+
// Best-effort — matches `useMarkMessageRead`'s own doc comment. Forgetting the
|
|
104
|
+
// request lets a later effect run (e.g. a re-expand) retry with a fresh copy.
|
|
105
|
+
markReadRequestedRef.current.delete(uid);
|
|
68
106
|
});
|
|
69
107
|
}
|
|
70
108
|
}
|