Delegate to ResetIdentityDialog from SetupEncryptionBody

This commit is contained in:
Andy Balaam
2025-04-11 11:52:41 +01:00
parent 8d714bdef3
commit 79c905de45
12 changed files with 263 additions and 77 deletions

View File

@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import { test, expect } from "../../element-web-test";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { completeCreateSecretStorageDialog, createBot, logIntoElement } from "./utils.ts";
import { createBot, logIntoElement } from "./utils.ts";
import { type Client } from "../../pages/client.ts";
import { type ElementAppPage } from "../../pages/ElementAppPage.ts";
@@ -35,14 +35,20 @@ test.describe("Dehydration", () => {
await app.closeDialog();
// Verify the device by resetting the key
// Reset the key
const settings = await app.settings.openUserSettings("Encryption");
await settings.getByRole("button", { name: "Verify this device" }).click();
await page.getByRole("button", { name: "Proceed with reset" }).click();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Copy" }).click();
// Set up recovery
await page.getByRole("button", { name: "Set up recovery" }).click();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Done" }).click();
const recoveryKey = await page.getByTestId("recoveryKey").innerText();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("textbox").fill(recoveryKey);
await page.getByRole("button", { name: "Finish set up" }).click();
await page.getByRole("button", { name: "Close" }).click();
await expectDehydratedDeviceEnabled(app);
@@ -80,7 +86,7 @@ test.describe("Dehydration", () => {
await expectDehydratedDeviceEnabled(app);
});
test("Reset recovery key during login re-creates dehydrated device", async ({
test("Reset identity during login and set up recovery re-creates dehydrated device", async ({
page,
homeserver,
app,
@@ -99,16 +105,26 @@ test.describe("Dehydration", () => {
// Log in our client
await logIntoElement(page, credentials);
// Oh no, we forgot our recovery key
// Oh no, we forgot our recovery key - reset our identity
await page.locator(".mx_AuthPage").getByRole("button", { name: "Reset all" }).click();
await page.locator(".mx_AuthPage").getByRole("button", { name: "Proceed with reset" }).click();
await expect(
page.getByRole("heading", { name: "Are you sure you want to reset your identity?" }),
).toBeVisible();
await page.getByRole("button", { name: "Continue", exact: true }).click();
await page.getByPlaceholder("Password").fill(credentials.password);
await page.getByRole("button", { name: "Continue" }).click();
await completeCreateSecretStorageDialog(page, { accountPassword: credentials.password });
// And set up recovery
const settings = await app.settings.openUserSettings("Encryption");
await settings.getByRole("button", { name: "Set up recovery" }).click();
await settings.getByRole("button", { name: "Continue" }).click();
const recoveryKey = await settings.getByTestId("recoveryKey").innerText();
await settings.getByRole("button", { name: "Continue" }).click();
await settings.getByRole("textbox").fill(recoveryKey);
await settings.getByRole("button", { name: "Finish set up" }).click();
// There should be a brand new dehydrated device
const dehydratedDeviceIds = await getDehydratedDeviceIds(app.client);
expect(dehydratedDeviceIds.length).toBe(1);
expect(dehydratedDeviceIds[0]).not.toEqual(initialDehydratedDeviceIds[0]);
await expectDehydratedDeviceEnabled(app);
});
test("'Reset cryptographic identity' removes dehydrated device", async ({ page, homeserver, app, credentials }) => {

View File

@@ -75,12 +75,9 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
} else if (phase === Phase.ConfirmSkip) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("common|are_you_sure");
} else if (phase === Phase.Busy) {
} else if (phase === Phase.Busy || phase === Phase.ConfirmReset) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("encryption|verification|after_new_login|verify_this_device");
} else if (phase === Phase.ConfirmReset) {
icon = <span className="mx_CompleteSecurity_headerIcon mx_E2EIcon_warning" />;
title = _t("encryption|verification|after_new_login|reset_confirmation");
} else if (phase === Phase.Finished) {
// SetupEncryptionBody will take care of calling onFinished, we don't need to do anything
} else {

View File

@@ -1,5 +1,5 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024, 2025 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -19,6 +19,7 @@ import { SetupEncryptionStore, Phase } from "../../../stores/SetupEncryptionStor
import EncryptionPanel from "../../views/right_panel/EncryptionPanel";
import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton";
import Spinner from "../../views/elements/Spinner";
import { ResetIdentityDialog } from "../../views/dialogs/ResetIdentityDialog";
function keyHasPassphrase(keyInfo: SecretStorageKeyDescription): boolean {
return Boolean(keyInfo.passphrase && keyInfo.passphrase.salt && keyInfo.passphrase.iterations);
@@ -114,12 +115,18 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
ev.preventDefault();
const store = SetupEncryptionStore.sharedInstance();
store.reset();
};
private onResetConfirmClick = (): void => {
this.props.onFinished();
const store = SetupEncryptionStore.sharedInstance();
store.resetConfirm();
Modal.createDialog(ResetIdentityDialog, {
onReset: () => {
// The user completed the reset process - close this dialog
this.props.onFinished();
this.onDoneClick();
},
onFinished: () => {
// The user cancelled the reset dialog or click away - go back a step
this.onResetBackClick();
},
variant: "confirm",
});
};
private onResetBackClick = (): void => {
@@ -157,7 +164,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
<p>{_t("encryption|verification|no_key_or_device")}</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton kind="primary" onClick={this.onResetConfirmClick}>
<AccessibleButton kind="primary" onClick={this.onResetClick}>
{_t("encryption|verification|reset_proceed_prompt")}
</AccessibleButton>
</div>
@@ -246,23 +253,7 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
</div>
</div>
);
} else if (phase === Phase.ConfirmReset) {
return (
<div>
<p>{_t("encryption|verification|verify_reset_warning_1")}</p>
<p>{_t("encryption|verification|verify_reset_warning_2")}</p>
<div className="mx_CompleteSecurity_actionRow">
<AccessibleButton kind="danger_outline" onClick={this.onResetConfirmClick}>
{_t("encryption|verification|reset_proceed_prompt")}
</AccessibleButton>
<AccessibleButton kind="primary" onClick={this.onResetBackClick}>
{_t("action|go_back")}
</AccessibleButton>
</div>
</div>
);
} else if (phase === Phase.Busy || phase === Phase.Loading) {
} else if (phase === Phase.Busy || phase === Phase.Loading || phase == Phase.ConfirmReset) {
return <Spinner />;
} else {
logger.log(`SetupEncryptionBody: Unknown phase ${phase}`);

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type MouseEventHandler } from "react";
import React, { type JSX, type MouseEventHandler } from "react";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
@@ -16,14 +16,11 @@ interface ResetIdentityDialogProps {
* Called when the dialog closes.
*/
onFinished: () => void;
/**
* Called when the identity is reset.
* Called when the identity is reset (before onFinished is called).
*/
onResetFinished: MouseEventHandler<HTMLButtonElement>;
/**
* Called when the cancel button is clicked.
*/
onCancelClick: () => void;
onReset: MouseEventHandler<HTMLButtonElement>;
/**
* Which variant of this dialog to show.
@@ -34,28 +31,19 @@ interface ResetIdentityDialogProps {
/**
* The dialog for resetting the identity of the current user.
*/
export function ResetIdentityDialog({
onFinished,
onCancelClick,
onResetFinished,
variant,
}: ResetIdentityDialogProps): JSX.Element {
export function ResetIdentityDialog({ onFinished, onReset, variant }: ResetIdentityDialogProps): JSX.Element {
const matrixClient = MatrixClientPeg.safeGet();
// Wrappers for ResetIdentityBody's callbacks so that onFinish gets called
// whenever the reset is done, whether by completing successfully, or by
// being cancelled
const onResetWrapper: MouseEventHandler<HTMLButtonElement> = (...args) => {
onReset(...args);
onFinished();
onResetFinished(...args);
};
const onCancelWrapper: () => void = () => {
onFinished();
onCancelClick();
};
return (
<MatrixClientContext.Provider value={matrixClient}>
<ResetIdentityBody onFinish={onResetWrapper} onCancelClick={onCancelWrapper} variant={variant} />
<ResetIdentityBody onReset={onResetWrapper} onCancelClick={onFinished} variant={variant} />
</MatrixClientContext.Provider>
);
}

View File

@@ -22,7 +22,8 @@ interface ResetIdentityBodyProps {
/**
* Called when the identity is reset.
*/
onFinish: MouseEventHandler<HTMLButtonElement>;
onReset: MouseEventHandler<HTMLButtonElement>;
/**
* Called when the cancel button is clicked.
*/
@@ -53,7 +54,7 @@ export type ResetIdentityBodyVariant = "compromised" | "forgot" | "sync_failed"
*
* Used by {@link ResetIdentityPanel}.
*/
export function ResetIdentityBody({ onCancelClick, onFinish, variant }: ResetIdentityBodyProps): JSX.Element {
export function ResetIdentityBody({ onCancelClick, onReset, variant }: ResetIdentityBodyProps): JSX.Element {
const matrixClient = useMatrixClientContext();
// After the user clicks "Continue", we disable the button so it can't be
@@ -85,7 +86,7 @@ export function ResetIdentityBody({ onCancelClick, onFinish, variant }: ResetIde
await matrixClient
.getCrypto()
?.resetEncryption((makeRequest) => uiAuthCallback(matrixClient, makeRequest));
onFinish(evt);
onReset(evt);
}}
>
{inProgress ? (

View File

@@ -15,7 +15,8 @@ interface ResetIdentityPanelProps {
/**
* Called when the identity is reset.
*/
onFinish: MouseEventHandler<HTMLButtonElement>;
onReset: MouseEventHandler<HTMLButtonElement>;
/**
* Called when the cancel button is clicked or when we go back in the breadcrumbs.
*/
@@ -32,7 +33,7 @@ interface ResetIdentityPanelProps {
*
* A thin wrapper around {@link ResetIdentityBody}, just adding breadcrumbs.
*/
export function ResetIdentityPanel({ onCancelClick, onFinish, variant }: ResetIdentityPanelProps): JSX.Element {
export function ResetIdentityPanel({ onCancelClick, onReset, variant }: ResetIdentityPanelProps): JSX.Element {
return (
<>
<Breadcrumb
@@ -41,7 +42,7 @@ export function ResetIdentityPanel({ onCancelClick, onFinish, variant }: ResetId
pages={[_t("settings|encryption|title"), _t("settings|encryption|advanced|breadcrumb_page")]}
onPageClick={onCancelClick}
/>
<ResetIdentityBody onFinish={onFinish} onCancelClick={onCancelClick} variant={variant} />
<ResetIdentityBody onReset={onReset} onCancelClick={onCancelClick} variant={variant} />
</>
);
}

View File

@@ -128,7 +128,7 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Props):
<ResetIdentityPanel
variant={findResetVariant(state)}
onCancelClick={checkEncryptionState}
onFinish={checkEncryptionState}
onReset={checkEncryptionState}
/>
);
break;

View File

@@ -992,7 +992,6 @@
"accepting": "Accepting…",
"after_new_login": {
"device_verified": "Device verified",
"reset_confirmation": "Really reset verification keys?",
"skip_verification": "Skip verification for now",
"unable_to_verify": "Unable to verify this device",
"verify_this_device": "Verify this device"
@@ -1063,8 +1062,6 @@
"verify_emoji_prompt": "Verify by comparing unique emoji.",
"verify_emoji_prompt_qr": "If you can't scan the code above, verify by comparing unique emoji.",
"verify_later": "I'll verify later",
"verify_reset_warning_1": "Resetting your verification keys cannot be undone. After resetting, you won't have access to old encrypted messages, and any friends who have previously verified you will see security warnings until you re-verify with them.",
"verify_reset_warning_2": "Please only proceed if you're sure you've lost all of your other devices and your Recovery Key.",
"verify_using_device": "Verify with another device",
"verify_using_key": "Verify with Recovery Key",
"verify_using_key_or_phrase": "Verify with Recovery Key or Phrase",

View File

@@ -0,0 +1,62 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2018-2022 The Matrix.org Foundation C.I.C.
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, { act } from "react";
import { render } from "jest-matrix-react";
import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
import { type Mocked } from "jest-mock";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { getMockClientWithEventEmitter } from "../../../../test-utils";
import { ResetIdentityDialog } from "../../../../../src/components/views/dialogs/ResetIdentityDialog";
describe("ResetIdentityDialog", () => {
afterEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
});
it("should call onReset and onFinished when we click Continue", async () => {
const client = mockClient();
const onFinished = jest.fn();
const onReset = jest.fn();
const dialog = render(<ResetIdentityDialog onFinished={onFinished} onReset={onReset} variant="compromised" />);
await act(async () => dialog.getByRole("button", { name: "Continue" }).click());
expect(onReset).toHaveBeenCalled();
expect(onFinished).toHaveBeenCalled();
expect(client.getCrypto()?.resetEncryption).toHaveBeenCalled();
});
it("should just call onFinished when we click Cancel", async () => {
const client = mockClient();
const onFinished = jest.fn();
const onReset = jest.fn();
const dialog = render(<ResetIdentityDialog onFinished={onFinished} onReset={onReset} variant="compromised" />);
await act(async () => dialog.getByRole("button", { name: "Cancel" }).click());
expect(onFinished).toHaveBeenCalled();
expect(onReset).not.toHaveBeenCalled();
expect(client.getCrypto()?.resetEncryption).not.toHaveBeenCalled();
});
});
function mockClient(): Mocked<MatrixClient> {
const mockCrypto = {
resetEncryption: jest.fn().mockResolvedValue(null),
} as unknown as Mocked<CryptoApi>;
return getMockClientWithEventEmitter({
getCrypto: jest.fn().mockReturnValue(mockCrypto),
});
}

View File

@@ -0,0 +1,118 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2018-2022 The Matrix.org Foundation C.I.C.
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, { act } from "react";
import { render, screen } from "jest-matrix-react";
import { type Mocked } from "jest-mock";
import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
import SetupEncryptionDialog from "../../../../../src/components/views/dialogs/security/SetupEncryptionDialog";
import { getMockClientWithEventEmitter } from "../../../../test-utils";
import { Phase, SetupEncryptionStore } from "../../../../../src/stores/SetupEncryptionStore";
import Modal from "../../../../../src/Modal";
describe("SetupEncryptionDialog", () => {
afterEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
});
it("should launch a dialog when I say Proceed, then be finished when I reset", async () => {
mockClient();
const store = new SetupEncryptionStore();
jest.spyOn(SetupEncryptionStore, "sharedInstance").mockReturnValue(store);
// Given when you open the reset dialog we immediately reset
jest.spyOn(Modal, "createDialog").mockImplementation((_, props) => {
// Simulate doing the reset in the dialog
props?.onReset();
return {
close: jest.fn(),
finished: Promise.resolve([]),
};
});
// When we launch the dialog and set it ready to start
const onFinished = jest.fn();
render(<SetupEncryptionDialog onFinished={onFinished} />);
await act(async () => await store.fetchKeyInfo());
expect(store.phase).toBe(Phase.Intro);
// And we hit the Proceed with reset button.
// (The createDialog mock above simulates the user doing the reset)
await act(async () => screen.getByRole("button", { name: "Proceed with reset" }).click());
// Then the phase has been set to Finished
expect(store.phase).toBe(Phase.Finished);
});
it("should launch a dialog when I say Proceed, then be ready when I cancel", async () => {
mockClient();
const store = new SetupEncryptionStore();
jest.spyOn(SetupEncryptionStore, "sharedInstance").mockReturnValue(store);
// Given when you open the reset dialog we immediately reset
jest.spyOn(Modal, "createDialog").mockImplementation((_, props) => {
// Simulate doing the reset in the dialog
props?.onFinished();
return {
close: jest.fn(),
finished: Promise.resolve([]),
};
});
// When we launch the dialog and set it ready to start
const onFinished = jest.fn();
render(<SetupEncryptionDialog onFinished={onFinished} />);
await act(async () => await store.fetchKeyInfo());
expect(store.phase).toBe(Phase.Intro);
// And we hit the Proceed with reset button.
// (The createDialog mock above simulates the user hitting cancel)
await act(async () => screen.getByRole("button", { name: "Proceed with reset" }).click());
// Then the phase has been set to Finished
expect(store.phase).toBe(Phase.Intro);
});
});
function mockClient() {
const mockCrypto = {
getDeviceVerificationStatus: jest.fn().mockResolvedValue({
crossSigningVerified: false,
}),
getUserDeviceInfo: jest.fn().mockResolvedValue(new Map()),
isCrossSigningReady: jest.fn().mockResolvedValue(true),
isSecretStorageReady: jest.fn().mockResolvedValue(true),
userHasCrossSigningKeys: jest.fn(),
getActiveSessionBackupVersion: jest.fn(),
getCrossSigningStatus: jest.fn().mockReturnValue({
publicKeysOnDevice: true,
privateKeysInSecretStorage: true,
privateKeysCachedLocally: {
masterKey: true,
selfSigningKey: true,
userSigningKey: true,
},
}),
getSessionBackupPrivateKey: jest.fn(),
isEncryptionEnabledInRoom: jest.fn(),
getKeyBackupInfo: jest.fn().mockResolvedValue(null),
getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]),
} as unknown as Mocked<CryptoApi>;
const userId = "@user:server";
getMockClientWithEventEmitter({
getCrypto: jest.fn().mockReturnValue(mockCrypto),
getUserId: jest.fn().mockReturnValue(userId),
secretStorage: { isStored: jest.fn().mockReturnValue({}) },
});
}

View File

@@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen } from "jest-matrix-react";
import { act, render, screen } from "jest-matrix-react";
import { mocked } from "jest-mock";
import EventEmitter from "events";
@@ -76,4 +76,19 @@ describe("CompleteSecurity", () => {
expect(screen.queryByRole("button", { name: "Skip verification for now" })).not.toBeInTheDocument();
});
it("Renders a warning if user hits Reset", async () => {
// Given a store and a dialog based on it
const store = new SetupEncryptionStore();
jest.spyOn(SetupEncryptionStore, "sharedInstance").mockReturnValue(store);
const panel = await act(() => render(<CompleteSecurity onFinished={() => {}} />));
// When we hit reset
await act(async () => panel.getByRole("button", { name: "Proceed with reset" }).click());
//await act(async () => store.reset());
// Then the title and button update
expect(screen.getByRole("heading", { name: "Verify this device" })).toBeInTheDocument();
expect(panel.getByRole("button", { name: "Continue" })).toBeInTheDocument();
});
});

View File

@@ -24,9 +24,9 @@ describe("<ResetIdentityPanel />", () => {
it("should reset the encryption when the continue button is clicked", async () => {
const user = userEvent.setup();
const onFinish = jest.fn();
const onReset = jest.fn();
const { asFragment } = render(
<ResetIdentityPanel variant="compromised" onFinish={onFinish} onCancelClick={jest.fn()} />,
<ResetIdentityPanel variant="compromised" onReset={onReset} onCancelClick={jest.fn()} />,
withClientContextRenderOptions(matrixClient),
);
expect(asFragment()).toMatchSnapshot();
@@ -43,22 +43,22 @@ describe("<ResetIdentityPanel />", () => {
await sleep(0);
expect(matrixClient.getCrypto()!.resetEncryption).toHaveBeenCalled();
expect(onFinish).toHaveBeenCalled();
expect(onReset).toHaveBeenCalled();
});
it("should display the 'forgot recovery key' variant correctly", async () => {
const onFinish = jest.fn();
const onReset = jest.fn();
const { asFragment } = render(
<ResetIdentityPanel variant="forgot" onFinish={onFinish} onCancelClick={jest.fn()} />,
<ResetIdentityPanel variant="forgot" onReset={onReset} onCancelClick={jest.fn()} />,
withClientContextRenderOptions(matrixClient),
);
expect(asFragment()).toMatchSnapshot();
});
it("should display the 'sync failed' variant correctly", async () => {
const onFinish = jest.fn();
const onReset = jest.fn();
const { asFragment } = render(
<ResetIdentityPanel variant="sync_failed" onFinish={onFinish} onCancelClick={jest.fn()} />,
<ResetIdentityPanel variant="sync_failed" onReset={onReset} onCancelClick={jest.fn()} />,
withClientContextRenderOptions(matrixClient),
);
expect(asFragment()).toMatchSnapshot();