Skip to content

Commit 9657d39

Browse files
dbkrflorianduros
andauthored
Wire up the "Forgot recovery key" button for the "Key storage out of sync" toast (#29138)
* Wire up the "Forgot recovery key" button for the "Key storage out of sync" toast * Unused import & fix test * Test 'forgot' variant * Fix dependencies * Add more toast tests * Unused import * Test initialState in Encryption Tab * Let's see if github has any more luck running this test than me * Working playwright test with screenshot * year * Convert playwright test to use the bot client * Disambiguate Co-authored-by: Florian Duros <[email protected]> * Add doc & do other part of rename * Split out into custom hook * Fix tests --------- Co-authored-by: Florian Duros <[email protected]>
1 parent 1c4e356 commit 9657d39

File tree

13 files changed

+389
-35
lines changed

13 files changed

+389
-35
lines changed

playwright/e2e/crypto/toasts.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";
9+
10+
import { test, expect } from "../../element-web-test";
11+
import { createBot, deleteCachedSecrets, logIntoElement } from "./utils";
12+
13+
test.describe("Key storage out of sync toast", () => {
14+
let recoveryKey: GeneratedSecretStorageKey;
15+
16+
test.beforeEach(async ({ page, homeserver, credentials }) => {
17+
const res = await createBot(page, homeserver, credentials);
18+
recoveryKey = res.recoveryKey;
19+
20+
await logIntoElement(page, credentials, recoveryKey.encodedPrivateKey);
21+
22+
await deleteCachedSecrets(page);
23+
24+
// We won't be prompted for crypto setup unless we have an e2e room, so make one
25+
await page.getByRole("button", { name: "Add room" }).click();
26+
await page.getByRole("menuitem", { name: "New room" }).click();
27+
await page.getByRole("textbox", { name: "Name" }).fill("Test room");
28+
await page.getByRole("button", { name: "Create room" }).click();
29+
});
30+
31+
test("should prompt for recovery key if 'enter recovery key' pressed", { tag: "@screenshot" }, async ({ page }) => {
32+
// Need to wait for 2 to appear since playwright only evaluates 'first()' initially, so the waiting won't work
33+
await expect(page.getByRole("alert")).toHaveCount(2);
34+
await expect(page.getByRole("alert").first()).toMatchScreenshot("key-storage-out-of-sync-toast.png");
35+
36+
await page.getByRole("button", { name: "Enter recovery key" }).click();
37+
await page.locator(".mx_Dialog").getByRole("button", { name: "use your Security Key" }).click();
38+
39+
await page.getByRole("textbox", { name: "Security key" }).fill(recoveryKey.encodedPrivateKey);
40+
await page.getByRole("button", { name: "Continue" }).click();
41+
42+
await expect(page.getByRole("button", { name: "Enter recovery key" })).not.toBeVisible();
43+
});
44+
45+
test("should open settings to reset flow if 'forgot recovery key' pressed", async ({ page, app, credentials }) => {
46+
await expect(page.getByRole("button", { name: "Enter recovery key" })).toBeVisible();
47+
48+
await page.getByRole("button", { name: "Forgot recovery key?" }).click();
49+
50+
await expect(
51+
page.getByRole("heading", { name: "Forgot your recovery key? You’ll need to reset your identity." }),
52+
).toBeVisible();
53+
});
54+
});

playwright/e2e/crypto/utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,11 @@ export async function logIntoElement(page: Page, credentials: Credentials, secur
214214
// if a securityKey was given, verify the new device
215215
if (securityKey !== undefined) {
216216
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key" }).click();
217+
218+
const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "use your Security Key" });
219+
if (await useSecurityKey.isVisible()) {
220+
await useSecurityKey.click();
221+
}
217222
// Fill in the security key
218223
await page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey);
219224
await page.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
Loading

