Skip to content

Commit 53f8312

Browse files
authored
Add Forgot recovery key? button to encryption tab (#29202)
* feat(crypto settings): add "Forgot recovery key?" button to encryption tab * test(crypto settings): add tests for `RecoveryPanelOutOfSync` * test(crypto settings): update encryption tab test * test(crypto settings): update and add e2e test
1 parent 63a3a6c commit 53f8312

File tree

11 files changed

+245
-67
lines changed

11 files changed

+245
-67
lines changed

playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,22 @@ test.describe("Encryption tab", () => {
9393
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
9494
},
9595
);
96+
97+
test("should display the reset identity panel when the user clicks on 'Forgot recovery key?'", async ({
98+
page,
99+
app,
100+
util,
101+
}) => {
102+
await verifySession(app, "new passphrase");
103+
// We need to delete the cached secrets
104+
await deleteCachedSecrets(page);
105+
106+
// The "Key storage is out sync" section is displayed and the user click on the "Forgot recovery key?" button
107+
await util.openEncryptionTab();
108+
const dialog = util.getEncryptionTabContent();
109+
await dialog.getByRole("button", { name: "Forgot recovery key?" }).click();
110+
111+
// The user is prompted to reset their identity
112+
await expect(dialog.getByText("Forgot your recovery key? You’ll need to reset your identity.")).toBeVisible();
113+
});
96114
});

res/css/_components.pcss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@
358358
@import "./views/settings/encryption/_AdvancedPanel.pcss";
359359
@import "./views/settings/encryption/_ChangeRecoveryKey.pcss";
360360
@import "./views/settings/encryption/_EncryptionCard.pcss";
361+
@import "./views/settings/encryption/_RecoveryPanelOutOfSync.pcss";
361362
@import "./views/settings/encryption/_ResetIdentityPanel.pcss";
362363
@import "./views/settings/tabs/_SettingsBanner.pcss";
363364
@import "./views/settings/tabs/_SettingsIndent.pcss";
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
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+
.mx_RecoveryPanelOutOfSync {
9+
display: flex;
10+
gap: var(--cpd-space-2x);
11+
}

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

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ interface RecoveryPanelOutOfSyncProps {
1919
* Callback for when the user has finished entering their recovery key.
2020
*/
2121
onFinish: () => void;
22+
/**
23+
* Callback for when the user clicks on the "Forgot recovery key?" button.
24+
*/
25+
onForgotRecoveryKey: () => void;
2226
}
2327

