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

[Backport staging] Wire up the "Forgot recovery key" button for the "Key storage out of sync" toast #29190

Open
wants to merge 1 commit into
base: staging
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
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
Loading