Skip to content

Commit

Permalink
Wire up the "Forgot recovery key" button for the "Key storage out of …
Browse files Browse the repository at this point in the history
…sync" toast (#29138)

* Wire up the "Forgot recovery key" button for the "Key storage out of sync" toast

* Unused import & fix test

* Test 'forgot' variant

* Fix dependencies

* Add more toast tests

* Unused import

* Test initialState in Encryption Tab

* Let's see if github has any more luck running this test than me

* Working playwright test with screenshot

* year

* Convert playwright test to use the bot client

* Disambiguate

Co-authored-by: Florian Duros <florianduros@element.io>

* Add doc & do other part of rename

* Split out into custom hook

* Fix tests

---------

Co-authored-by: Florian Duros <florianduros@element.io>
(cherry picked from commit 9657d39)
  • Loading branch information
dbkr authored and github-actions[bot] committed Feb 4, 2025
1 parent 0a8393c commit 159acef
Show file tree
Hide file tree
Showing 13 changed files with 389 additions and 35 deletions.
54 changes: 54 additions & 0 deletions playwright/e2e/crypto/toasts.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* 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 { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api";

import { test, expect } from "../../element-web-test";
import { createBot, deleteCachedSecrets, logIntoElement } from "./utils";

test.describe("Key storage out of sync toast", () => {
let recoveryKey: GeneratedSecretStorageKey;

test.beforeEach(async ({ page, homeserver, credentials }) => {
const res = await createBot(page, homeserver, credentials);
recoveryKey = res.recoveryKey;

await logIntoElement(page, credentials, recoveryKey.encodedPrivateKey);

await deleteCachedSecrets(page);

// We won't be prompted for crypto setup unless we have an e2e room, so make one
await page.getByRole("button", { name: "Add room" }).click();
await page.getByRole("menuitem", { name: "New room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("Test room");
await page.getByRole("button", { name: "Create room" }).click();
});

test("should prompt for recovery key if 'enter recovery key' pressed", { tag: "@screenshot" }, async ({ page }) => {
// Need to wait for 2 to appear since playwright only evaluates 'first()' initially, so the waiting won't work
await expect(page.getByRole("alert")).toHaveCount(2);
await expect(page.getByRole("alert").first()).toMatchScreenshot("key-storage-out-of-sync-toast.png");

await page.getByRole("button", { name: "Enter recovery key" }).click();
await page.locator(".mx_Dialog").getByRole("button", { name: "use your Security Key" }).click();

await page.getByRole("textbox", { name: "Security key" }).fill(recoveryKey.encodedPrivateKey);
await page.getByRole("button", { name: "Continue" }).click();

await expect(page.getByRole("button", { name: "Enter recovery key" })).not.toBeVisible();
});

test("should open settings to reset flow if 'forgot recovery key' pressed", async ({ page, app, credentials }) => {
await expect(page.getByRole("button", { name: "Enter recovery key" })).toBeVisible();

await page.getByRole("button", { name: "Forgot recovery key?" }).click();

await expect(
page.getByRole("heading", { name: "Forgot your recovery key? You’ll need to reset your identity." }),
).toBeVisible();
});
});
5 changes: 5 additions & 0 deletions playwright/e2e/crypto/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,11 @@ export async function logIntoElement(page: Page, credentials: Credentials, secur
// if a securityKey was given, verify the new device
if (securityKey !== undefined) {
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Security Key" }).click();

const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "use your Security Key" });
if (await useSecurityKey.isVisible()) {
await useSecurityKey.click();
}
// Fill in the security key
await page.locator(".mx_Dialog").locator('input[type="password"]').fill(securityKey);
await page.locator(".mx_Dialog_primary:not([disabled])", { hasText: "Continue" }).click();
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 11 additions & 3 deletions src/components/views/dialogs/UserSettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { EncryptionUserSettingsTab } from "../settings/tabs/user/EncryptionUserS
interface IProps {
initialTabId?: UserTab;
showMsc4108QrCode?: boolean;
showResetIdentity?: boolean;
sdkContext: SdkContextClass;
onFinished(): void;
}
Expand Down Expand Up @@ -91,8 +92,9 @@ function titleForTabID(tabId: UserTab): React.ReactNode {
export default function UserSettingsDialog(props: IProps): JSX.Element {
const voipEnabled = useSettingValue(UIFeature.Voip);
const mjolnirEnabled = useSettingValue("feature_mjolnir");
// store this prop in state as changing tabs back and forth should clear it
// store these props in state as changing tabs back and forth should clear it
const [showMsc4108QrCode, setShowMsc4108QrCode] = useState(props.showMsc4108QrCode);
const [showResetIdentity, setShowResetIdentity] = useState(props.showResetIdentity);

const getTabs = (): NonEmptyArray<Tab<UserTab>> => {
const tabs: Tab<UserTab>[] = [];
Expand Down Expand Up @@ -184,7 +186,12 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
);

tabs.push(
new Tab(UserTab.Encryption, _td("settings|encryption|title"), <KeyIcon />, <EncryptionUserSettingsTab />),
new Tab(
UserTab.Encryption,
_td("settings|encryption|title"),
<KeyIcon />,
<EncryptionUserSettingsTab initialState={showResetIdentity ? "reset_identity_forgot" : undefined} />,
),
);

if (showLabsFlags() || SettingsStore.getFeatureSettingNames().some((k) => SettingsStore.getBetaInfo(k))) {
Expand Down Expand Up @@ -219,8 +226,9 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
const [activeTabId, _setActiveTabId] = useActiveTabWithDefault(getTabs(), UserTab.Account, props.initialTabId);
const setActiveTabId = (tabId: UserTab): void => {
_setActiveTabId(tabId);
// Clear this so switching away from the tab and back to it will not show the QR code again
// Clear these so switching away from the tab and back to it will not show the QR code again
setShowMsc4108QrCode(false);
setShowResetIdentity(false);
};

const [activeToast, toastRack] = useActiveToast();
Expand Down
19 changes: 16 additions & 3 deletions src/components/views/settings/encryption/ResetIdentityPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,21 @@ interface ResetIdentityPanelProps {
* Called when the cancel button is clicked or when we go back in the breadcrumbs.
*/
onCancelClick: () => void;

/**
* The variant of the panel to show. We show more warnings in the 'compromised' variant (no use in showing a user this
* warning if they have to reset because they no longer have their key)
* "compromised" is shown when the user chooses 'reset' explicitly in settings, usually because they believe their
* identity has been compromised.
* "forgot" is shown when the user has just forgotten their passphrase.
*/
variant: "compromised" | "forgot";
}

/**
* The panel for resetting the identity of the current user.
*/
export function ResetIdentityPanel({ onCancelClick, onFinish }: ResetIdentityPanelProps): JSX.Element {
export function ResetIdentityPanel({ onCancelClick, onFinish, variant }: ResetIdentityPanelProps): JSX.Element {
const matrixClient = useMatrixClientContext();

return (
Expand All @@ -44,7 +53,11 @@ export function ResetIdentityPanel({ onCancelClick, onFinish }: ResetIdentityPan
<EncryptionCard
Icon={ErrorIcon}
destructive={true}
title={_t("settings|encryption|advanced|breadcrumb_title")}
title={
variant === "forgot"
? _t("settings|encryption|advanced|breadcrumb_title_forgot")
: _t("settings|encryption|advanced|breadcrumb_title")
}
className="mx_ResetIdentityPanel"
>
<div className="mx_ResetIdentityPanel_content">
Expand All @@ -59,7 +72,7 @@ export function ResetIdentityPanel({ onCancelClick, onFinish }: ResetIdentityPan
{_t("settings|encryption|advanced|breadcrumb_third_description")}
</VisualListItem>
</VisualList>
<span>{_t("settings|encryption|advanced|breadcrumb_warning")}</span>
{variant === "compromised" && <span>{_t("settings|encryption|advanced|breadcrumb_warning")}</span>}
</div>
<div className="mx_ResetIdentityPanel_footer">
<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,23 +32,35 @@ import { RecoveryPanelOutOfSync } from "../../encryption/RecoveryPanelOutOfSync"
* This happens when the user has a recovery key and the user clicks on "Change recovery key" button of the RecoveryPanel.
* - "set_recovery_key": The panel to show when the user is setting up their recovery key.
* This happens when the user doesn't have a key a recovery key and the user clicks on "Set up recovery key" button of the RecoveryPanel.
* - "reset_identity": The panel to show when the user is resetting their identity.
* - `secrets_not_cached`: The secrets are not cached locally. This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
* - "reset_identity_compromised": The panel to show when the user is resetting their identity, in te case where their key is compromised.
* - "reset_identity_forgot": The panel to show when the user is resetting their identity, in the case where they forgot their recovery key.
* - `secrets_not_cached`: The secrets are not cached locally. This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
* If the "set_up_encryption" and "secrets_not_cached" conditions are both filled, "set_up_encryption" prevails.
*
*/
type State =
export type State =
| "loading"
| "main"
| "set_up_encryption"
| "change_recovery_key"
| "set_recovery_key"
| "reset_identity"
| "reset_identity_compromised"
| "reset_identity_forgot"
| "secrets_not_cached";

export function EncryptionUserSettingsTab(): JSX.Element {
const [state, setState] = useState<State>("loading");
const checkEncryptionState = useCheckEncryptionState(setState);
interface EncryptionUserSettingsTabProps {
/**
* If the tab should start in a state other than the deasult
*/
initialState?: State;
}

/**
* The encryption settings tab.
*/
export function EncryptionUserSettingsTab({ initialState = "loading" }: EncryptionUserSettingsTabProps): JSX.Element {
const [state, setState] = useState<State>(initialState);

const checkEncryptionState = useCheckEncryptionState(state, setState);

let content: JSX.Element;
switch (state) {
Expand All @@ -70,7 +82,7 @@ export function EncryptionUserSettingsTab(): JSX.Element {
}
/>
<Separator kind="section" />
<AdvancedPanel onResetIdentityClick={() => setState("reset_identity")} />
<AdvancedPanel onResetIdentityClick={() => setState("reset_identity_compromised")} />
</>
);
break;
Expand All @@ -84,8 +96,23 @@ export function EncryptionUserSettingsTab(): JSX.Element {
/>
);
break;
case "reset_identity":
content = <ResetIdentityPanel onCancelClick={() => setState("main")} onFinish={() => setState("main")} />;
case "reset_identity_compromised":
content = (
<ResetIdentityPanel
variant="compromised"
onCancelClick={() => setState("main")}
onFinish={() => setState("main")}
/>
);
break;
case "reset_identity_forgot":
content = (
<ResetIdentityPanel
variant="forgot"
onCancelClick={() => setState("main")}
onFinish={() => setState("main")}
/>
);
break;
}

Expand All @@ -111,7 +138,7 @@ export function EncryptionUserSettingsTab(): JSX.Element {
* @param setState - callback passed from the EncryptionUserSettingsTab to set the current `State`.
* @returns a callback function, which will re-run the logic and update the state.
*/
function useCheckEncryptionState(setState: (state: State) => void): () => Promise<void> {
function useCheckEncryptionState(state: State, setState: (state: State) => void): () => Promise<void> {
const matrixClient = useMatrixClientContext();

const checkEncryptionState = useCallback(async () => {
Expand All @@ -129,8 +156,8 @@ function useCheckEncryptionState(setState: (state: State) => void): () => Promis

// Initialise the state when the component is mounted
useEffect(() => {
checkEncryptionState();
}, [checkEncryptionState]);
if (state === "loading") checkEncryptionState();
}, [checkEncryptionState, state]);

// Also return the callback so that the component can re-run the logic.
return checkEncryptionState;
Expand Down
1 change: 1 addition & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2469,6 +2469,7 @@
"breadcrumb_second_description": "You will lose any message history that’s stored only on the server",
"breadcrumb_third_description": "You will need to verify all your existing devices and contacts again",
"breadcrumb_title": "Are you sure you want to reset your identity?",
"breadcrumb_title_forgot": "Forgot your recovery key? You’ll need to reset your identity.",
"breadcrumb_warning": "Only do this if you believe your account has been compromised.",
"details_title": "Encryption details",
"export_keys": "Export keys",
Expand Down
27 changes: 20 additions & 7 deletions src/toasts/SetupEncryptionToast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import GenericToast from "../components/views/toasts/GenericToast";
import { ModuleRunner } from "../modules/ModuleRunner";
import { SetupEncryptionStore } from "../stores/SetupEncryptionStore";
import Spinner from "../components/views/elements/Spinner";
import { OpenToTabPayload } from "../dispatcher/payloads/OpenToTabPayload";
import { Action } from "../dispatcher/actions";
import { UserTab } from "../components/views/dialogs/UserTab";
import defaultDispatcher from "../dispatcher/dispatcher";

const TOAST_KEY = "setupencryption";

Expand Down Expand Up @@ -104,10 +108,6 @@ export enum Kind {
KEY_STORAGE_OUT_OF_SYNC = "key_storage_out_of_sync",
}

const onReject = (): void => {
DeviceListener.sharedInstance().dismissEncryptionSetup();
};

/**
* Show a toast prompting the user for some action related to setting up their encryption.
*
Expand All @@ -123,7 +123,7 @@ export const showToast = (kind: Kind): void => {
return;
}

const onAccept = async (): Promise<void> => {
const onPrimaryClick = async (): Promise<void> => {
if (kind === Kind.VERIFY_THIS_SESSION) {
Modal.createDialog(SetupEncryptionDialog, {}, undefined, /* priority = */ false, /* static = */ true);
} else {
Expand All @@ -142,16 +142,29 @@ export const showToast = (kind: Kind): void => {
}
};

const onSecondaryClick = (): void => {
if (kind === Kind.KEY_STORAGE_OUT_OF_SYNC) {
const payload: OpenToTabPayload = {
action: Action.ViewUserSettings,
initialTabId: UserTab.Encryption,
props: { showResetIdentity: true },
};
defaultDispatcher.dispatch(payload);
} else {
DeviceListener.sharedInstance().dismissEncryptionSetup();
}
};

ToastStore.sharedInstance().addOrReplaceToast({
key: TOAST_KEY,
title: getTitle(kind),
icon: getIcon(kind),
props: {
description: getDescription(kind),
primaryLabel: getSetupCaption(kind),
onPrimaryClick: onAccept,
onPrimaryClick,
secondaryLabel: getSecondaryButtonLabel(kind),
onSecondaryClick: onReject,
onSecondaryClick,
overrideWidth: kind === Kind.KEY_STORAGE_OUT_OF_SYNC ? "366px" : undefined,
},
component: GenericToast,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ describe("<UserSettingsDialog />", () => {

let sdkContext: SdkContextClass;
const defaultProps = { onFinished: jest.fn() };
const getComponent = (props: Partial<typeof defaultProps & { initialTabId?: UserTab }> = {}): ReactElement => (
<UserSettingsDialog sdkContext={sdkContext} {...defaultProps} {...props} />
);
const getComponent = (
props: Partial<typeof defaultProps & { initialTabId?: UserTab; props: Record<string, any> }> = {},
): ReactElement => <UserSettingsDialog sdkContext={sdkContext} {...defaultProps} {...props} />;

beforeEach(() => {
jest.clearAllMocks();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe("<ResetIdentityPanel />", () => {

const onFinish = jest.fn();
const { asFragment } = render(
<ResetIdentityPanel onFinish={onFinish} onCancelClick={jest.fn()} />,
<ResetIdentityPanel variant="compromised" onFinish={onFinish} onCancelClick={jest.fn()} />,
withClientContextRenderOptions(matrixClient),
);
expect(asFragment()).toMatchSnapshot();
Expand All @@ -34,4 +34,13 @@ describe("<ResetIdentityPanel />", () => {
expect(matrixClient.getCrypto()!.resetEncryption).toHaveBeenCalled();
expect(onFinish).toHaveBeenCalled();
});

it("should display the 'forgot recovery key' variant correctly", async () => {
const onFinish = jest.fn();
const { asFragment } = render(
<ResetIdentityPanel variant="forgot" onFinish={onFinish} onCancelClick={jest.fn()} />,
withClientContextRenderOptions(matrixClient),
);
expect(asFragment()).toMatchSnapshot();
});
});
Loading

0 comments on commit 159acef

Please sign in to comment.