2428
/**
@@ -28,7 +32,7 @@ interface RecoveryPanelOutOfSyncProps {
2832
* It prompts the user to enter their recovery key so that the secrets can be loaded from 4S into
2933
* the client.
3034
*/
31-
export function RecoveryPanelOutOfSync({ onFinish }: RecoveryPanelOutOfSyncProps): JSX.Element {
35+
export function RecoveryPanelOutOfSync({ onForgotRecoveryKey, onFinish }: RecoveryPanelOutOfSyncProps): JSX.Element {
3236
return (
3337
<SettingsSection
3438
legacy={false}
@@ -42,17 +46,22 @@ export function RecoveryPanelOutOfSync({ onFinish }: RecoveryPanelOutOfSyncProps
4246
}
4347
data-testid="recoveryPanel"
4448
>
45-
<Button
46-
size="sm"
47-
kind="primary"
48-
Icon={KeyIcon}
49-
onClick={async () => {
50-
await accessSecretStorage();
51-
onFinish();
52-
}}
53-
>
54-
{_t("settings|encryption|recovery|enter_recovery_key")}
55-
</Button>
49+
<div className="mx_RecoveryPanelOutOfSync">
50+
<Button size="sm" kind="secondary" onClick={onForgotRecoveryKey}>
51+
{_t("settings|encryption|recovery|forgot_recovery_key")}
52+
</Button>
53+
<Button
54+
size="sm"
55+
kind="primary"
56+
Icon={KeyIcon}
57+
onClick={async () => {
58+
await accessSecretStorage();
59+
onFinish();
60+
}}
61+
>
62+
{_t("settings|encryption|recovery|enter_recovery_key")}
63+
</Button>
64+
</div>
5665
</SettingsSection>
5766
);
5867
}

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,12 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Encrypti
7171
content = <SetUpEncryptionPanel onFinish={checkEncryptionState} />;
7272
break;
7373
case "secrets_not_cached":
74-
content = <RecoveryPanelOutOfSync onFinish={checkEncryptionState} />;
74+
content = (
75+
<RecoveryPanelOutOfSync
76+
onFinish={checkEncryptionState}
77+
onForgotRecoveryKey={() => setState("reset_identity_forgot")}
78+
/>
79+
);
7580
break;
7681
case "main":
7782
content = (

src/i18n/strings/en_EN.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2496,7 +2496,8 @@
24962496
"description": "Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices.",
24972497
"enter_key_error": "The recovery key you entered is not correct.",
24982498
"enter_recovery_key": "Enter recovery key",
2499-
"key_storage_warning": "Your key storage is out of sync. Click the button below to fix the problem.",
2499+
"forgot_recovery_key": "Forgot recovery key?",
2500+
"key_storage_warning": "Your key storage is out of sync. Click one of the buttons below to fix the problem.",
25002501
"save_key_description": "Do not share this with anyone!",
25012502
"save_key_title": "Recovery key",
25022503
"set_up_recovery": "Set up recovery",
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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 React from "react";
9+
import { render, screen } from "jest-matrix-react";
10+
import userEvent from "@testing-library/user-event";
11+
import { mocked } from "jest-mock";
12+
13+
import { RecoveryPanelOutOfSync } from "../../../../../../src/components/views/settings/encryption/RecoveryPanelOutOfSync";
14+
import { accessSecretStorage } from "../../../../../../src/SecurityManager";
15+
16+
jest.mock("../../../../../../src/SecurityManager", () => ({
17+
accessSecretStorage: jest.fn(),
18+
}));
19+
20+
describe("<RecoveyPanelOutOfSync />", () => {
21+
function renderComponent(onFinish = jest.fn(), onForgotRecoveryKey = jest.fn()) {
22+
return render(<RecoveryPanelOutOfSync onFinish={onFinish} onForgotRecoveryKey={onForgotRecoveryKey} />);
23+
}
24+
25+
it("should render", () => {
26+
const { asFragment } = renderComponent();
27+
expect(asFragment()).toMatchSnapshot();
28+
});
29+
30+
it("should call onForgotRecoveryKey when the 'Forgot recovery key?' is clicked", async () => {
31+
const user = userEvent.setup();
32+
33+
const onForgotRecoveryKey = jest.fn();
34+
renderComponent(jest.fn(), onForgotRecoveryKey);
35+
36+
await user.click(screen.getByRole("button", { name: "Forgot recovery key?" }));
37+
expect(onForgotRecoveryKey).toHaveBeenCalled();
38+
});
39+
40+
it("should access to 4S and call onFinish when 'Enter recovery key' is clicked", async () => {
41+
const user = userEvent.setup();
42+
mocked(accessSecretStorage).mockClear().mockResolvedValue();
43+
44+
const onFinish = jest.fn();
45+
renderComponent(onFinish);
46+
47+
await user.click(screen.getByRole("button", { name: "Enter recovery key" }));
48+
expect(accessSecretStorage).toHaveBeenCalled();
49+
expect(onFinish).toHaveBeenCalled();
50+
});
51+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`<RecoveyPanelOutOfSync /> should render 1`] = `
4+
<DocumentFragment>
5+
<div
6+
class="mx_SettingsSection mx_SettingsSection_newUi"
7+
data-testid="recoveryPanel"
8+
>
9+
<div
10+
class="mx_SettingsSection_header"
11+
>
12+
<h2
13+
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102 mx_SettingsHeader"
14+
>
15+
Recovery
16+
</h2>
17+
<div
18+
class="mx_SettingsSubheader"
19+
>
20+
Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices.
21+
<span
22+
class="mx_SettingsSubheader_error"
23+
>
24+
<svg
25+
fill="currentColor"
26+
height="20px"
27+
viewBox="0 0 24 24"
28+
width="20px"
29+
xmlns="http://www.w3.org/2000/svg"
30+
>
31+
<path
32+
d="M12 17a.97.97 0 0 0 .713-.288A.968.968 0 0 0 13 16a.968.968 0 0 0-.287-.713A.968.968 0 0 0 12 15a.968.968 0 0 0-.713.287A.968.968 0 0 0 11 16c0 .283.096.52.287.712.192.192.43.288.713.288Zm0-4c.283 0 .52-.096.713-.287A.968.968 0 0 0 13 12V8a.967.967 0 0 0-.287-.713A.968.968 0 0 0 12 7a.968.968 0 0 0-.713.287A.967.967 0 0 0 11 8v4c0 .283.096.52.287.713.192.191.43.287.713.287Zm0 9a9.738 9.738 0 0 1-3.9-.788 10.099 10.099 0 0 1-3.175-2.137c-.9-.9-1.612-1.958-2.137-3.175A9.738 9.738 0 0 1 2 12a9.74 9.74 0 0 1 .788-3.9 10.099 10.099 0 0 1 2.137-3.175c.9-.9 1.958-1.612 3.175-2.137A9.738 9.738 0 0 1 12 2a9.74 9.74 0 0 1 3.9.788 10.098 10.098 0 0 1 3.175 2.137c.9.9 1.613 1.958 2.137 3.175A9.738 9.738 0 0 1 22 12a9.738 9.738 0 0 1-.788 3.9 10.098 10.098 0 0 1-2.137 3.175c-.9.9-1.958 1.613-3.175 2.137A9.738 9.738 0 0 1 12 22Z"
33+
/>
34+
</svg>
35+
Your key storage is out of sync. Click one of the buttons below to fix the problem.
36+
</span>
37+
</div>
38+
</div>
39+
<div
40+
class="mx_RecoveryPanelOutOfSync"
41+
>
42+
<button
43+
class="_button_i91xf_17"
44+
data-kind="secondary"
45+
data-size="sm"
46+
role="button"
47+
tabindex="0"
48+
>
49+
Forgot recovery key?
50+
</button>
51+
<button
52+
class="_button_i91xf_17 _has-icon_i91xf_66"
53+
data-kind="primary"
54+
data-size="sm"
55+
role="button"
56+
tabindex="0"
57+
>
58+
<svg
59+
aria-hidden="true"
60+
fill="currentColor"
61+
height="20"
62+
viewBox="0 0 24 24"
63+
width="20"
64+
xmlns="http://www.w3.org/2000/svg"
65+
>
66+
<path
67+
d="M7 14c-.55 0-1.02-.196-1.412-.588A1.926 1.926 0 0 1 5 12c0-.55.196-1.02.588-1.412A1.926 1.926 0 0 1 7 10c.55 0 1.02.196 1.412.588.392.391.588.862.588 1.412 0 .55-.196 1.02-.588 1.412A1.926 1.926 0 0 1 7 14Zm0 4c-1.667 0-3.083-.583-4.25-1.75C1.583 15.083 1 13.667 1 12c0-1.667.583-3.083 1.75-4.25C3.917 6.583 5.333 6 7 6c1.117 0 2.13.275 3.037.825A6.212 6.212 0 0 1 12.2 9h8.375a1.033 1.033 0 0 1 .725.3l2 2c.1.1.17.208.212.325.042.117.063.242.063.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-3.175 3.175a.946.946 0 0 1-.3.2c-.117.05-.233.083-.35.1a.832.832 0 0 1-.35-.025.884.884 0 0 1-.325-.175L17.5 15l-1.425 1.075a.945.945 0 0 1-.887.15.859.859 0 0 1-.288-.15L13.375 15H12.2a6.212 6.212 0 0 1-2.162 2.175C9.128 17.725 8.117 18 7 18Zm0-2c.933 0 1.754-.283 2.463-.85A4.032 4.032 0 0 0 10.875 13H14l1.45 1.025L17.5 12.5l1.775 1.375L21.15 12l-1-1h-9.275a4.032 4.032 0 0 0-1.412-2.15C8.754 8.283 7.933 8 7 8c-1.1 0-2.042.392-2.825 1.175C3.392 9.958 3 10.9 3 12s.392 2.042 1.175 2.825C4.958 15.608 5.9 16 7 16Z"
68+
/>
69+
</svg>
70+
Enter recovery key
71+
</button>
72+
</div>
73+
</div>
74+
</DocumentFragment>
75+
`;

test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,13 @@ import { render, screen } from "jest-matrix-react";
1010
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
1111
import { waitFor } from "@testing-library/dom";
1212
import userEvent from "@testing-library/user-event";
13-
import { mocked } from "jest-mock";
1413

1514
import {
1615
EncryptionUserSettingsTab,
1716
type State,
1817
} from "../../../../../../../src/components/views/settings/tabs/user/EncryptionUserSettingsTab";
1918
import { createTestClient, withClientContextRenderOptions } from "../../../../../../test-utils";
2019
import Modal from "../../../../../../../src/Modal";
21-
import { accessSecretStorage } from "../../../../../../../src/SecurityManager";
22-
23-
jest.mock("../../../../../../../src/SecurityManager", () => ({
24-
accessSecretStorage: jest.fn(),
25-
}));
2620

2721
describe("<EncryptionUserSettingsTab />", () => {
2822
let matrixClient: MatrixClient;
@@ -42,8 +36,6 @@ describe("<EncryptionUserSettingsTab />", () => {
4236
userSigningKey: true,
4337
},
4438
});
45-
46-
mocked(accessSecretStorage).mockClear().mockResolvedValue();
4739
});
4840

4941
function renderComponent(props: { initialState?: State } = {}) {
@@ -79,7 +71,7 @@ describe("<EncryptionUserSettingsTab />", () => {
7971
await waitFor(() => expect(screen.getByText("Recovery")).toBeInTheDocument());
8072
});
8173

82-
it("should ask to enter the recovery key when secrets are not cached", async () => {
74+
it("should display the recovery out of sync panel when secrets are not cached", async () => {
8375
// Secrets are not cached
8476
jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({
8577
privateKeysInSecretStorage: true,
@@ -97,8 +89,10 @@ describe("<EncryptionUserSettingsTab />", () => {
9789
await waitFor(() => screen.getByRole("button", { name: "Enter recovery key" }));
9890
expect(asFragment()).toMatchSnapshot();
9991

100-
await user.click(screen.getByRole("button", { name: "Enter recovery key" }));
101-
expect(accessSecretStorage).toHaveBeenCalled();
92+
await user.click(screen.getByRole("button", { name: "Forgot recovery key?" }));
93+
expect(
94+
screen.getByRole("heading", { name: "Forgot your recovery key? You’ll need to reset your identity." }),
95+
).toBeVisible();
10296
});
10397

10498
it("should display the change recovery key panel when the user clicks on the change recovery button", async () => {

0 commit comments

Comments
 (0)