From 7eb16b3361c7539ed68868423353176fafff8c6f Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Fri, 6 Jun 2025 12:21:29 +0100 Subject: [PATCH] AccessSecretStorageDialog: fix inability to enter recovery key (#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 --- .../e2e/crypto/device-verification.spec.ts | 51 +++++++++++-------- src/components/views/dialogs/BaseDialog.tsx | 27 +++++++--- .../security/AccessSecretStorageDialog.tsx | 21 +++++--- .../views/dialogs/BaseDialog-test.tsx | 21 ++++++++ 4 files changed, 82 insertions(+), 38 deletions(-) create mode 100644 test/unit-tests/components/views/dialogs/BaseDialog-test.tsx diff --git a/playwright/e2e/crypto/device-verification.spec.ts b/playwright/e2e/crypto/device-verification.spec.ts index 64846ac86d..5ae53f649e 100644 --- a/playwright/e2e/crypto/device-verification.spec.ts +++ b/playwright/e2e/crypto/device-verification.spec.ts @@ -22,6 +22,7 @@ import { } from "./utils"; import { type Bot } from "../../pages/bot"; import { Toasts } from "../../pages/toasts.ts"; +import type { ElementAppPage } from "../../pages/ElementAppPage.ts"; test.describe("Device verification", { tag: "@no-webkit" }, () => { let aliceBotClient: Bot; @@ -163,38 +164,44 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { test("Verify device with Security Phrase during login", async ({ page, app, credentials, homeserver }) => { await logIntoElement(page, credentials); + await enterRecoveryKeyAndCheckVerified(page, app, "new passphrase"); + }); - // Select the security phrase - await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click(); + test("Verify device with Recovery Key during login", async ({ page, app, credentials, homeserver }) => { + const recoveryKey = (await aliceBotClient.getRecoveryKey()).encodedPrivateKey; - // Fill the passphrase - const dialog = page.locator(".mx_Dialog"); - await dialog.locator("textarea").fill("new passphrase"); - await dialog.getByRole("button", { name: "Continue", disabled: false }).click(); + await logIntoElement(page, credentials); + await enterRecoveryKeyAndCheckVerified(page, app, recoveryKey); + }); - await page.locator(".mx_AuthPage").getByRole("button", { name: "Done" }).click(); + test("Verify device with Recovery Key from settings", async ({ page, app, credentials }) => { + const recoveryKey = (await aliceBotClient.getRecoveryKey()).encodedPrivateKey; - // Check that our device is now cross-signed - await checkDeviceIsCrossSigned(app); + await logIntoElement(page, credentials); - // Check that the current device is connected to key backup - // The backup decryption key should be in cache also, as we got it directly from the 4S - await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true); - }); + /* Dismiss "Verify this device" */ + const authPage = page.locator(".mx_AuthPage"); + await authPage.getByRole("button", { name: "Skip verification for now" }).click(); + await authPage.getByRole("button", { name: "I'll verify later" }).click(); + await page.waitForSelector(".mx_MatrixChat"); - test("Verify device with Recovery Key during login", async ({ page, app, credentials, homeserver }) => { - await logIntoElement(page, credentials); + const settings = await app.settings.openUserSettings("Encryption"); + await settings.getByRole("button", { name: "Verify this device" }).click(); + await enterRecoveryKeyAndCheckVerified(page, app, recoveryKey); + }); - // Select the security phrase - await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click(); + /** Helper for the three tests above which verify by recovery key */ + async function enterRecoveryKeyAndCheckVerified(page: Page, app: ElementAppPage, recoveryKey: string) { + await page.getByRole("button", { name: "Verify with Recovery Key or Phrase" }).click(); - // Fill the recovery key + // Enter the recovery key const dialog = page.locator(".mx_Dialog"); - const aliceRecoveryKey = await aliceBotClient.getRecoveryKey(); - await dialog.locator("textarea").fill(aliceRecoveryKey.encodedPrivateKey); + // We use `pressSequentially` here to make sure that the FocusLock isn't causing us any problems + // (cf https://github.com/element-hq/element-web/issues/30089) + await dialog.locator("textarea").pressSequentially(recoveryKey); await dialog.getByRole("button", { name: "Continue", disabled: false }).click(); - await page.locator(".mx_AuthPage").getByRole("button", { name: "Done" }).click(); + await page.getByRole("button", { name: "Done" }).click(); // Check that our device is now cross-signed await checkDeviceIsCrossSigned(app); @@ -202,7 +209,7 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => { // Check that the current device is connected to key backup // The backup decryption key should be in cache also, as we got it directly from the 4S await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, true); - }); + } test("Handle incoming verification request with SAS", async ({ page, credentials, homeserver, toasts }) => { await logIntoElement(page, credentials); diff --git a/src/components/views/dialogs/BaseDialog.tsx b/src/components/views/dialogs/BaseDialog.tsx index bf23919771..56f6d0c528 100644 --- a/src/components/views/dialogs/BaseDialog.tsx +++ b/src/components/views/dialogs/BaseDialog.tsx @@ -23,13 +23,25 @@ import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; interface IProps { - // Whether the dialog should have a 'close' button that will - // cause the dialog to be cancelled. This should only be set - // to false if there is nothing the app can sensibly do if the - // dialog is cancelled, eg. "We can't restore your session and - // the app cannot work". Default: true. + /** + * Whether the dialog should have a 'close' button and a keyDown handler which + * will intercept 'Escape'. + * + * This should only be set to `false` if there is nothing the app can sensibly do if the + * dialog is cancelled, eg. "We can't restore your session and + * the app cannot work". + * + * Default: `true`. + */ "hasCancel"?: boolean; + /** + * Callback that will be called when the 'close' button is clicked or 'Escape' is pressed. + * + * Not used if `hasCancel` is false. + */ + "onFinished"?: () => void; + // called when a key is pressed "onKeyDown"?: (e: KeyboardEvent | React.KeyboardEvent) => void; @@ -66,7 +78,6 @@ interface IProps { // optional Posthog ScreenName to supply during the lifetime of this dialog "screenName"?: ScreenName; - onFinished(): void; } /* @@ -103,13 +114,13 @@ export default class BaseDialog extends React.Component<IProps> { e.stopPropagation(); e.preventDefault(); - this.props.onFinished(); + this.props.onFinished?.(); break; } }; private onCancelClick = (): void => { - this.props.onFinished(); + this.props.onFinished?.(); }; public render(): React.ReactNode { diff --git a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx index 5b1fb3e142..b44e456404 100644 --- a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx @@ -17,6 +17,7 @@ import Field from "../../elements/Field"; import { _t } from "../../../../languageHandler"; import { EncryptionCard } from "../../settings/encryption/EncryptionCard"; import { EncryptionCardButtons } from "../../settings/encryption/EncryptionCardButtons"; +import BaseDialog from "../BaseDialog"; // Don't shout at the user that their key is invalid every time they type a key: wait a short time const VALIDATION_THROTTLE_MS = 200; @@ -205,15 +206,19 @@ export default class AccessSecretStorageDialog extends React.PureComponent<IProp </div> ); + // We wrap the content in `BaseDialog` mostly so that we get a `FocusLock` container; otherwise, if the + // SettingsDialog is open, then the `FocusLock` in *that* stops us getting the focus. return ( - <EncryptionCard - Icon={LockSolidIcon} - className="mx_AccessSecretStorageDialog" - title={title} - description={_t("encryption|access_secret_storage_dialog|privacy_warning")} - > - {content} - </EncryptionCard> + <BaseDialog fixedWidth={false} hasCancel={false}> + <EncryptionCard + Icon={LockSolidIcon} + className="mx_AccessSecretStorageDialog" + title={title} + description={_t("encryption|access_secret_storage_dialog|privacy_warning")} + > + {content} + </EncryptionCard> + </BaseDialog> ); } } diff --git a/test/unit-tests/components/views/dialogs/BaseDialog-test.tsx b/test/unit-tests/components/views/dialogs/BaseDialog-test.tsx new file mode 100644 index 0000000000..e8737fe88d --- /dev/null +++ b/test/unit-tests/components/views/dialogs/BaseDialog-test.tsx @@ -0,0 +1,21 @@ +/* +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 } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; + +import BaseDialog from "../../../../../src/components/views/dialogs/BaseDialog.tsx"; + +describe("BaseDialog", () => { + it("calls onFinished when Escape is pressed", async () => { + const onFinished = jest.fn(); + render(<BaseDialog onFinished={onFinished} />); + await userEvent.keyboard("{Escape}"); + expect(onFinished).toHaveBeenCalled(); + }); +}); -- GitLab