src/components/views/dialogs/UserSettingsDialog.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import { EncryptionUserSettingsTab } from "../settings/tabs/user/EncryptionUserS
5050
interface IProps {
5151
initialTabId?: UserTab;
5252
showMsc4108QrCode?: boolean;
53+
showResetIdentity?: boolean;
5354
sdkContext: SdkContextClass;
5455
onFinished(): void;
5556
}
@@ -91,8 +92,9 @@ function titleForTabID(tabId: UserTab): React.ReactNode {
9192
export default function UserSettingsDialog(props: IProps): JSX.Element {
9293
const voipEnabled = useSettingValue(UIFeature.Voip);
9394
const mjolnirEnabled = useSettingValue("feature_mjolnir");
94-
// store this prop in state as changing tabs back and forth should clear it
95+
// store these props in state as changing tabs back and forth should clear it
9596
const [showMsc4108QrCode, setShowMsc4108QrCode] = useState(props.showMsc4108QrCode);
97+
const [showResetIdentity, setShowResetIdentity] = useState(props.showResetIdentity);
9698

9799
const getTabs = (): NonEmptyArray<Tab<UserTab>> => {
98100
const tabs: Tab<UserTab>[] = [];
@@ -184,7 +186,12 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
184186
);
185187

186188
tabs.push(
187-
new Tab(UserTab.Encryption, _td("settings|encryption|title"), <KeyIcon />, <EncryptionUserSettingsTab />),
189+
new Tab(
190+
UserTab.Encryption,
191+
_td("settings|encryption|title"),
192+
<KeyIcon />,
193+
<EncryptionUserSettingsTab initialState={showResetIdentity ? "reset_identity_forgot" : undefined} />,
194+
),
188195
);
189196

190197
if (showLabsFlags() || SettingsStore.getFeatureSettingNames().some((k) => SettingsStore.getBetaInfo(k))) {
@@ -219,8 +226,9 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
219226
const [activeTabId, _setActiveTabId] = useActiveTabWithDefault(getTabs(), UserTab.Account, props.initialTabId);
220227
const setActiveTabId = (tabId: UserTab): void => {
221228
_setActiveTabId(tabId);
222-
// Clear this so switching away from the tab and back to it will not show the QR code again
229+
// Clear these so switching away from the tab and back to it will not show the QR code again
223230
setShowMsc4108QrCode(false);
231+
setShowResetIdentity(false);
224232
};
225233

226234
const [activeToast, toastRack] = useActiveToast();

src/components/views/settings/encryption/ResetIdentityPanel.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,21 @@ interface ResetIdentityPanelProps {
2525
* Called when the cancel button is clicked or when we go back in the breadcrumbs.
2626
*/
2727
onCancelClick: () => void;
28+
29+
/**
30+
* The variant of the panel to show. We show more warnings in the 'compromised' variant (no use in showing a user this
31+
* warning if they have to reset because they no longer have their key)
32+
* "compromised" is shown when the user chooses 'reset' explicitly in settings, usually because they believe their
33+
* identity has been compromised.
34+
* "forgot" is shown when the user has just forgotten their passphrase.
35+
*/
36+
variant: "compromised" | "forgot";
2837
}
2938

3039
/**
3140
* The panel for resetting the identity of the current user.
3241
*/
33-
export function ResetIdentityPanel({ onCancelClick, onFinish }: ResetIdentityPanelProps): JSX.Element {
42+
export function ResetIdentityPanel({ onCancelClick, onFinish, variant }: ResetIdentityPanelProps): JSX.Element {
3443
const matrixClient = useMatrixClientContext();
3544

3645
return (
@@ -44,7 +53,11 @@ export function ResetIdentityPanel({ onCancelClick, onFinish }: ResetIdentityPan
4453
<EncryptionCard
4554
Icon={ErrorIcon}
4655
destructive={true}
47-
title={_t("settings|encryption|advanced|breadcrumb_title")}
56+
title={
57+
variant === "forgot"
58+
? _t("settings|encryption|advanced|breadcrumb_title_forgot")
59+
: _t("settings|encryption|advanced|breadcrumb_title")
60+
}
4861
className="mx_ResetIdentityPanel"
4962
>
5063
<div className="mx_ResetIdentityPanel_content">
@@ -59,7 +72,7 @@ export function ResetIdentityPanel({ onCancelClick, onFinish }: ResetIdentityPan
5972
{_t("settings|encryption|advanced|breadcrumb_third_description")}
6073
</VisualListItem>
6174
</VisualList>
62-
<span>{_t("settings|encryption|advanced|breadcrumb_warning")}</span>
75+
{variant === "compromised" && <span>{_t("settings|encryption|advanced|breadcrumb_warning")}</span>}
6376
</div>
6477
<div className="mx_ResetIdentityPanel_footer">
6578
<Button

src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,23 +32,35 @@ import { RecoveryPanelOutOfSync } from "../../encryption/RecoveryPanelOutOfSync"
3232
* This happens when the user has a recovery key and the user clicks on "Change recovery key" button of the RecoveryPanel.
3333
* - "set_recovery_key": The panel to show when the user is setting up their recovery key.
3434
* This happens when the user doesn't have a key a recovery key and the user clicks on "Set up recovery key" button of the RecoveryPanel.
35-
* - "reset_identity": The panel to show when the user is resetting their identity.
36-
* - `secrets_not_cached`: The secrets are not cached locally. This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
35+
* - "reset_identity_compromised": The panel to show when the user is resetting their identity, in te case where their key is compromised.
36+
* - "reset_identity_forgot": The panel to show when the user is resetting their identity, in the case where they forgot their recovery key.
37+
* - `secrets_not_cached`: The secrets are not cached locally. This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
3738
* If the "set_up_encryption" and "secrets_not_cached" conditions are both filled, "set_up_encryption" prevails.
38-
*
3939
*/
40-
type State =
40+
export type State =
4141
| "loading"
4242
| "main"
4343
| "set_up_encryption"
4444
| "change_recovery_key"
4545
| "set_recovery_key"
46-
| "reset_identity"
46+
| "reset_identity_compromised"
47+
| "reset_identity_forgot"
4748
| "secrets_not_cached";
4849

49-
export function EncryptionUserSettingsTab(): JSX.Element {
50-
const [state, setState] = useState<State>("loading");
51-
const checkEncryptionState = useCheckEncryptionState(setState);
50+
interface EncryptionUserSettingsTabProps {
51+
/**
52+
* If the tab should start in a state other than the deasult
53+
*/
54+
initialState?: State;
55+
}
56+
57+
/**
58+
* The encryption settings tab.
59+
*/
60+
export function EncryptionUserSettingsTab({ initialState = "loading" }: EncryptionUserSettingsTabProps): JSX.Element {
61+
const [state, setState] = useState<State>(initialState);
62+
63+
const checkEncryptionState = useCheckEncryptionState(state, setState);
5264

5365
let content: JSX.Element;
5466
switch (state) {
@@ -70,7 +82,7 @@ export function EncryptionUserSettingsTab(): JSX.Element {
7082
}
7183
/>
7284
<Separator kind="section" />
73-
<AdvancedPanel onResetIdentityClick={() => setState("reset_identity")} />
85+
<AdvancedPanel onResetIdentityClick={() => setState("reset_identity_compromised")} />
7486
</>
7587
);
7688
break;
@@ -84,8 +96,23 @@ export function EncryptionUserSettingsTab(): JSX.Element {
8496
/>
8597
);
8698
break;
87-
case "reset_identity":
88-
content = <ResetIdentityPanel onCancelClick={() => setState("main")} onFinish={() => setState("main")} />;
99+
case "reset_identity_compromised":
100+
content = (
101+
<ResetIdentityPanel
102+
variant="compromised"
103+
onCancelClick={() => setState("main")}
104+
onFinish={() => setState("main")}
105+
/>
106+
);
107+
break;
108+
case "reset_identity_forgot":
109+
content = (
110+
<ResetIdentityPanel
111+
variant="forgot"
112+
onCancelClick={() => setState("main")}
113+
onFinish={() => setState("main")}
114+
/>
115+
);
89116
break;
90117
}
91118

@@ -111,7 +138,7 @@ export function EncryptionUserSettingsTab(): JSX.Element {
111138
* @param setState - callback passed from the EncryptionUserSettingsTab to set the current `State`.
112139
* @returns a callback function, which will re-run the logic and update the state.
113140
*/
114-
function useCheckEncryptionState(setState: (state: State) => void): () => Promise<void> {
141+
function useCheckEncryptionState(state: State, setState: (state: State) => void): () => Promise<void> {
115142
const matrixClient = useMatrixClientContext();
116143

117144
const checkEncryptionState = useCallback(async () => {
@@ -129,8 +156,8 @@ function useCheckEncryptionState(setState: (state: State) => void): () => Promis
129156

130157
// Initialise the state when the component is mounted
131158
useEffect(() => {
132-
checkEncryptionState();
133-
}, [checkEncryptionState]);
159+
if (state === "loading") checkEncryptionState();
160+
}, [checkEncryptionState, state]);
134161

135162
// Also return the callback so that the component can re-run the logic.
136163
return checkEncryptionState;

src/i18n/strings/en_EN.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2469,6 +2469,7 @@
24692469
"breadcrumb_second_description": "You will lose any message history that’s stored only on the server",
24702470
"breadcrumb_third_description": "You will need to verify all your existing devices and contacts again",
24712471
"breadcrumb_title": "Are you sure you want to reset your identity?",
2472+
"breadcrumb_title_forgot": "Forgot your recovery key? You’ll need to reset your identity.",
24722473
"breadcrumb_warning": "Only do this if you believe your account has been compromised.",
24732474
"details_title": "Encryption details",
24742475
"export_keys": "Export keys",

src/toasts/SetupEncryptionToast.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ import GenericToast from "../components/views/toasts/GenericToast";
1616
import { ModuleRunner } from "../modules/ModuleRunner";
1717
import { SetupEncryptionStore } from "../stores/SetupEncryptionStore";
1818
import Spinner from "../components/views/elements/Spinner";
19+
import { OpenToTabPayload } from "../dispatcher/payloads/OpenToTabPayload";
20+
import { Action } from "../dispatcher/actions";
21+
import { UserTab } from "../components/views/dialogs/UserTab";
22+
import defaultDispatcher from "../dispatcher/dispatcher";
1923

2024
const TOAST_KEY = "setupencryption";
2125

@@ -104,10 +108,6 @@ export enum Kind {
104108
KEY_STORAGE_OUT_OF_SYNC = "key_storage_out_of_sync",
105109
}
106110

107-
const onReject = (): void => {
108-
DeviceListener.sharedInstance().dismissEncryptionSetup();
109-
};
110-
111111
/**
112112
* Show a toast prompting the user for some action related to setting up their encryption.
113113
*
@@ -123,7 +123,7 @@ export const showToast = (kind: Kind): void => {
123123
return;
124124
}
125125

126-
const onAccept = async (): Promise<void> => {
126+
const onPrimaryClick = async (): Promise<void> => {
127127
if (kind === Kind.VERIFY_THIS_SESSION) {
128128
Modal.createDialog(SetupEncryptionDialog, {}, undefined, /* priority = */ false, /* static = */ true);
129129
} else {
@@ -142,16 +142,29 @@ export const showToast = (kind: Kind): void => {
142142
}
143143
};
144144

145+
const onSecondaryClick = (): void => {
146+
if (kind === Kind.KEY_STORAGE_OUT_OF_SYNC) {
147+
const payload: OpenToTabPayload = {
148+
action: Action.ViewUserSettings,
149+
initialTabId: UserTab.Encryption,
150+
props: { showResetIdentity: true },
151+
};
152+
defaultDispatcher.dispatch(payload);
153+
} else {
154+
DeviceListener.sharedInstance().dismissEncryptionSetup();
155+
}
156+
};
157+
145158
ToastStore.sharedInstance().addOrReplaceToast({
146159
key: TOAST_KEY,
147160
title: getTitle(kind),
148161
icon: getIcon(kind),
149162
props: {
150163
description: getDescription(kind),
151164
primaryLabel: getSetupCaption(kind),
152-
onPrimaryClick: onAccept,
165+
onPrimaryClick,
153166
secondaryLabel: getSecondaryButtonLabel(kind),
154-
onSecondaryClick: onReject,
167+
onSecondaryClick,
155168
overrideWidth: kind === Kind.KEY_STORAGE_OUT_OF_SYNC ? "366px" : undefined,
156169
},
157170
component: GenericToast,

test/unit-tests/components/views/dialogs/UserSettingsDialog-test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,9 @@ describe("<UserSettingsDialog />", () => {
5757

5858
let sdkContext: SdkContextClass;
5959
const defaultProps = { onFinished: jest.fn() };
60-
const getComponent = (props: Partial<typeof defaultProps & { initialTabId?: UserTab }> = {}): ReactElement => (
61-
<UserSettingsDialog sdkContext={sdkContext} {...defaultProps} {...props} />
62-
);
60+
const getComponent = (
61+
props: Partial<typeof defaultProps & { initialTabId?: UserTab; props: Record<string, any> }> = {},
62+
): ReactElement => <UserSettingsDialog sdkContext={sdkContext} {...defaultProps} {...props} />;
6363

6464
beforeEach(() => {
6565
jest.clearAllMocks();

test/unit-tests/components/views/settings/encryption/ResetIdentityPanel-test.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe("<ResetIdentityPanel />", () => {
2525

2626
const onFinish = jest.fn();
2727
const { asFragment } = render(
28-
<ResetIdentityPanel onFinish={onFinish} onCancelClick={jest.fn()} />,
28+
<ResetIdentityPanel variant="compromised" onFinish={onFinish} onCancelClick={jest.fn()} />,
2929
withClientContextRenderOptions(matrixClient),
3030
);
3131
expect(asFragment()).toMatchSnapshot();
@@ -34,4 +34,13 @@ describe("<ResetIdentityPanel />", () => {
3434
expect(matrixClient.getCrypto()!.resetEncryption).toHaveBeenCalled();
3535
expect(onFinish).toHaveBeenCalled();
3636
});
37+
38+
it("should display the 'forgot recovery key' variant correctly", async () => {
39+
const onFinish = jest.fn();
40+
const { asFragment } = render(
41+
<ResetIdentityPanel variant="forgot" onFinish={onFinish} onCancelClick={jest.fn()} />,
42+
withClientContextRenderOptions(matrixClient),
43+
);
44+
expect(asFragment()).toMatchSnapshot();
45+
});
3746
});

0 commit comments

Comments
 (0)