diff --git a/apps/web/src/components/structures/auth/CompleteSecurity.test.tsx b/apps/web/src/components/structures/auth/CompleteSecurity.test.tsx index 156c4015bd6..bee74277260 100644 --- a/apps/web/src/components/structures/auth/CompleteSecurity.test.tsx +++ b/apps/web/src/components/structures/auth/CompleteSecurity.test.tsx @@ -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; @@ -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)); + const store = new SetupEncryptionStore(); + vi.spyOn(SetupEncryptionStore, "sharedInstance").mockReturnValue(store); + const panel = await act(() => render( {}} 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); + + // 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(); diff --git a/apps/web/src/components/structures/auth/CompleteSecurity.tsx b/apps/web/src/components/structures/auth/CompleteSecurity.tsx index 27676bb5615..fd55dd998ab 100644 --- a/apps/web/src/components/structures/auth/CompleteSecurity.tsx +++ b/apps/web/src/components/structures/auth/CompleteSecurity.tsx @@ -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 { @@ -109,7 +113,11 @@ export default class CompleteSecurity extends React.Component { {skipButton}
- +
diff --git a/apps/web/src/components/structures/auth/SetupEncryptionBody.tsx b/apps/web/src/components/structures/auth/SetupEncryptionBody.tsx index 96b6fac1ba2..35ecc019382 100644 --- a/apps/web/src/components/structures/auth/SetupEncryptionBody.tsx +++ b/apps/web/src/components/structures/auth/SetupEncryptionBody.tsx @@ -29,6 +29,8 @@ import ExternalLink from "../../views/elements/ExternalLink"; 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; @@ -42,6 +44,11 @@ interface IProps { * 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 { @@ -136,6 +143,22 @@ export default class SetupEncryptionBody extends React.Component 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) => ( + + {label} + + ), + }), + button: _t("action|ok"), + }); + }, variant: store.lostKeys() ? "no_verification_method" : "confirm", }); }; diff --git a/apps/web/src/components/views/dialogs/ResetIdentityDialog.tsx b/apps/web/src/components/views/dialogs/ResetIdentityDialog.tsx index b946ab1d79a..fd672c49a07 100644 --- a/apps/web/src/components/views/dialogs/ResetIdentityDialog.tsx +++ b/apps/web/src/components/views/dialogs/ResetIdentityDialog.tsx @@ -24,6 +24,16 @@ interface ResetIdentityDialogProps { */ 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. */ @@ -33,17 +43,36 @@ interface ResetIdentityDialogProps { /** * 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 { 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 ( - + ); } diff --git a/apps/web/src/components/views/settings/encryption/ResetIdentityBody.tsx b/apps/web/src/components/views/settings/encryption/ResetIdentityBody.tsx index 91b7d65b5d4..2964d9032c1 100644 --- a/apps/web/src/components/views/settings/encryption/ResetIdentityBody.tsx +++ b/apps/web/src/components/views/settings/encryption/ResetIdentityBody.tsx @@ -17,6 +17,7 @@ import { uiAuthCallback } from "../../../../CreateCrossSigning"; import { EncryptionCardButtons } from "./EncryptionCardButtons"; import { EncryptionCardEmphasisedContent } from "./EncryptionCardEmphasisedContent"; import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import { timeout } from "../../../../utils/promise"; interface ResetIdentityBodyProps { /** @@ -24,6 +25,16 @@ interface ResetIdentityBodyProps { */ 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. */ @@ -60,13 +71,45 @@ export type ResetIdentityBodyVariant = "compromised" | "forgot" | "sync_failed" * * Used by {@link ResetIdentityPanel}. */ -export function ResetIdentityBody({ onCancelClick, onReset, variant }: ResetIdentityBodyProps): JSX.Element { +export function ResetIdentityBody({ + onCancelClick, + onReset, + resetTimeoutMs, + onFail, + variant, +}: ResetIdentityBodyProps): JSX.Element { 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 { + 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()); + } + } + + async function doOnClick(): Promise { + const crypto = matrixClient.getCrypto(); + if (!crypto) { + throw new Error("Crypto is not set up"); + } + + await crypto.resetEncryption((makeRequest) => uiAuthCallback(matrixClient, makeRequest)); + } + return ( @@ -84,17 +127,7 @@ export function ResetIdentityBody({ onCancelClick, onReset, variant }: ResetIden {variant === "compromised" && {_t("settings|encryption|advanced|breadcrumb_warning")}} -