Skip to content

Commit a577a2c

Browse files
richvdhsnowping
authored andcommitted
AccessSecretStorageDialog: fix inability to enter recovery key (element-hq#30090)
* BaseDialog: fix documentation, and make `onFinished` optional Since `onFinished` isn't used if `hasCancel` is false, it's a bit silly to make it mandatory. * AccessSecretStorageDialog: fix inability to enter recovery key Wrap AccessSecretStorageDialog in a `BaseDialog`. The main thing this achieves is a `FocusLock`. * playwright: factor out helper for verification We have two copies of the same code, and we're about to add a third... * playwright: test for verifying from Settings * Add a unit test for BaseDialog
1 parent 29276c6 commit a577a2c

File tree

4 files changed

+82
-38
lines changed

4 files changed

+82
-38
lines changed

playwright/e2e/crypto/device-verification.spec.ts

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from "./utils";
2323
import { type Bot } from "../../pages/bot";
2424
import { Toasts } from "../../pages/toasts.ts";
25+
import type { ElementAppPage } from "../../pages/ElementAppPage.ts";
2526

2627
test.describe("Device verification", { tag: "@no-webkit" }, () => {
2728
let aliceBotClient: Bot;
@@ -163,46 +164,52 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
163164

164165
test("Verify device with Security Phrase during login", async ({ page, app, credentials, homeserver }) => {
165166
await logIntoElement(page, credentials);
167+
await enterRecoveryKeyAndCheckVerified(page, app, "new passphrase");
168+
});
166169

167-
// Select the security phrase
168-
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click();
170+
test("Verify device with Recovery Key during login", async ({ page, app, credentials, homeserver }) => {
171+
const recoveryKey = (await aliceBotClient.getRecoveryKey()).encodedPrivateKey;
169172

170-
// Fill the passphrase
171-
const dialog = page.locator(".mx_Dialog");
172-
await dialog.locator("textarea").fill("new passphrase");
173-
await dialog.getByRole("button", { name: "Continue", disabled: false }).click();
173+
await logIntoElement(page, credentials);
174+
await enterRecoveryKeyAndCheckVerified(page, app, recoveryKey);
175+
});
174176

175-
await page.locator(".mx_AuthPage").getByRole("button", { name: "Done" }).click();
177+
test("Verify device with Recovery Key from settings", async ({ page, app, credentials }) => {
178+
const recoveryKey = (await aliceBotClient.getRecoveryKey()).encodedPrivateKey;
176179

177-
// Check that our device is now cross-signed
178-
await checkDeviceIsCrossSigned(app);
180+
await logIntoElement(page, credentials);
179181

180-
// Check that the current device is connected to key backup
181-
// The backup decryption key should be in cache also, as we got it directly from the 4S
182-
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
183-
});
182+
/* Dismiss "Verify this device" */
183+
const authPage = page.locator(".mx_AuthPage");
184+
await authPage.getByRole("button", { name: "Skip verification for now" }).click();
185+
await authPage.getByRole("button", { name: "I'll verify later" }).click();
186+
await page.waitForSelector(".mx_MatrixChat");
184187

185-
test("Verify device with Recovery Key during login", async ({ page, app, credentials, homeserver }) => {
186-
await logIntoElement(page, credentials);
188+
const settings = await app.settings.openUserSettings("Encryption");
189+
await settings.getByRole("button", { name: "Verify this device" }).click();
190+
await enterRecoveryKeyAndCheckVerified(page, app, recoveryKey);
191+
});
187192

188-
// Select the security phrase
189-
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click();
193+
/** Helper for the three tests above which verify by recovery key */
194+
async function enterRecoveryKeyAndCheckVerified(page: Page, app: ElementAppPage, recoveryKey: string) {
195+
await page.getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click();
190196

191-
// Fill the recovery key
197+
// Enter the recovery key
192198
const dialog = page.locator(".mx_Dialog");
193-
const aliceRecoveryKey = await aliceBotClient.getRecoveryKey();
194-
await dialog.locator("textarea").fill(aliceRecoveryKey.encodedPrivateKey);
199+
// We use `pressSequentially` here to make sure that the FocusLock isn't causing us any problems
200+
// (cf https://github.com/element-hq/element-web/issues/30089)
201+
await dialog.locator("textarea").pressSequentially(recoveryKey);
195202
await dialog.getByRole("button", { name: "Continue", disabled: false }).click();
196203

197-
await page.locator(".mx_AuthPage").getByRole("button", { name: "Done" }).click();
204+
await page.getByRole("button", { name: "Done" }).click();
198205

199206
// Check that our device is now cross-signed
200207
await checkDeviceIsCrossSigned(app);
201208

202209
// Check that the current device is connected to key backup
203210
// The backup decryption key should be in cache also, as we got it directly from the 4S
204211
await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true);
205-
});
212+
}
206213

