Skip to content
Closed
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { stubClient } from "test-utils";
import CompleteSecurity from "./CompleteSecurity";
import { Phase, SetupEncryptionStore } from "../../../stores/SetupEncryptionStore";
import SdkConfig from "../../../SdkConfig";
import { sleep } from "matrix-js-sdk/src/utils";
import { MatrixClientPeg } from "../../../MatrixClientPeg";

class MockSetupEncryptionStore extends EventEmitter {
public phase: Phase = Phase.Intro;
Expand Down Expand Up @@ -98,6 +100,26 @@ describe("CompleteSecurity", () => {
expect(panel.getByRole("button", { name: "Continue" })).toBeInTheDocument();
});

it("Shows an error if reset times out", async () => {
const client = MatrixClientPeg.safeGet();

// Given reset will freeze forever when we do it
client.getCrypto()!.resetEncryption = vi.fn().mockImplementation(() => sleep(20000));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

"A promise that never resolves" can also be spelt new Promise(() => {}), which might be better than having a dangling timeout?

const store = new SetupEncryptionStore();
vi.spyOn(SetupEncryptionStore, "sharedInstance").mockReturnValue(store);
const panel = await act(() => render(<CompleteSecurity onFinished={() => {}} resetTimeoutMs={1} />));

// When we hit reset, then continue
await act(async () => panel.getByRole("button", { name: "Can't confirm?" }).click());
await act(async () => panel.getByRole("button", { name: "Continue" }).click());

// And wait more than the timeout
await sleep(10);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

rather than sleeping in a test, and threading a timeout value through 10 layers of components, could we use fake timers?


// Then an error dialog appears
expect(screen.getByRole("heading", { name: "Identity reset failed" })).toBeInTheDocument();
});

it("Allows verifying with another device if one is available", async () => {
// Given a store and a dialog based on it
const store = new SetupEncryptionStore();
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/components/structures/auth/CompleteSecurity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import { E2EStatus } from "../../../utils/ShieldUtils.ts";

interface IProps {
onFinished: () => void;

// How long to wait for an identity reset before we assume it failed.
// Default: 5000ms.
resetTimeoutMs?: number;
}

interface IState {
Expand Down Expand Up @@ -109,7 +113,11 @@ export default class CompleteSecurity extends React.Component<IProps, IState> {
{skipButton}
</h1>
<div className="mx_CompleteSecurity_body">
<SetupEncryptionBody onFinished={this.props.onFinished} allowLogout={true} />
<SetupEncryptionBody
onFinished={this.props.onFinished}
allowLogout={true}
resetTimeoutMs={this.props.resetTimeoutMs}
/>
</div>
</CompleteSecurityBody>
</Glass>
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/components/structures/auth/SetupEncryptionBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
import dispatcher from "../../../dispatcher/dispatcher";
import E2EIcon from "../../views/rooms/E2EIcon.tsx";
import { E2EStatus } from "../../../utils/ShieldUtils.ts";
import ErrorDialog from "../../views/dialogs/ErrorDialog.tsx";
import SdkConfig from "../../../SdkConfig.ts";

interface IProps {
onFinished: () => void;
Expand All @@ -42,6 +44,11 @@
* Defaults to `false` if omitted.
*/
allowLogout?: boolean;

// How long to wait for an identity reset before we assume it failed.
//
// Defaults to 5000ms if omitted.
resetTimeoutMs?: number;
}

interface IState {
Expand Down Expand Up @@ -136,6 +143,22 @@
const store = SetupEncryptionStore.sharedInstance();
store.done();
},
resetTimeoutMs: this.props.resetTimeoutMs,
onFail: (failureReason) => {
logger.error(`Failed to reset identity: ${failureReason}`);

Modal.createDialog(ErrorDialog, {
title: _t("error_reset_failed"),
description: _t("error_reset_failed_description", undefined, {
issueLink: (label: string) => (

Check warning on line 153 in apps/web/src/components/structures/auth/SetupEncryptionBody.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Move this component definition out of the parent component and pass data as props.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AaAAdJr9ONzRfOHPUe2X&open=AaAAdJr9ONzRfOHPUe2X&pullRequest=34715
<a href={SdkConfig.get().feedback.new_issue_url} target="_blank" rel="noreferrer noopener">
{label}
</a>
),
}),
button: _t("action|ok"),
});
},
variant: store.lostKeys() ? "no_verification_method" : "confirm",
});
};
Expand Down
35 changes: 32 additions & 3 deletions apps/web/src/components/views/dialogs/ResetIdentityDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@
*/
onReset: () => void;

// How long to wait for an identity reset before we assume it failed.
//
// Defaults to 5000ms if omitted.
resetTimeoutMs?: number;

/**
* Called when the identity reset fails (before onFinished is called).
*/
onFail: (failureReason: string) => void;

/**
* Which variant of this dialog to show.
*/
Expand All @@ -33,17 +43,36 @@
/**
* The dialog for resetting the identity of the current user.
*/
export function ResetIdentityDialog({ onFinished, onReset, variant }: ResetIdentityDialogProps): JSX.Element {
export function ResetIdentityDialog({
onFinished,
onReset,
resetTimeoutMs,
onFail,
variant,
}: ResetIdentityDialogProps): JSX.Element {

Check warning on line 52 in apps/web/src/components/views/dialogs/ResetIdentityDialog.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AaAQcETfyyfysVycWdUp&open=AaAQcETfyyfysVycWdUp&pullRequest=34715
const matrixClient = MatrixClientPeg.safeGet();

const onResetWrapper: () => void = () => {
const onResetWrapper = (): void => {
onReset();
// Close the dialog
onFinished();
};

const onFailWrapper = (reason: string): void => {
onFail(reason);
// Close the dialog
onFinished();
};

return (
<MatrixClientContext.Provider value={matrixClient}>
<ResetIdentityBody onReset={onResetWrapper} onCancelClick={onFinished} variant={variant} />
<ResetIdentityBody
onReset={onResetWrapper}
resetTimeoutMs={resetTimeoutMs}
onFail={onFailWrapper}
onCancelClick={onFinished}
variant={variant}
/>
</MatrixClientContext.Provider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,24 @@
import { EncryptionCardButtons } from "./EncryptionCardButtons";
import { EncryptionCardEmphasisedContent } from "./EncryptionCardEmphasisedContent";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { timeout } from "../../../../utils/promise";

interface ResetIdentityBodyProps {
/**
* Called when the identity is reset.
*/
onReset: () => void;

// How long to wait for an identity reset before we assume it failed.
//
// Defaults to 5000ms if omitted.
resetTimeoutMs?: number;

/**
* Called when the identity reset fails.
*/
onFail: (failureReason: string) => void;

/**
* Called when the cancel button is clicked.
*/
Expand Down Expand Up @@ -60,13 +71,45 @@
*
* Used by {@link ResetIdentityPanel}.
*/
export function ResetIdentityBody({ onCancelClick, onReset, variant }: ResetIdentityBodyProps): JSX.Element {
export function ResetIdentityBody({
onCancelClick,
onReset,
resetTimeoutMs,
onFail,
variant,
}: ResetIdentityBodyProps): JSX.Element {

Check warning on line 80 in apps/web/src/components/views/settings/encryption/ResetIdentityBody.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AaAQcEFnyyfysVycWdUo&open=AaAQcEFnyyfysVycWdUo&pullRequest=34715
const matrixClient = useMatrixClientContext();

// After the user clicks "Continue", we disable the button so it can't be
// clicked again, and warn the user not to close the window.
const [inProgress, setInProgress] = useState(false);

async function onClick(): Promise<void> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

shouldn't this and doOnClick have a useCallback ?

setInProgress(true);

try {
const timedOut = "timed_out";
const result = await timeout(doOnClick(), timedOut, resetTimeoutMs ?? 5000);

if (result === timedOut) {
onFail("Timed out");
} else {
onReset();
}
} catch (e: any) {
onFail(e.toString());

Check warning on line 100 in apps/web/src/components/views/settings/encryption/ResetIdentityBody.tsx

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Line 100 is not covered by tests
}
}

async function doOnClick(): Promise<void> {
const crypto = matrixClient.getCrypto();
if (!crypto) {
throw new Error("Crypto is not set up");

Check warning on line 107 in apps/web/src/components/views/settings/encryption/ResetIdentityBody.tsx

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Line 107 is not covered by tests
}

await crypto.resetEncryption((makeRequest) => uiAuthCallback(matrixClient, makeRequest));
}

return (
<EncryptionCard Icon={ErrorIcon} destructive={true} title={titleForVariant(variant)}>
<EncryptionCardEmphasisedContent>
Expand All @@ -84,17 +127,7 @@
{variant === "compromised" && <span>{_t("settings|encryption|advanced|breadcrumb_warning")}</span>}
</EncryptionCardEmphasisedContent>
<EncryptionCardButtons>
<Button
destructive={true}
disabled={inProgress}
onClick={async () => {
setInProgress(true);
await matrixClient
.getCrypto()
?.resetEncryption((makeRequest) => uiAuthCallback(matrixClient, makeRequest));
onReset();
}}
>
<Button destructive={true} disabled={inProgress} onClick={onClick}>
{inProgress ? (
<>
<InlineSpinner /> {_t("settings|encryption|advanced|reset_in_progress")}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
*/
onReset: () => void;

/**
* Called if the identity reset fails.
*/
onFail: (failureReason: string) => void;

/**
* Called when the cancel button is clicked or when we go back in the breadcrumbs.
*/
Expand All @@ -33,7 +38,7 @@
*
* A thin wrapper around {@link ResetIdentityBody}, just adding breadcrumbs.
*/
export function ResetIdentityPanel({ onCancelClick, onReset, variant }: ResetIdentityPanelProps): JSX.Element {
export function ResetIdentityPanel({ onCancelClick, onReset, onFail, variant }: ResetIdentityPanelProps): JSX.Element {

Check warning on line 41 in apps/web/src/components/views/settings/encryption/ResetIdentityPanel.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AaAAm4gkkva3qXZoQK0-&open=AaAAm4gkkva3qXZoQK0-&pullRequest=34715
return (
<>
<Breadcrumb
Expand All @@ -42,7 +47,7 @@
pages={[_t("settings|encryption|title"), _t("settings|encryption|advanced|breadcrumb_page")]}
onPageClick={onCancelClick}
/>
<ResetIdentityBody onReset={onReset} onCancelClick={onCancelClick} variant={variant} />
<ResetIdentityBody onReset={onReset} onFail={onFail} onCancelClick={onCancelClick} variant={variant} />
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
import { DeleteKeyStoragePanel } from "../../encryption/DeleteKeyStoragePanel";
import { DeviceListener, CurrentDeviceEvents, type DeviceState } from "../../../../../device-listener";
import { useKeyStoragePanelViewModel } from "../../../../viewmodels/settings/encryption/KeyStoragePanelViewModel";
import ErrorDialog from "../../../dialogs/ErrorDialog";
import SdkConfig from "../../../../../SdkConfig";

/**
* The state in the encryption settings tab.
Expand Down Expand Up @@ -143,6 +145,23 @@
variant={findResetVariant(state)}
onCancelClick={() => setState("main")}
onReset={() => setState("main")}
onFail={() => {
Modal.createDialog(ErrorDialog, {

Check warning on line 149 in apps/web/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Lines 148-149 are not covered by tests
title: _t("error_reset_failed"),
description: _t("error_reset_failed_description", undefined, {
issueLink: (label: string) => (

Check warning on line 152 in apps/web/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Move this component definition out of the parent component and pass data as props.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AaAAm4g9kva3qXZoQK0_&open=AaAAm4g9kva3qXZoQK0_&pullRequest=34715
<a

Check warning on line 153 in apps/web/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Lines 152-153 are not covered by tests
href={SdkConfig.get().feedback.new_issue_url}
target="_blank"
rel="noreferrer noopener"
>
{label}
</a>
),
}),
button: _t("action|ok"),
});
}}
/>
);
break;
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1147,6 +1147,8 @@
"error_loading_user_profile": "Could not load user profile",
"forget_room_failed": "Failed to forget room %(errCode)s"
},
"error_reset_failed": "Identity reset failed",
"error_reset_failed_description": "Something went wrong while resetting your digital identity. Please try again and <issueLink>submit a bug report</issueLink> if the problem persists.",
"error_user_not_logged_in": "User is not logged in",
"event_preview": {
"m.call.answer": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ describe("ResetIdentityDialog", () => {

const onFinished = jest.fn();
const onReset = jest.fn();
const dialog = render(<ResetIdentityDialog onFinished={onFinished} onReset={onReset} variant="compromised" />);
const onFail = jest.fn();
const dialog = render(
<ResetIdentityDialog onFinished={onFinished} onReset={onReset} onFail={onFail} variant="compromised" />,
);

await act(async () => dialog.getByRole("button", { name: "Continue" }).click());

Expand All @@ -41,7 +44,10 @@ describe("ResetIdentityDialog", () => {

const onFinished = jest.fn();
const onReset = jest.fn();
const dialog = render(<ResetIdentityDialog onFinished={onFinished} onReset={onReset} variant="compromised" />);
const onFail = jest.fn();
const dialog = render(
<ResetIdentityDialog onFinished={onFinished} onReset={onReset} onFail={onFail} variant="compromised" />,
);

await act(async () => dialog.getByRole("button", { name: "Cancel" }).click());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ describe("<ResetIdentityPanel />", () => {
const user = userEvent.setup();

const onReset = jest.fn();
const onFail = jest.fn();
const { asFragment } = render(
<ResetIdentityPanel variant="compromised" onReset={onReset} onCancelClick={jest.fn()} />,
<ResetIdentityPanel variant="compromised" onReset={onReset} onFail={onFail} onCancelClick={jest.fn()} />,
withClientContextRenderOptions(matrixClient),
);
expect(asFragment()).toMatchSnapshot();
Expand All @@ -48,17 +49,19 @@ describe("<ResetIdentityPanel />", () => {

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

it("should display the 'sync failed' variant correctly", async () => {
const onReset = jest.fn();
const onFail = jest.fn();
const { asFragment } = render(
<ResetIdentityPanel variant="sync_failed" onReset={onReset} onCancelClick={jest.fn()} />,
<ResetIdentityPanel variant="sync_failed" onReset={onReset} onFail={onFail} onCancelClick={jest.fn()} />,
withClientContextRenderOptions(matrixClient),
);
expect(asFragment()).toMatchSnapshot();
Expand Down
Loading