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

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

Merged
merged 16 commits into from
Feb 4, 2025
Merged
69 changes: 69 additions & 0 deletions playwright/e2e/crypto/toasts.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* 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 { test, expect } from "../../element-web-test";
import { isDendrite } from "../../plugins/homeserver/dendrite";
import { masHomeserver } from "../../plugins/homeserver/synapse/masHomeserver";
import { registerAccountMas } from "../oidc";
import { deleteCachedSecrets } from "./utils";

test.use(masHomeserver);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test needs mas?

test.describe("Key storage out of sync toast", () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(don't we already have some tests for this toast somewhere else?)

test.skip(isDendrite, "does not yet support MAS");

let recoveryKey;

test.beforeEach(async ({ page, app, mailpitClient }) => {
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await registerAccountMas(page, mailpitClient, "alice", "[email protected]", "Pa$sW0rD!");

await expect(page.getByRole("heading", { level: 1, name: "Welcome alice" })).toBeVisible();

// We won't be prompted for crypto setup unless we have na e2e room, so make one
dbkr marked this conversation as resolved.
Show resolved Hide resolved
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();

// Now set up recovery (otherwise we'll delete the only copy of the secret)
await page.getByLabel("User menu").click();
await page.getByLabel("All settings").click();
await page.getByRole("tab", { name: "Encryption" }).click();
await page.getByRole("button", { name: "Set up recovery" }).click();
await page.getByRole("button", { name: "Continue" }).click();
recoveryKey = await page.getByTestId("recoveryKey").textContent();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("textbox", { name: "Enter recovery key" }).fill(recoveryKey);
await page.getByRole("button", { name: "Finish set up" }).click();

await expect(page.getByRole("button", { name: "Change recovery key" })).toBeVisible();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we use crypto/utils#createBot to create a second session, bootstrap cross singing and create a recovery key?

crypto/utils#verifySession can be used to verify the current session too

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It took a bit but I think this is working with the bot client now.


await deleteCachedSecrets(page);

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

test("should prompt for recovery key if 'enter recovery key' pressed", { tag: "@screenshot" }, async ({ page }) => {
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.getByRole("textbox", { name: "Security key" }).fill(recoveryKey);
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 }) => {
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();
});
});
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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

documentation please. What does this do? Is false the same as undefined?

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/it/them/

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how is setShowResetIdentity related to the QR code?

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.
*/
Comment on lines +29 to +35
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Formatting is weird here. Newlines don't do anything in tsdoc; either commit to new paragraphs with a blank line, or match the generated output by removing the linebreaks (presumably the former in this case). You can't just sit on the fence.

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 @@ -33,19 +33,49 @@ import { ResetIdentityPanel } from "../../encryption/ResetIdentityPanel";
* 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.
*/
type State = "loading" | "main" | "set_up_encryption" | "change_recovery_key" | "set_recovery_key" | "reset_identity";
export type State =
| "loading"
| "main"
| "set_up_encryption"
| "change_recovery_key"
| "set_recovery_key"
| "reset_identity_compromised"
| "reset_identity_forgot";
dbkr marked this conversation as resolved.
Show resolved Hide resolved

export function EncryptionUserSettingsTab(): JSX.Element {
const [state, setState] = useState<State>("loading");
const setUpEncryptionRequired = useSetUpEncryptionRequired(setState);
interface Props {
dbkr marked this conversation as resolved.
Show resolved Hide resolved
/**
* If the tab should start in a state other than the deasult
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

desault

*/
initialState?: State;
}

export function EncryptionUserSettingsTab({ initialState = "loading" }: Props): JSX.Element {
const [state, setState] = useState<State>(initialState);
const matrixClient = useMatrixClientContext();

const recheckSetupRequired = useCallback(() => {
(async () => {
const crypto = matrixClient.getCrypto()!;
const isCrossSigningReady = await crypto.isCrossSigningReady();
if (isCrossSigningReady) {
setState("main");
} else {
setState("set_up_encryption");
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (isCrossSigningReady) {
setState("main");
} else {
setState("set_up_encryption");
}
if (isCrossSigningReady) setState("main");
else setState("set_up_encryption");

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd really rather not if at all possible: I find these line-ifs split over multiple lines very confusing, and I think our code style permits line ifs only if they're one single line.

})();
}, [matrixClient]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm in favour to use a custom hook like useSetUpEncryptionRequired before. It helps the readability of the component imo

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something like this? I find the custom hook a bit awkward in this scenario since you have to pass a lot of functions around, but I don't mind too much.


useEffect(() => {
if (state === "loading") recheckSetupRequired();
}, [recheckSetupRequired, state]);

let content: JSX.Element;
switch (state) {
case "loading":
content = <InlineSpinner aria-label={_t("common|loading")} />;
break;
case "set_up_encryption":
content = <SetUpEncryptionPanel onFinish={setUpEncryptionRequired} />;
content = <SetUpEncryptionPanel onFinish={recheckSetupRequired} />;
break;
case "main":
content = (
Expand All @@ -56,7 +86,7 @@ export function EncryptionUserSettingsTab(): JSX.Element {
}
/>
<Separator kind="section" />
<AdvancedPanel onResetIdentityClick={() => setState("reset_identity")} />
<AdvancedPanel onResetIdentityClick={() => setState("reset_identity_compromised")} />
</>
);
break;
Expand All @@ -70,8 +100,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 @@ -82,36 +127,6 @@ export function EncryptionUserSettingsTab(): JSX.Element {
);
}

/**
* Hook to check if the user needs to go through the SetupEncryption flow.
* If the user needs to set up the encryption, the state will be set to "set_up_encryption".
* Otherwise, the state will be set to "main".
*
* The state is set once when the component is first mounted.
* Also returns a callback function which can be called to re-run the logic.
*
* @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 useSetUpEncryptionRequired(setState: (state: State) => void): () => Promise<void> {
const matrixClient = useMatrixClientContext();

const setUpEncryptionRequired = useCallback(async () => {
const crypto = matrixClient.getCrypto()!;
const isCrossSigningReady = await crypto.isCrossSigningReady();
if (isCrossSigningReady) setState("main");
else setState("set_up_encryption");
}, [matrixClient, setState]);

// Initialise the state when the component is mounted
useEffect(() => {
setUpEncryptionRequired();
}, [setUpEncryptionRequired]);

// Also return the callback so that the component can re-run the logic.
return setUpEncryptionRequired;
}

interface SetUpEncryptionPanelProps {
/**
* Callback to call when the user has finished setting up encryption.
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 @@ -2466,6 +2466,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);
Comment on lines +147 to +152
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a comment in here might not go amiss. What does this payload actually do?

} 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