@rapidmx/web-client 0.4.0 → 0.5.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/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 +3 -1
- 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 +1 -1
- 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/escrow/layout/EscrowShell.tsx +148 -134
- package/apps/shared/components/layout/AppShell.tsx +87 -15
- 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 +12 -2
- 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/mail/listAllPages.ts +39 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +64 -55
- package/apps/shared/search/localIndexBuilder.ts +481 -179
- package/apps/shared/search/localIndexRpcClient.ts +228 -28
- 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/booking-types/[uid].tsx +306 -300
- 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/audit-log/index.js +38 -8
- package/dist/apps/admin/data-requests/index.js +52 -72
- package/dist/apps/admin/distribution-lists/[uid].js +1 -1
- package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
- package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
- package/dist/apps/admin/index.js +4 -1
- package/dist/apps/admin/ingest-queue/index.js +24 -6
- package/dist/apps/admin/mailboxes/[uid].js +20 -4
- package/dist/apps/admin/quarantine/index.js +20 -11
- package/dist/apps/admin/transport-rules/[uid].js +18 -2
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
- package/dist/apps/admin/transport-rules/index.js +22 -6
- package/dist/apps/admin/transport-rules/new/index.js +18 -2
- package/dist/apps/escrow/_layout.js +3 -3
- package/dist/apps/escrow/audit-log/index.js +26 -1
- package/dist/apps/escrow/matters/[uid].js +77 -42
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
- package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -1
- package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +15 -4
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +13 -7
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +7 -2
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +6 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +34 -12
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +390 -78
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +40 -11
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +29 -13
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +87 -21
- package/dist/apps/shared/components/admin/signOut.js +75 -0
- package/dist/apps/shared/components/admin/usePagedList.js +99 -0
- package/dist/apps/shared/components/calendar/EventModal.js +142 -21
- package/dist/apps/shared/components/calendar/MonthView.js +12 -5
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
- package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
- package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
- package/dist/apps/shared/components/calendar/allDay.js +111 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +1 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
- package/dist/apps/shared/components/contacts/ContactForm.js +32 -10
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
- package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
- package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
- package/dist/apps/shared/components/layout/AppShell.js +83 -14
- package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +49 -12
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
- package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +73 -14
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
- package/dist/apps/shared/components/mail/MessageDetailPane.js +441 -36
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +699 -128
- package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +12 -3
- package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
- package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
- package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
- package/dist/apps/shared/mail/listAllPages.js +25 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +58 -54
- package/dist/apps/shared/search/localIndexBuilder.js +292 -37
- package/dist/apps/shared/search/localIndexRpcClient.js +188 -25
- package/dist/apps/shared/search/localIndexSchema.js +12 -6
- package/dist/apps/shared/search/localIndexStorage.js +86 -0
- package/dist/apps/shared/search/localIndexVFS.js +68 -40
- package/dist/apps/shared/search/localIndexWorker.js +519 -173
- package/dist/apps/www/calendar/index.js +28 -10
- package/dist/apps/www/contacts/[uid].js +13 -2
- package/dist/apps/www/contacts/index.js +80 -10
- package/dist/apps/www/index.js +339 -108
- package/dist/apps/www/messages/[uid].js +18 -6
- package/dist/apps/www/settings/auto-reply/index.js +9 -4
- package/dist/apps/www/settings/booking-types/[uid].js +17 -12
- package/dist/apps/www/settings/encryption/index.js +514 -97
- package/dist/apps/www/settings/filters/[uid].js +22 -7
- package/dist/apps/www/settings/filters/new/index.js +8 -2
- package/dist/apps/www/settings/focused-inbox/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.js +57 -22
- package/dist/apps/www/settings/read-receipts/index.js +10 -3
- package/dist/apps/www/settings/sharing/index.js +7 -11
- package/dist/apps/www/tasks/index.js +50 -10
- package/package.json +2 -2
|
@@ -8,7 +8,7 @@ import { MailboxAutoProvisionAliasOption, autoProvisionMailbox } from "@rapidmx/
|
|
|
8
8
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
9
9
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
10
10
|
|
|
11
|
-
type Status = "checking" | "needs_selection" | "creating" | "unavailable";
|
|
11
|
+
type Status = "checking" | "needs_selection" | "creating" | "unavailable" | "retryable";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Rendered by each app's shell (Mail/Calendar/Contacts/Tasks) *instead of* the normal `AppShell`
|
|
@@ -27,7 +27,11 @@ export default function MailboxProvisioning() {
|
|
|
27
27
|
const [selected, setSelected] = useState("");
|
|
28
28
|
const [error, setError] = useState<string | null>(null);
|
|
29
29
|
|
|
30
|
+
// Bumped by "Retry" to re-run the check below.
|
|
31
|
+
const [attempt, setAttempt] = useState(0);
|
|
32
|
+
|
|
30
33
|
useEffect(() => {
|
|
34
|
+
setStatus("checking");
|
|
31
35
|
autoProvisionMailbox()
|
|
32
36
|
.then((result) => {
|
|
33
37
|
if (result.status === "needs_selection") {
|
|
@@ -44,8 +48,10 @@ export default function MailboxProvisioning() {
|
|
|
44
48
|
window.location.reload();
|
|
45
49
|
}
|
|
46
50
|
})
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
// A 503 means the server couldn't read its own provisioning policy right now - not that there's no
|
|
52
|
+
// mailbox to be had - so it gets a retry instead of the permanent "ask an administrator" message.
|
|
53
|
+
.catch((err) => setStatus(err instanceof ApiRequestError && err.status === 503 ? "retryable" : "unavailable"));
|
|
54
|
+
}, [attempt]);
|
|
49
55
|
|
|
50
56
|
async function handleConfirm() {
|
|
51
57
|
// `selected` only ever takes a value from `options` itself (the initial default, or the
|
|
@@ -90,6 +96,16 @@ export default function MailboxProvisioning() {
|
|
|
90
96
|
</Button>
|
|
91
97
|
</div>
|
|
92
98
|
);
|
|
99
|
+
} else if (status === "retryable") {
|
|
100
|
+
content = (
|
|
101
|
+
<>
|
|
102
|
+
<h1 className="text-lg font-bold uppercase tracking-wide">Couldn’t check right now</h1>
|
|
103
|
+
<p className="text-sm text-text-muted">We couldn’t check whether a mailbox can be set up for you. Please try again.</p>
|
|
104
|
+
<Button type="button" onClick={() => setAttempt((n) => n + 1)} className="!w-auto self-center">
|
|
105
|
+
Retry
|
|
106
|
+
</Button>
|
|
107
|
+
</>
|
|
108
|
+
);
|
|
93
109
|
} else {
|
|
94
110
|
content = (
|
|
95
111
|
<>
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Unlocking with a recovery code, and what has to happen after it. A recovery code is single-use: once it has opened
|
|
7
|
+
* the keys, the user may set a new encryption password (`replacePasswordWrap()`), and then the used code is removed
|
|
8
|
+
* (`consumeRecoveryCode()`). The order matters - react-shared's `replacePasswordWrap()` refuses unless another own
|
|
9
|
+
* unlock method remains, and the not-yet-consumed code is that method.
|
|
10
|
+
*
|
|
11
|
+
* `UnlockPromptProvider` and `KeyEnrollmentGate` both unlock with `startRecoveryUnlock()`, settle whatever was waiting
|
|
12
|
+
* on the unlock straight away, and then show `RecoveryFollowUpModal` for the follow-up steps.
|
|
13
|
+
*/
|
|
14
|
+
import React, { FormEvent, useState } from "react";
|
|
15
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
16
|
+
import { getUnlockedKeys, unlockWithRecoveryCode } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
17
|
+
import { getKeyVault, type PublicKey } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
18
|
+
import { KeysLockedError } from "@rapidmx/react-shared/crypto/masterKey.js";
|
|
19
|
+
import {
|
|
20
|
+
PasswordWrapReplaceError,
|
|
21
|
+
consumeRecoveryCode,
|
|
22
|
+
replacePasswordWrap,
|
|
23
|
+
} from "@rapidmx/react-shared/crypto/masterKeyWraps.js";
|
|
24
|
+
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
25
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
26
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
27
|
+
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
28
|
+
|
|
29
|
+
/** The same minimum every other "set an encryption password" form in this app uses. */
|
|
30
|
+
export const MIN_PASSWORD_LENGTH = 8;
|
|
31
|
+
|
|
32
|
+
/** Where recovery codes are regenerated. */
|
|
33
|
+
export const ENCRYPTION_SETTINGS_HREF = "/settings/encryption";
|
|
34
|
+
|
|
35
|
+
/** Deliberately generic: a wrong code and a vault with no recovery codes fail the same way. */
|
|
36
|
+
export const RECOVERY_CODE_ERROR = "That recovery code didn't work.";
|
|
37
|
+
|
|
38
|
+
export const REPLACE_ROTATED_MESSAGE =
|
|
39
|
+
"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.";
|
|
40
|
+
export const REPLACE_MULTIPLE_PASSWORDS_MESSAGE =
|
|
41
|
+
"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.";
|
|
42
|
+
export const REPLACE_NO_OTHER_METHOD_MESSAGE =
|
|
43
|
+
"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.";
|
|
44
|
+
export const REPLACE_ADD_FAILED_RESTORED_MESSAGE =
|
|
45
|
+
"Your new password couldn't be saved, so your old password still works. Nothing was changed. Try again.";
|
|
46
|
+
export const REPLACE_ADD_FAILED_NOT_RESTORED_MESSAGE =
|
|
47
|
+
"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.";
|
|
48
|
+
export const REPLACE_LOCKED_MESSAGE =
|
|
49
|
+
"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.";
|
|
50
|
+
export const REPLACE_GENERIC_MESSAGE = "Your new password couldn't be saved. Nothing was changed. Try again.";
|
|
51
|
+
export const CONSUME_FAILED_MESSAGE =
|
|
52
|
+
"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.";
|
|
53
|
+
|
|
54
|
+
/** What a successful recovery-code unlock hands to `RecoveryFollowUpModal`. */
|
|
55
|
+
export interface RecoveryFollowUp {
|
|
56
|
+
mailboxUid: string;
|
|
57
|
+
recoveryMethodId?: string;
|
|
58
|
+
/** The codes left once the used one is consumed. */
|
|
59
|
+
remainingRecoveryCodes: number;
|
|
60
|
+
/** The vault's generation read *before* the unlock - see `startRecoveryUnlock()`. */
|
|
61
|
+
expectedMasterKeyGeneration?: number;
|
|
62
|
+
unopenableKeys: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Unlocks `mailboxUid` with a recovery code (normalized by react-shared, so spacing and case don't matter). The
|
|
67
|
+
* vault's `masterKeyGeneration` is read first: if another device rotates the master key at any point after that read,
|
|
68
|
+
* the generation passed to `replacePasswordWrap()` later is stale and the replacement is refused, never written with
|
|
69
|
+
* a dead master key. A failed read just means no generation check. Rejects exactly as `unlockWithRecoveryCode()` does.
|
|
70
|
+
*/
|
|
71
|
+
export async function startRecoveryUnlock(mailboxUid: string, mailboxKeys: PublicKey[], code: string): Promise<RecoveryFollowUp> {
|
|
72
|
+
const expectedMasterKeyGeneration = await getKeyVault(mailboxUid).then(
|
|
73
|
+
(vault) => vault.masterKeyGeneration,
|
|
74
|
+
() => undefined,
|
|
75
|
+
);
|
|
76
|
+
const result = await unlockWithRecoveryCode(mailboxUid, mailboxKeys, code);
|
|
77
|
+
return {
|
|
78
|
+
mailboxUid,
|
|
79
|
+
recoveryMethodId: result.recoveryMethodId,
|
|
80
|
+
remainingRecoveryCodes: result.remainingRecoveryCodes,
|
|
81
|
+
expectedMasterKeyGeneration,
|
|
82
|
+
unopenableKeys: result.unopenableKeys,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A "use a recovery code instead" / "use your password instead" switch for an unlock form. */
|
|
87
|
+
export function UnlockModeToggle({
|
|
88
|
+
mode,
|
|
89
|
+
disabled,
|
|
90
|
+
onChange,
|
|
91
|
+
}: {
|
|
92
|
+
mode: "password" | "recovery";
|
|
93
|
+
disabled: boolean;
|
|
94
|
+
onChange: (mode: "password" | "recovery") => void;
|
|
95
|
+
}) {
|
|
96
|
+
return (
|
|
97
|
+
<Button
|
|
98
|
+
type="button"
|
|
99
|
+
variant="text"
|
|
100
|
+
className="!w-auto"
|
|
101
|
+
disabled={disabled}
|
|
102
|
+
onClick={() => onChange(mode === "password" ? "recovery" : "password")}
|
|
103
|
+
>
|
|
104
|
+
{mode === "password" ? "Use a recovery code instead" : "Use your password instead"}
|
|
105
|
+
</Button>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type Step = "password" | "done";
|
|
110
|
+
|
|
111
|
+
/** Why the new-password form is no longer offered, with the message to show. `reload` adds a Reload button. */
|
|
112
|
+
interface Blocked {
|
|
113
|
+
message: string;
|
|
114
|
+
reload?: boolean;
|
|
115
|
+
/** The secondary Skip / Keep action is still offered (e.g. two password wraps - nothing else is wrong). */
|
|
116
|
+
allowSecondary?: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function replaceFailure(err: unknown): { message: string; blocked?: Blocked } {
|
|
120
|
+
if (err instanceof PasswordWrapReplaceError) {
|
|
121
|
+
switch (err.reason) {
|
|
122
|
+
case "master_key_rotated":
|
|
123
|
+
return { message: REPLACE_ROTATED_MESSAGE, blocked: { message: REPLACE_ROTATED_MESSAGE, reload: true } };
|
|
124
|
+
case "multiple_password_wraps":
|
|
125
|
+
return {
|
|
126
|
+
message: REPLACE_MULTIPLE_PASSWORDS_MESSAGE,
|
|
127
|
+
blocked: { message: REPLACE_MULTIPLE_PASSWORDS_MESSAGE, allowSecondary: true },
|
|
128
|
+
};
|
|
129
|
+
case "no_other_unlock_method":
|
|
130
|
+
return { message: REPLACE_NO_OTHER_METHOD_MESSAGE, blocked: { message: REPLACE_NO_OTHER_METHOD_MESSAGE } };
|
|
131
|
+
default:
|
|
132
|
+
return { message: err.restored ? REPLACE_ADD_FAILED_RESTORED_MESSAGE : REPLACE_ADD_FAILED_NOT_RESTORED_MESSAGE };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (err instanceof KeysLockedError) {
|
|
136
|
+
return { message: REPLACE_LOCKED_MESSAGE, blocked: { message: REPLACE_LOCKED_MESSAGE } };
|
|
137
|
+
}
|
|
138
|
+
return { message: err instanceof ApiRequestError ? `${REPLACE_GENERIC_MESSAGE} (${err.message})` : REPLACE_GENERIC_MESSAGE };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface RecoveryFollowUpModalProps {
|
|
142
|
+
followUp: RecoveryFollowUp;
|
|
143
|
+
/** `false` hides the dialog without losing its progress (e.g. while another unlock dialog is on top). */
|
|
144
|
+
open?: boolean;
|
|
145
|
+
onDone: () => void;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The steps after a recovery-code unlock, shown once the keys are already open:
|
|
150
|
+
*
|
|
151
|
+
* 1. Offer a new encryption password. Optional ("Skip") - unless this was the last code, where it's required before the
|
|
152
|
+
* code is used up (otherwise only a forgotten password would remain); then "Keep this code for now" closes without
|
|
153
|
+
* consuming anything.
|
|
154
|
+
* 2. Remove the used code. A failure is reported but never blocks anything - the keys are already open.
|
|
155
|
+
* 3. Say how many codes are left, nudging towards regenerating them at two or fewer.
|
|
156
|
+
*
|
|
157
|
+
* Mount it with a `key` per follow-up so each unlock starts from step 1.
|
|
158
|
+
*/
|
|
159
|
+
export function RecoveryFollowUpModal({ followUp, open = true, onDone }: RecoveryFollowUpModalProps) {
|
|
160
|
+
const { mailboxUid, recoveryMethodId, remainingRecoveryCodes, expectedMasterKeyGeneration } = followUp;
|
|
161
|
+
const required = remainingRecoveryCodes === 0;
|
|
162
|
+
const [step, setStep] = useState<Step>("password");
|
|
163
|
+
const [newPassword, setNewPassword] = useState("");
|
|
164
|
+
const [confirmPassword, setConfirmPassword] = useState("");
|
|
165
|
+
const [error, setError] = useState<string | null>(null);
|
|
166
|
+
const [blocked, setBlocked] = useState<Blocked | null>(null);
|
|
167
|
+
const [busy, setBusy] = useState(false);
|
|
168
|
+
const [passwordSet, setPasswordSet] = useState(false);
|
|
169
|
+
const [consumeFailed, setConsumeFailed] = useState(false);
|
|
170
|
+
|
|
171
|
+
async function consume() {
|
|
172
|
+
let failed = !recoveryMethodId;
|
|
173
|
+
if (recoveryMethodId) {
|
|
174
|
+
try {
|
|
175
|
+
await consumeRecoveryCode(mailboxUid, recoveryMethodId);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
// 404: already gone (e.g. consumed from another tab) - that's the outcome wanted.
|
|
178
|
+
failed = !(err instanceof ApiRequestError && err.status === 404);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
setConsumeFailed(failed);
|
|
182
|
+
setStep("done");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function handleSetPassword(e: FormEvent) {
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
|
188
|
+
setError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (newPassword !== confirmPassword) {
|
|
192
|
+
setError("Passwords do not match.");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
setError(null);
|
|
196
|
+
setBusy(true);
|
|
197
|
+
try {
|
|
198
|
+
// Read now, not at unlock time: an idle timeout or a lock elsewhere may have destroyed the keys since.
|
|
199
|
+
const unlocked = getUnlockedKeys(mailboxUid);
|
|
200
|
+
if (!unlocked || unlocked.destroyed) {
|
|
201
|
+
throw new KeysLockedError();
|
|
202
|
+
}
|
|
203
|
+
// Before consuming - the unused code is the other unlock method that makes replacing safe.
|
|
204
|
+
await replacePasswordWrap(mailboxUid, unlocked, newPassword, expectedMasterKeyGeneration);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
const failure = replaceFailure(err);
|
|
207
|
+
setBlocked(failure.blocked ?? null);
|
|
208
|
+
setError(failure.blocked ? null : failure.message);
|
|
209
|
+
setBusy(false);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
setNewPassword("");
|
|
213
|
+
setConfirmPassword("");
|
|
214
|
+
setPasswordSet(true);
|
|
215
|
+
await consume();
|
|
216
|
+
setBusy(false);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function handleSkip() {
|
|
220
|
+
setError(null);
|
|
221
|
+
setBusy(true);
|
|
222
|
+
await consume();
|
|
223
|
+
setBusy(false);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const title = step === "done" ? "Recovery code used" : "Set a new encryption password";
|
|
227
|
+
const secondary = !blocked || !!blocked.allowSecondary;
|
|
228
|
+
|
|
229
|
+
function handleClose() {
|
|
230
|
+
if (busy) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
// Closing the password step means the same as its secondary action - never a silent password change, and the
|
|
234
|
+
// last code is never consumed without a new password.
|
|
235
|
+
if (step === "password" && !required && secondary) {
|
|
236
|
+
void handleSkip();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
onDone();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<Modal open={open} onClose={handleClose} title={title}>
|
|
244
|
+
{step === "password" ? (
|
|
245
|
+
<>
|
|
246
|
+
{required ? (
|
|
247
|
+
<p className="text-sm text-text-muted mb-4">
|
|
248
|
+
That was your last recovery code. Set a new encryption password before it’s used up -
|
|
249
|
+
otherwise a password you’ve forgotten would be the only way left to unlock this mailbox.
|
|
250
|
+
</p>
|
|
251
|
+
) : (
|
|
252
|
+
<p className="text-sm text-text-muted mb-4">
|
|
253
|
+
Your mailbox is unlocked. If you’ve forgotten your encryption password, set a new one now.
|
|
254
|
+
The recovery code you used will then be used up.
|
|
255
|
+
</p>
|
|
256
|
+
)}
|
|
257
|
+
{blocked ? (
|
|
258
|
+
<>
|
|
259
|
+
<Alert>{blocked.message}</Alert>
|
|
260
|
+
<div className="flex gap-2">
|
|
261
|
+
{blocked.reload && (
|
|
262
|
+
<Button type="button" className="!w-auto" onClick={() => window.location.reload()}>
|
|
263
|
+
Reload page
|
|
264
|
+
</Button>
|
|
265
|
+
)}
|
|
266
|
+
{!secondary && (
|
|
267
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={onDone}>
|
|
268
|
+
Close
|
|
269
|
+
</Button>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
</>
|
|
273
|
+
) : (
|
|
274
|
+
<form onSubmit={handleSetPassword}>
|
|
275
|
+
{error && <Alert>{error}</Alert>}
|
|
276
|
+
<FormField label="New encryption password" htmlFor="recovery-new-password">
|
|
277
|
+
<input
|
|
278
|
+
id="recovery-new-password"
|
|
279
|
+
type="password"
|
|
280
|
+
className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
|
|
281
|
+
value={newPassword}
|
|
282
|
+
onChange={(e) => setNewPassword(e.target.value)}
|
|
283
|
+
disabled={busy}
|
|
284
|
+
autoComplete="new-password"
|
|
285
|
+
autoFocus
|
|
286
|
+
/>
|
|
287
|
+
</FormField>
|
|
288
|
+
<FormField label="Confirm new password" htmlFor="recovery-new-password-confirm">
|
|
289
|
+
<input
|
|
290
|
+
id="recovery-new-password-confirm"
|
|
291
|
+
type="password"
|
|
292
|
+
className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
|
|
293
|
+
value={confirmPassword}
|
|
294
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
295
|
+
disabled={busy}
|
|
296
|
+
autoComplete="new-password"
|
|
297
|
+
/>
|
|
298
|
+
</FormField>
|
|
299
|
+
<Button type="submit" className="!w-auto" loading={busy} disabled={busy}>
|
|
300
|
+
Set password
|
|
301
|
+
</Button>
|
|
302
|
+
</form>
|
|
303
|
+
)}
|
|
304
|
+
{secondary && (
|
|
305
|
+
<div className="mt-2">
|
|
306
|
+
{required ? (
|
|
307
|
+
<Button type="button" variant="text" className="!w-auto" disabled={busy} onClick={onDone}>
|
|
308
|
+
Keep this code for now
|
|
309
|
+
</Button>
|
|
310
|
+
) : (
|
|
311
|
+
<Button type="button" variant="text" className="!w-auto" disabled={busy} onClick={handleSkip}>
|
|
312
|
+
Skip
|
|
313
|
+
</Button>
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
)}
|
|
317
|
+
</>
|
|
318
|
+
) : (
|
|
319
|
+
<>
|
|
320
|
+
{passwordSet && <p className="text-sm mb-3">Your new encryption password is set.</p>}
|
|
321
|
+
{consumeFailed ? (
|
|
322
|
+
<p role="status" className="mb-3 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text">
|
|
323
|
+
{CONSUME_FAILED_MESSAGE}
|
|
324
|
+
</p>
|
|
325
|
+
) : (
|
|
326
|
+
<p className="text-sm mb-3">The recovery code you used has been used up and won’t work again.</p>
|
|
327
|
+
)}
|
|
328
|
+
<p className="text-sm mb-3">
|
|
329
|
+
{remainingRecoveryCodes === 0
|
|
330
|
+
? "You have no recovery codes left."
|
|
331
|
+
: `You have ${remainingRecoveryCodes} recovery ${remainingRecoveryCodes === 1 ? "code" : "codes"} left.`}
|
|
332
|
+
{remainingRecoveryCodes <= 2 && (
|
|
333
|
+
<>
|
|
334
|
+
{" "}
|
|
335
|
+
Generate a new set in{" "}
|
|
336
|
+
<a href={ENCRYPTION_SETTINGS_HREF} className="text-primary-dark hover:underline">
|
|
337
|
+
Settings > Encryption
|
|
338
|
+
</a>{" "}
|
|
339
|
+
with Regenerate recovery codes.
|
|
340
|
+
</>
|
|
341
|
+
)}
|
|
342
|
+
</p>
|
|
343
|
+
<Button type="button" className="!w-auto" onClick={onDone}>
|
|
344
|
+
Done
|
|
345
|
+
</Button>
|
|
346
|
+
</>
|
|
347
|
+
)}
|
|
348
|
+
</Modal>
|
|
349
|
+
);
|
|
350
|
+
}
|