Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Forgot recovery key? button to encryption tab #29202

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,22 @@ test.describe("Encryption tab", () => {
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
},
);

test("should display the reset identity panel when the user clicks on 'Forgot recovery key?'", async ({
page,
app,
util,
}) => {
await verifySession(app, "new passphrase");
// We need to delete the cached secrets
await deleteCachedSecrets(page);

// The "Key storage is out sync" section is displayed and the user click on the "Forgot recovery key?" button
await util.openEncryptionTab();
const dialog = util.getEncryptionTabContent();
await dialog.getByRole("button", { name: "Forgot recovery key?" }).click();

// The user is prompted to reset their identity
await expect(dialog.getByText("Forgot your recovery key? You’ll need to reset your identity.")).toBeVisible();
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions res/css/_components.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@
@import "./views/settings/encryption/_AdvancedPanel.pcss";
@import "./views/settings/encryption/_ChangeRecoveryKey.pcss";
@import "./views/settings/encryption/_EncryptionCard.pcss";
@import "./views/settings/encryption/_RecoveryPanelOutOfSync.pcss";
@import "./views/settings/encryption/_ResetIdentityPanel.pcss";
@import "./views/settings/tabs/_SettingsBanner.pcss";
@import "./views/settings/tabs/_SettingsIndent.pcss";
Expand Down
11 changes: 11 additions & 0 deletions res/css/views/settings/encryption/_RecoveryPanelOutOfSync.pcss
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

.mx_RecoveryPanelOutOfSync {
display: flex;
gap: var(--cpd-space-2x);
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ interface RecoveryPanelOutOfSyncProps {
* Callback for when the user has finished entering their recovery key.
*/
onFinish: () => void;
/**
* Callback for when the user clicks on the "Forgot recovery key?" button.
*/
onForgotRecoveryKey: () => void;
}

/**
Expand All @@ -28,7 +32,7 @@ interface RecoveryPanelOutOfSyncProps {
* It prompts the user to enter their recovery key so that the secrets can be loaded from 4S into
* the client.
*/
export function RecoveryPanelOutOfSync({ onFinish }: RecoveryPanelOutOfSyncProps): JSX.Element {
export function RecoveryPanelOutOfSync({ onForgotRecoveryKey, onFinish }: RecoveryPanelOutOfSyncProps): JSX.Element {
return (
<SettingsSection
legacy={false}
Expand All @@ -42,17 +46,22 @@ export function RecoveryPanelOutOfSync({ onFinish }: RecoveryPanelOutOfSyncProps
}
data-testid="recoveryPanel"
>
<Button
size="sm"
kind="primary"
Icon={KeyIcon}
onClick={async () => {
await accessSecretStorage();
onFinish();
}}
>
{_t("settings|encryption|recovery|enter_recovery_key")}
</Button>
<div className="mx_RecoveryPanelOutOfSync">
<Button size="sm" kind="secondary" onClick={onForgotRecoveryKey}>
{_t("settings|encryption|recovery|forgot_recovery_key")}
</Button>
<Button
size="sm"
kind="primary"
Icon={KeyIcon}
onClick={async () => {
await accessSecretStorage();
onFinish();
}}
>
{_t("settings|encryption|recovery|enter_recovery_key")}
</Button>
</div>
</SettingsSection>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,12 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Encrypti
content = <SetUpEncryptionPanel onFinish={checkEncryptionState} />;
break;
case "secrets_not_cached":
content = <RecoveryPanelOutOfSync onFinish={checkEncryptionState} />;
content = (
<RecoveryPanelOutOfSync
onFinish={checkEncryptionState}
onForgotRecoveryKey={() => setState("reset_identity_forgot")}
/>
);
break;
case "main":
content = (
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2496,7 +2496,8 @@
"description": "Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices.",
"enter_key_error": "The recovery key you entered is not correct.",
"enter_recovery_key": "Enter recovery key",
"key_storage_warning": "Your key storage is out of sync. Click the button below to fix the problem.",
"forgot_recovery_key": "Forgot recovery key?",
"key_storage_warning": "Your key storage is out of sync. Click one of the buttons below to fix the problem.",
"save_key_description": "Do not share this with anyone!",
"save_key_title": "Recovery key",
"set_up_recovery": "Set up recovery",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

import React from "react";
import { render, screen } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";

import { RecoveryPanelOutOfSync } from "../../../../../../src/components/views/settings/encryption/RecoveryPanelOutOfSync";
import { accessSecretStorage } from "../../../../../../src/SecurityManager";

jest.mock("../../../../../../src/SecurityManager", () => ({
accessSecretStorage: jest.fn(),
}));

describe("<RecoveyPanelOutOfSync />", () => {
function renderComponent(onFinish = jest.fn(), onForgotRecoveryKey = jest.fn()) {
return render(<RecoveryPanelOutOfSync onFinish={onFinish} onForgotRecoveryKey={onForgotRecoveryKey} />);
}

it("should render", () => {
const { asFragment } = renderComponent();
expect(asFragment()).toMatchSnapshot();
});

it("should call onForgotRecoveryKey when the 'Forgot recovery key?' is clicked", async () => {
const user = userEvent.setup();

const onForgotRecoveryKey = jest.fn();
renderComponent(jest.fn(), onForgotRecoveryKey);

await user.click(screen.getByRole("button", { name: "Forgot recovery key?" }));
expect(onForgotRecoveryKey).toHaveBeenCalled();
});

it("should access to 4S and call onFinish when 'Enter recovery key' is clicked", async () => {
const user = userEvent.setup();
mocked(accessSecretStorage).mockClear().mockResolvedValue();

const onFinish = jest.fn();
renderComponent(onFinish);

await user.click(screen.getByRole("button", { name: "Enter recovery key" }));
expect(accessSecretStorage).toHaveBeenCalled();
expect(onFinish).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`<RecoveyPanelOutOfSync /> should render 1`] = `
<DocumentFragment>
<div
class="mx_SettingsSection mx_SettingsSection_newUi"
data-testid="recoveryPanel"
>
<div
class="mx_SettingsSection_header"
>
<h2
class="_typography_yh5dq_162 _font-heading-sm-semibold_yh5dq_102 mx_SettingsHeader"
>
Recovery
</h2>
<div
class="mx_SettingsSubheader"
>
Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices.
<span
class="mx_SettingsSubheader_error"
>
<svg
fill="currentColor"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
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"
/>
</svg>
Your key storage is out of sync. Click one of the buttons below to fix the problem.
</span>
</div>
</div>
<div
class="mx_RecoveryPanelOutOfSync"
>
<button
class="_button_i91xf_17"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
Forgot recovery key?
</button>
<button
class="_button_i91xf_17 _has-icon_i91xf_66"
data-kind="primary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
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"
/>
</svg>
Enter recovery key
</button>
</div>
</div>
</DocumentFragment>
`;
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,13 @@ import { render, screen } from "jest-matrix-react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { waitFor } from "@testing-library/dom";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";

import {
EncryptionUserSettingsTab,
type State,
} from "../../../../../../../src/components/views/settings/tabs/user/EncryptionUserSettingsTab";
import { createTestClient, withClientContextRenderOptions } from "../../../../../../test-utils";
import Modal from "../../../../../../../src/Modal";
import { accessSecretStorage } from "../../../../../../../src/SecurityManager";

jest.mock("../../../../../../../src/SecurityManager", () => ({
accessSecretStorage: jest.fn(),
}));

describe("<EncryptionUserSettingsTab />", () => {
let matrixClient: MatrixClient;
Expand All @@ -42,8 +36,6 @@ describe("<EncryptionUserSettingsTab />", () => {
userSigningKey: true,
},
});

mocked(accessSecretStorage).mockClear().mockResolvedValue();
});

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

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

await user.click(screen.getByRole("button", { name: "Enter recovery key" }));
expect(accessSecretStorage).toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Forgot recovery key?" }));
expect(
screen.getByRole("heading", { name: "Forgot your recovery key? You’ll need to reset your identity." }),
).toBeVisible();
});

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