207214
test("Handle incoming verification request with SAS", async ({ page, credentials, homeserver, toasts }) => {
208215
await logIntoElement(page, credentials);

src/components/views/dialogs/BaseDialog.tsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,25 @@ import { getKeyBindingsManager } from "../../../KeyBindingsManager";
2323
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
2424

2525
interface IProps {
26-
// Whether the dialog should have a 'close' button that will
27-
// cause the dialog to be cancelled. This should only be set
28-
// to false if there is nothing the app can sensibly do if the
29-
// dialog is cancelled, eg. "We can't restore your session and
30-
// the app cannot work". Default: true.
26+
/**
27+
* Whether the dialog should have a 'close' button and a keyDown handler which
28+
* will intercept 'Escape'.
29+
*
30+
* This should only be set to `false` if there is nothing the app can sensibly do if the
31+
* dialog is cancelled, eg. "We can't restore your session and
32+
* the app cannot work".
33+
*
34+
* Default: `true`.
35+
*/
3136
"hasCancel"?: boolean;
3237

38+
/**
39+
* Callback that will be called when the 'close' button is clicked or 'Escape' is pressed.
40+
*
41+
* Not used if `hasCancel` is false.
42+
*/
43+
"onFinished"?: () => void;
44+
3345
// called when a key is pressed
3446
"onKeyDown"?: (e: KeyboardEvent | React.KeyboardEvent) => void;
3547

@@ -66,7 +78,6 @@ interface IProps {
6678

6779
// optional Posthog ScreenName to supply during the lifetime of this dialog
6880
"screenName"?: ScreenName;
69-
onFinished(): void;
7081
}
7182

7283
/*
@@ -103,13 +114,13 @@ export default class BaseDialog extends React.Component<IProps> {
103114

104115
e.stopPropagation();
105116
e.preventDefault();
106-
this.props.onFinished();
117+
this.props.onFinished?.();
107118
break;
108119
}
109120
};
110121

111122
private onCancelClick = (): void => {
112-
this.props.onFinished();
123+
this.props.onFinished?.();
113124
};
114125

115126
public render(): React.ReactNode {

src/components/views/dialogs/security/AccessSecretStorageDialog.tsx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import Field from "../../elements/Field";
1717
import { _t } from "../../../../languageHandler";
1818
import { EncryptionCard } from "../../settings/encryption/EncryptionCard";
1919
import { EncryptionCardButtons } from "../../settings/encryption/EncryptionCardButtons";
20+
import BaseDialog from "../BaseDialog";
2021

2122
// Don't shout at the user that their key is invalid every time they type a key: wait a short time
2223
const VALIDATION_THROTTLE_MS = 200;
@@ -205,15 +206,19 @@ export default class AccessSecretStorageDialog extends React.PureComponent<IProp
205206
</div>
206207
);
207208

209+
// We wrap the content in `BaseDialog` mostly so that we get a `FocusLock` container; otherwise, if the
210+
// SettingsDialog is open, then the `FocusLock` in *that* stops us getting the focus.
208211
return (
209-
<EncryptionCard
210-
Icon={LockSolidIcon}
211-
className="mx_AccessSecretStorageDialog"
212-
title={title}
213-
description={_t("encryption|access_secret_storage_dialog|privacy_warning")}
214-
>
215-
{content}
216-
</EncryptionCard>
212+
<BaseDialog fixedWidth={false} hasCancel={false}>
213+
<EncryptionCard
214+
Icon={LockSolidIcon}
215+
className="mx_AccessSecretStorageDialog"
216+
title={title}
217+
description={_t("encryption|access_secret_storage_dialog|privacy_warning")}
218+
>
219+
{content}
220+
</EncryptionCard>
221+
</BaseDialog>
217222
);
218223
}
219224
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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 } from "jest-matrix-react";
10+
import userEvent from "@testing-library/user-event";
11+
12+
import BaseDialog from "../../../../../src/components/views/dialogs/BaseDialog.tsx";
13+
14+
describe("BaseDialog", () => {
15+
it("calls onFinished when Escape is pressed", async () => {
16+
const onFinished = jest.fn();
17+
render(<BaseDialog onFinished={onFinished} />);
18+
await userEvent.keyboard("{Escape}");
19+
expect(onFinished).toHaveBeenCalled();
20+
});
21+
});

0 commit comments

Comments
 (0)