{
+ const { finished } = Modal.createDialog<[boolean]>(QuestionDialog, {
+ title: _t('Warning!'),
+ description:
+
+
{ !this.state.serverSupportsControlOfDevicesLogout ?
+ _t(
+ "Resetting your password on this homeserver will cause all of your devices to be " +
+ "signed out. This will delete the message encryption keys stored on them, " +
+ "making encrypted chat history unreadable.",
+ ) :
+ _t(
+ "Signing out your devices will delete the message encryption keys stored on them, " +
+ "making encrypted chat history unreadable.",
+ )
+ }
+
{ _t(
+ "If you want to retain access to your chat history in encrypted rooms, set up Key Backup " +
+ "or export your message keys from one of your other devices before proceeding.",
+ ) }
+
,
+ button: _t('Continue'),
});
+ const [confirmed] = await finished;
+ return confirmed;
}
- renderForgot() {
- let errorText = null;
- const err = this.state.errorText;
- if (err) {
- errorText = { err }
;
- }
+ renderCheckEmail(): JSX.Element {
+ return ;
+ }
- let serverDeadSection;
- if (!this.state.serverIsAlive) {
- const classes = classNames({
- "mx_Login_error": true,
- "mx_Login_serverError": true,
- "mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal,
- });
- serverDeadSection = (
-
- { this.state.serverDeadError }
-
- );
- }
+ renderSetPassword(): JSX.Element {
+ const submitButtonChild = this.state.phase === Phase.ResettingPassword
+ ?
+ : _t("Reset password");
- return
- { errorText }
- { serverDeadSection }
-
+ return <>
+
+
{ _t("Reset your password") }
+ { this.state.serverSupportsControlOfDevicesLogout ?
+
+ this.setState({ logoutDevices: !this.state.logoutDevices })} checked={this.state.logoutDevices}>
+ { _t("Sign out of all devices") }
+
+
: null
+ }
+ { this.state.errorText && }
+
+
-
- { _t('Sign in instead') }
-
- ;
- }
-
- renderSendingEmail() {
- return ;
- }
-
- renderEmailSent() {
- return
- { _t("An email has been sent to %(emailAddress)s. Once you've followed the " +
- "link it contains, click below.", { emailAddress: this.state.email }) }
-
-
- { this.state.currentHttpRequest && (
-
)
- }
-
;
+ >;
}
renderDone() {
- return
-
{ _t("Your password has been reset.") }
+ return <>
+
+
{ _t("Your password has been reset.") }
{ this.state.logoutDevices ?
{ _t(
"You have been logged out of all devices and will no longer receive " +
@@ -411,33 +391,40 @@ export default class ForgotPassword extends React.Component {
type="button"
onClick={this.props.onComplete}
value={_t('Return to login screen')} />
-
;
+ >;
}
render() {
- let resetPasswordJsx;
+ let resetPasswordJsx: JSX.Element;
+
switch (this.state.phase) {
- case Phase.Forgot:
- resetPasswordJsx = this.renderForgot();
- break;
+ case Phase.EnterEmail:
case Phase.SendingEmail:
- resetPasswordJsx = this.renderSendingEmail();
+ resetPasswordJsx = this.renderEnterEmail();
break;
case Phase.EmailSent:
- resetPasswordJsx = this.renderEmailSent();
+ resetPasswordJsx = this.renderCheckEmail();
+ break;
+ case Phase.PasswordInput:
+ case Phase.ResettingPassword:
+ resetPasswordJsx = this.renderSetPassword();
break;
case Phase.Done:
resetPasswordJsx = this.renderDone();
break;
default:
- resetPasswordJsx =
;
+ // This should not happen. However, it is logged and the user is sent to the start.
+ logger.warn(`unknown forgot password phase ${this.state.phase}`);
+ this.setState({
+ phase: Phase.EnterEmail,
+ });
+ return;
}
return (
- { _t('Set a new password') }
{ resetPasswordJsx }
diff --git a/src/components/structures/auth/forgot-password/CheckEmail.tsx b/src/components/structures/auth/forgot-password/CheckEmail.tsx
new file mode 100644
index 00000000000..ee4e6081848
--- /dev/null
+++ b/src/components/structures/auth/forgot-password/CheckEmail.tsx
@@ -0,0 +1,84 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+
+import AccessibleButton from "../../../views/elements/AccessibleButton";
+import { Icon as EMailPromptIcon } from "../../../../../res/img/element-icons/email-prompt.svg";
+import { Icon as RetryIcon } from "../../../../../res/img/element-icons/retry.svg";
+import { _t } from '../../../../languageHandler';
+import Tooltip, { Alignment } from "../../../views/elements/Tooltip";
+import { useTimeoutToggle } from "../../../../hooks/useTimeoutToggle";
+import { ErrorMessage } from "../../ErrorMessage";
+
+interface CheckEmailProps {
+ email: string;
+ errorText: string | null;
+ onResendClick: () => Promise;
+ onSubmitForm: (ev: React.FormEvent) => void;
+}
+
+/**
+ * This component renders the email verification view of the forgot password flow.
+ */
+export const CheckEmail: React.FC = ({
+ email,
+ errorText,
+ onSubmitForm,
+ onResendClick,
+}) => {
+ const { toggle: toggleTooltipVisible, value: tooltipVisible } = useTimeoutToggle(false, 2500);
+
+ const onResendClickFn = async (): Promise => {
+ await onResendClick();
+ toggleTooltipVisible();
+ };
+
+ return <>
+
+ { _t("Check your email to continue") }
+
+ { _t(
+ "Follow the instructions sent to %(email)s",
+ { email: email },
+ { b: t => { t } },
+ ) }
+
+
+
{ _t("Did not receive it?") }
+
+
+ { _t("Resend") }
+
+
+
+ { errorText && }
+
+ >;
+};
diff --git a/src/components/structures/auth/forgot-password/EnterEmail.tsx b/src/components/structures/auth/forgot-password/EnterEmail.tsx
new file mode 100644
index 00000000000..b807bfbcf46
--- /dev/null
+++ b/src/components/structures/auth/forgot-password/EnterEmail.tsx
@@ -0,0 +1,96 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React, { useRef } from "react";
+
+import { Icon as EMailIcon } from "../../../../../res/img/element-icons/Email-icon.svg";
+import { _t, _td } from '../../../../languageHandler';
+import EmailField from "../../../views/auth/EmailField";
+import { ErrorMessage } from "../../ErrorMessage";
+import Spinner from "../../../views/elements/Spinner";
+import Field from "../../../views/elements/Field";
+
+interface EnterEmailProps {
+ email: string;
+ errorText: string | null;
+ loading: boolean;
+ onInputChanged: (stateKey: string, ev: React.FormEvent) => void;
+ onSubmitForm: (ev: React.FormEvent) => void;
+}
+
+/**
+ * This component renders the email input view of the forgot password flow.
+ */
+export const EnterEmail: React.FC = ({
+ email,
+ errorText,
+ loading,
+ onInputChanged,
+ onSubmitForm,
+}) => {
+ const submitButtonChild = loading
+ ?
+ : _t("Send email");
+
+ const emailFieldRef = useRef(null);
+
+ const onSubmit = async (event: React.FormEvent) => {
+ if (await emailFieldRef.current?.validate({ allowEmpty: false })) {
+ onSubmitForm(event);
+ return;
+ }
+
+ emailFieldRef.current?.focus();
+ emailFieldRef.current?.validate({ allowEmpty: false, focused: true });
+ };
+
+ return <>
+
+ { _t("Enter your email to reset password") }
+
+ {
+ _t(
+ "%(homeserver)s will send you a verification link to let you reset your password.",
+ { homeserver: "matrix.org" },
+ { b: t => { t } },
+ )
+ }
+
+
+ >;
+};
diff --git a/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx b/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx
new file mode 100644
index 00000000000..c18f4caded4
--- /dev/null
+++ b/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx
@@ -0,0 +1,78 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+
+import { _t } from "../../../../languageHandler";
+import AccessibleButton from "../../../views/elements/AccessibleButton";
+import { Icon as RetryIcon } from "../../../../../res/img/element-icons/retry.svg";
+import { Icon as EMailPromptIcon } from "../../../../../res/img/element-icons/email-prompt.svg";
+import Tooltip, { Alignment } from "../../../views/elements/Tooltip";
+import { useTimeoutToggle } from "../../../../hooks/useTimeoutToggle";
+import { ErrorMessage } from "../../ErrorMessage";
+
+interface Props {
+ email: string;
+ errorText: string | null;
+ onResendClick: () => Promise;
+}
+
+export const VerifyEmailModal: React.FC = ({
+ email,
+ errorText,
+ onResendClick,
+}) => {
+ const { toggle: toggleTooltipVisible, value: tooltipVisible } = useTimeoutToggle(false, 2500);
+
+ const onResendClickFn = async (): Promise => {
+ await onResendClick();
+ toggleTooltipVisible();
+ };
+
+ return <>
+
+ { _t("Verify your email to continue") }
+
+ { _t(
+ `We need to know it’s you before resetting your password.
+ Click the link in the email we just sent to %(email)s`,
+ {
+ email,
+ },
+ {
+ b: sub => { sub },
+ },
+ ) }
+
+
+
{ _t("Did not receive it?") }
+
+
+ { _t("Resend") }
+
+
+ { errorText &&
}
+
+ >;
+};
diff --git a/src/components/views/dialogs/QuestionDialog.tsx b/src/components/views/dialogs/QuestionDialog.tsx
index b1e913dc8ea..bc91d0a0f9c 100644
--- a/src/components/views/dialogs/QuestionDialog.tsx
+++ b/src/components/views/dialogs/QuestionDialog.tsx
@@ -39,7 +39,7 @@ export interface IQuestionDialogProps extends IDialogProps {
cancelButton?: React.ReactNode;
}
-export default class QuestionDialog extends React.Component {
+const QuestionDialog: React.ComponentClass = class extends React.Component {
public static defaultProps: Partial = {
title: "",
description: "",
@@ -90,4 +90,6 @@ export default class QuestionDialog extends React.Component
);
}
-}
+};
+
+export default QuestionDialog;
diff --git a/src/components/views/elements/Field.tsx b/src/components/views/elements/Field.tsx
index 59f16caacd1..3a0c3ef155f 100644
--- a/src/components/views/elements/Field.tsx
+++ b/src/components/views/elements/Field.tsx
@@ -290,7 +290,7 @@ export default class Field extends React.PureComponent {
let fieldTooltip;
if (tooltipContent || this.state.feedback) {
fieldTooltip = {
+ const timeoutId = useRef();
+ const [value, setValue] = useState(defaultValue);
+
+ const toggle = () => {
+ setValue(!defaultValue);
+ timeoutId.current = setTimeout(() => setValue(defaultValue), timeoutMs);
+ };
+
+ useEffect(() => {
+ return () => {
+ clearTimeout(timeoutId.current);
+ };
+ });
+
+ return {
+ toggle,
+ value,
+ };
+};
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 272583abc12..3f79caf25ae 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -3421,24 +3421,20 @@
"Device verified": "Device verified",
"Really reset verification keys?": "Really reset verification keys?",
"Skip verification for now": "Skip verification for now",
- "Failed to send email": "Failed to send email",
+ "Too many attempts in a short time. Wait some time before trying again.": "Too many attempts in a short time. Wait some time before trying again.",
+ "Too many attempts in a short time. Retry after %(timeout)s.": "Too many attempts in a short time. Retry after %(timeout)s.",
"Resetting your password on this homeserver will cause all of your devices to be signed out. This will delete the message encryption keys stored on them, making encrypted chat history unreadable.": "Resetting your password on this homeserver will cause all of your devices to be signed out. This will delete the message encryption keys stored on them, making encrypted chat history unreadable.",
"Signing out your devices will delete the message encryption keys stored on them, making encrypted chat history unreadable.": "Signing out your devices will delete the message encryption keys stored on them, making encrypted chat history unreadable.",
"If you want to retain access to your chat history in encrypted rooms, set up Key Backup or export your message keys from one of your other devices before proceeding.": "If you want to retain access to your chat history in encrypted rooms, set up Key Backup or export your message keys from one of your other devices before proceeding.",
- "The email address linked to your account must be entered.": "The email address linked to your account must be entered.",
- "The email address doesn't appear to be valid.": "The email address doesn't appear to be valid.",
+ "Reset password": "Reset password",
+ "Reset your password": "Reset your password",
+ "Confirm new password": "Confirm new password",
"A new password must be entered.": "A new password must be entered.",
"New passwords must match each other.": "New passwords must match each other.",
- "Sign out all devices": "Sign out all devices",
- "A verification email will be sent to your inbox to confirm setting your new password.": "A verification email will be sent to your inbox to confirm setting your new password.",
- "Send Reset Email": "Send Reset Email",
- "Sign in instead": "Sign in instead",
- "An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.": "An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.",
- "I have verified my email address": "I have verified my email address",
+ "Sign out of all devices": "Sign out of all devices",
"Your password has been reset.": "Your password has been reset.",
"You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device.": "You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device.",
"Return to login screen": "Return to login screen",
- "Set a new password": "Set a new password",
"Invalid homeserver discovery response": "Invalid homeserver discovery response",
"Failed to get autodiscovery configuration from server": "Failed to get autodiscovery configuration from server",
"Invalid base_url for m.homeserver": "Invalid base_url for m.homeserver",
@@ -3498,6 +3494,16 @@
"You're signed out": "You're signed out",
"Clear personal data": "Clear personal data",
"Warning: Your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.": "Warning: Your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.",
+ "Follow the instructions sent to %(email)s": "Follow the instructions sent to %(email)s",
+ "Did not receive it?": "Did not receive it?",
+ "Verification link email resent!": "Verification link email resent!",
+ "Send email": "Send email",
+ "Enter your email to reset password": "Enter your email to reset password",
+ "%(homeserver)s will send you a verification link to let you reset your password.": "%(homeserver)s will send you a verification link to let you reset your password.",
+ "The email address linked to your account must be entered.": "The email address linked to your account must be entered.",
+ "The email address doesn't appear to be valid.": "The email address doesn't appear to be valid.",
+ "Verify your email to continue": "Verify your email to continue",
+ "We need to know it’s you before resetting your password.\n Click the link in the email we just sent to %(email)s": "We need to know it’s you before resetting your password.\n Click the link in the email we just sent to %(email)s",
"Commands": "Commands",
"Command Autocomplete": "Command Autocomplete",
"Emoji Autocomplete": "Emoji Autocomplete",
diff --git a/test/components/structures/auth/ForgotPassword-test.tsx b/test/components/structures/auth/ForgotPassword-test.tsx
new file mode 100644
index 00000000000..f79c4f1ff5f
--- /dev/null
+++ b/test/components/structures/auth/ForgotPassword-test.tsx
@@ -0,0 +1,239 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+import { mocked } from "jest-mock";
+import { act, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { MatrixClient, createClient } from "matrix-js-sdk/src/matrix";
+
+import ForgotPassword from "../../../../src/components/structures/auth/ForgotPassword";
+import { ValidatedServerConfig } from "../../../../src/utils/ValidatedServerConfig";
+import { flushPromisesWithFakeTimers, stubClient } from "../../../test-utils";
+import { filterConsoleError } from "../../../test-utils/console";
+
+jest.mock("matrix-js-sdk/src/matrix", () => ({
+ ...jest.requireActual("matrix-js-sdk/src/matrix"),
+ createClient: jest.fn(),
+}));
+
+describe("", () => {
+ const testEmail = "user@example.com";
+ const testSid = "sid42";
+ const testPassword = "cRaZyP4ssw0rd!";
+ let client: MatrixClient;
+ let serverConfig: ValidatedServerConfig;
+ let onServerConfigChange: (serverConfig: ValidatedServerConfig) => void;
+ let onComplete: () => void;
+ let restoreConsole: () => void;
+
+ const typeIntoField = async (label: string, value: string): Promise => {
+ await act(async () => {
+ await userEvent.type(screen.getByLabelText(label), value, { delay: null });
+ // the message is shown after some time
+ jest.advanceTimersByTime(500);
+ });
+ };
+
+ const submitForm = async (submitLabel: string): Promise => {
+ await act(async () => {
+ await userEvent.click(screen.getByText(submitLabel), { delay: null });
+ });
+ };
+
+ beforeAll(() => {
+ restoreConsole = filterConsoleError(
+ // not implemented in js-dom https://github.com/jsdom/jsdom/issues/1937
+ "Not implemented: HTMLFormElement.prototype.requestSubmit",
+ );
+
+ client = stubClient();
+ serverConfig = new ValidatedServerConfig();
+ mocked(createClient).mockReturnValue(client);
+ jest.useFakeTimers();
+ onServerConfigChange = jest.fn();
+ onComplete = jest.fn();
+ });
+
+ afterAll(() => {
+ jest.useRealTimers();
+ restoreConsole();
+ });
+
+ describe("when starting a password reset flow", () => {
+ beforeEach(() => {
+ render();
+ });
+
+ it("should show the email input", () => {
+ expect(screen.getByLabelText("Email address")).toBeInTheDocument();
+ });
+
+ describe("when entering a non-email value", () => {
+ beforeEach(async () => {
+ await typeIntoField("Email address", "not en email");
+ });
+
+ it("should show a message about the wrong format", () => {
+ expect(screen.getByText("The email address doesn't appear to be valid.")).toBeInTheDocument();
+ });
+ });
+
+ describe("when submitting an unknown email", () => {
+ beforeEach(async () => {
+ await typeIntoField("Email address", testEmail);
+ mocked(client).requestPasswordEmailToken.mockRejectedValue({
+ errcode: "M_THREEPID_NOT_FOUND",
+ });
+ await submitForm("Send email");
+ });
+
+ it("should show an email not found message", () => {
+ expect(screen.getByText("This email address was not found")).toBeInTheDocument();
+ });
+ });
+
+ describe("when submitting an known email", () => {
+ beforeEach(async () => {
+ await typeIntoField("Email address", testEmail);
+ mocked(client).requestPasswordEmailToken.mockResolvedValue({
+ sid: testSid,
+ });
+ await submitForm("Send email");
+ });
+
+ it("should send the mail and show the check email view", () => {
+ expect(client.requestPasswordEmailToken).toHaveBeenCalledWith(
+ testEmail,
+ expect.any(String),
+ 1, // second send attempt
+ );
+ expect(screen.getByText("Check your email to continue")).toBeInTheDocument();
+ expect(screen.getByText(testEmail)).toBeInTheDocument();
+ });
+
+ describe("when clicking resend email", () => {
+ beforeEach(async () => {
+ await userEvent.click(screen.getByText("Resend"), { delay: null });
+ // the message is shown after some time
+ jest.advanceTimersByTime(500);
+ });
+
+ it("should should resend the mail and show the tooltip", () => {
+ expect(client.requestPasswordEmailToken).toHaveBeenCalledWith(
+ testEmail,
+ expect.any(String),
+ 2, // second send attempt
+ );
+ expect(screen.getByText("Verification link email resent!")).toBeInTheDocument();
+ });
+ });
+
+ describe("when clicking next", () => {
+ beforeEach(async () => {
+ await submitForm("Next");
+ });
+
+ it("should show the password input view", () => {
+ expect(screen.getByText("Reset your password")).toBeInTheDocument();
+ });
+
+ describe("when entering different passwords", () => {
+ beforeEach(async () => {
+ await typeIntoField("New Password", testPassword);
+ await typeIntoField("Confirm new password", testPassword + "asd");
+ });
+
+ it("should show an info about that", () => {
+ expect(screen.getByText("New passwords must match each other.")).toBeInTheDocument();
+ });
+ });
+
+ describe("when entering a new password", () => {
+ beforeEach(async () => {
+ mocked(client.setPassword).mockRejectedValue({ httpStatus: 401 });
+ await typeIntoField("New Password", testPassword);
+ await typeIntoField("Confirm new password", testPassword);
+ });
+
+ describe("and submitting it running into rate limiting", () => {
+ beforeEach(async () => {
+ mocked(client.setPassword).mockRejectedValue({
+ message: "rate limit reached",
+ httpStatus: 429,
+ data: {
+ retry_after_ms: (13 * 60 + 37) * 1000,
+ },
+ });
+ await submitForm("Reset password");
+ });
+
+ it("should show the rate limit error message", () => {
+ expect(
+ screen.getByText("Too many attempts in a short time. Retry after 13:37."),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe("and submitting it", () => {
+ beforeEach(async () => {
+ await submitForm("Reset password");
+ // double flash promises for the modal to appear
+ await flushPromisesWithFakeTimers();
+ await flushPromisesWithFakeTimers();
+ });
+
+ it("should send the new password and show the click validation link dialog", () => {
+ expect(client.setPassword).toHaveBeenCalledWith(
+ {
+ type: "m.login.email.identity",
+ threepid_creds: {
+ client_secret: expect.any(String),
+ sid: testSid,
+ },
+ threepidCreds: {
+ client_secret: expect.any(String),
+ sid: testSid,
+ },
+ },
+ testPassword,
+ false,
+ );
+ expect(screen.getByText("Verify your email to continue")).toBeInTheDocument();
+ expect(screen.getByText(testEmail)).toBeInTheDocument();
+ });
+
+ describe("when validating the link from the mail", () => {
+ beforeEach(async () => {
+ mocked(client.setPassword).mockResolvedValue({});
+ // be sure the next set password attempt was sent
+ jest.advanceTimersByTime(5000);
+ });
+
+ it("should display the confirm reset view", () => {
+ expect(screen.getByText("Your password has been reset.")).toBeInTheDocument();
+ });
+ });
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/test/test-utils/console.ts b/test/test-utils/console.ts
new file mode 100644
index 00000000000..b695df6c00e
--- /dev/null
+++ b/test/test-utils/console.ts
@@ -0,0 +1,39 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Allows to filter out specific messages in console.error.
+ *
+ * @param {string[]} ignoreList Messages to be filtered
+ * @returns {() => void} Function to restore the console
+ */
+export const filterConsoleError = (...ignoreList: string[]) => {
+ const originalConsoleError = window.console.error;
+
+ window.console.error = (...data: any[]) => {
+ const message = data?.[0]?.message || data?.[0];
+
+ if (typeof message === "string" && ignoreList.some(i => message.includes(i))) {
+ return;
+ }
+
+ originalConsoleError(data);
+ };
+
+ return () => {
+ window.console.error = originalConsoleError;
+ };
+};
diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts
index 5b75d87a530..8b4ca9ba865 100644
--- a/test/test-utils/test-utils.ts
+++ b/test/test-utils/test-utils.ts
@@ -85,6 +85,7 @@ export function createTestClient(): MatrixClient {
getIdentityServerUrl: jest.fn(),
getDomain: jest.fn().mockReturnValue("matrix.org"),
getUserId: jest.fn().mockReturnValue("@userId:matrix.org"),
+ getUserIdLocalpart: jest.fn().mockResolvedValue("userId"),
getUser: jest.fn().mockReturnValue({ on: jest.fn() }),
getDeviceId: jest.fn().mockReturnValue("ABCDEFGHI"),
deviceId: "ABCDEFGHI",
@@ -193,6 +194,9 @@ export function createTestClient(): MatrixClient {
uploadContent: jest.fn(),
getEventMapper: () => (opts) => new MatrixEvent(opts),
leaveRoomChain: jest.fn(roomId => ({ [roomId]: null })),
+ doesServerSupportLogoutDevices: jest.fn().mockReturnValue(true),
+ requestPasswordEmailToken: jest.fn().mockRejectedValue({}),
+ setPassword: jest.fn().mockRejectedValue({}),
} as unknown as MatrixClient;
client.reEmitter = new ReEmitter(client);