From a2f876c5720169d36f82c72f3235eb28d40e47d7 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 30 Nov 2022 16:09:43 +0100 Subject: [PATCH] Add password reset back options --- res/css/views/auth/_AuthBody.pcss | 47 ++++++++-- res/css/views/dialogs/_VerifyEMailDialog.pcss | 11 ++- src/Modal.tsx | 12 ++- src/PasswordReset.ts | 20 ----- .../structures/auth/ForgotPassword.tsx | 40 +++++++-- .../auth/forgot-password/CheckEmail.tsx | 42 ++++++--- .../auth/forgot-password/EnterEmail.tsx | 12 +++ .../auth/forgot-password/VerifyEmailModal.tsx | 24 ++++- src/i18n/strings/en_EN.json | 3 + .../structures/auth/ForgotPassword-test.tsx | 89 +++++++++++++++++-- 10 files changed, 239 insertions(+), 61 deletions(-) diff --git a/res/css/views/auth/_AuthBody.pcss b/res/css/views/auth/_AuthBody.pcss index 824f6411dfd4..d59c1fe25b8d 100644 --- a/res/css/views/auth/_AuthBody.pcss +++ b/res/css/views/auth/_AuthBody.pcss @@ -137,15 +137,50 @@ limitations under the License. } /* specialisation for password reset views */ -.mx_AuthBody_forgot-password { +.mx_AuthBody.mx_AuthBody_forgot-password { font-size: $font-14px; color: $primary-content; padding: 50px 32px; min-height: 600px; h1 { - margin-bottom: $spacing-20; - margin-top: $spacing-24; + margin: $spacing-24 0; + } + + .mx_AuthBody_button-container { + display: flex; + justify-content: center; + } + + .mx_Login_submit { + font-weight: $font-semi-bold; + margin: 0 0 $spacing-16; + } + + .mx_AuthBody_text { + margin-bottom: $spacing-32; + + p { + margin: 0 0 8px; + } + } + + .mx_AuthBody_sign-in-instead-button { + font-weight: $font-semi-bold; + padding: $spacing-4; + } + + .mx_AuthBody_fieldRow { + margin-bottom: $spacing-24; + } + + .mx_AccessibleButton.mx_AccessibleButton_hasKind { + background: none; + + &:disabled { + cursor: default; + opacity: .4; + } } } @@ -154,12 +189,6 @@ limitations under the License. color: $secondary-content; display: flex; gap: $spacing-8; - margin-bottom: 10px; - margin-top: $spacing-24; -} - -.mx_AuthBody_did-not-receive--centered { - justify-content: center; } .mx_AuthBody_resend-button { diff --git a/res/css/views/dialogs/_VerifyEMailDialog.pcss b/res/css/views/dialogs/_VerifyEMailDialog.pcss index fa36f0e114f0..3d92f08babe0 100644 --- a/res/css/views/dialogs/_VerifyEMailDialog.pcss +++ b/res/css/views/dialogs/_VerifyEMailDialog.pcss @@ -21,7 +21,7 @@ limitations under the License. .mx_Dialog { color: $primary-content; font-size: 14px; - padding: 16px; + padding: $spacing-24 $spacing-24 $spacing-16; text-align: center; width: 485px; @@ -34,5 +34,14 @@ limitations under the License. color: $secondary-content; line-height: 20px; } + + .mx_AuthBody_did-not-receive { + justify-content: center; + margin-bottom: 8px; + } + + .mx_Dialog_cancelButton { + right: 10px; + } } } diff --git a/src/Modal.tsx b/src/Modal.tsx index ee24b15d54d7..53e47cc01a0b 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -347,7 +347,11 @@ export class ModalManager extends TypedEventEmitter { this.staticModal.elem } -
+
); @@ -368,7 +372,11 @@ export class ModalManager extends TypedEventEmitter { modal.elem }
-
+
); diff --git a/src/PasswordReset.ts b/src/PasswordReset.ts index 1f2c54127035..7bcb6ac78e3e 100644 --- a/src/PasswordReset.ts +++ b/src/PasswordReset.ts @@ -19,8 +19,6 @@ import { createClient, IRequestTokenResponse, MatrixClient } from 'matrix-js-sdk import { _t } from './languageHandler'; -const CHECK_EMAIL_VERIFIED_POLL_INTERVAL = 2000; - /** * Allows a user to reset their password on a homeserver. * @@ -108,24 +106,6 @@ export default class PasswordReset { await this.checkEmailLinkClicked(); } - public async retrySetNewPassword(password: string): Promise { - this.password = password; - return new Promise((resolve) => { - this.tryCheckEmailLinkClicked(resolve); - }); - } - - private tryCheckEmailLinkClicked(resolve: Function): void { - this.checkEmailLinkClicked() - .then(() => resolve()) - .catch(() => { - window.setTimeout( - () => this.tryCheckEmailLinkClicked(resolve), - CHECK_EMAIL_VERIFIED_POLL_INTERVAL, - ); - }); - } - /** * Checks if the email link has been clicked by attempting to change the password * for the mxid linked to the email. diff --git a/src/components/structures/auth/ForgotPassword.tsx b/src/components/structures/auth/ForgotPassword.tsx index fe246aabf7e6..95382a8a84a8 100644 --- a/src/components/structures/auth/ForgotPassword.tsx +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -19,6 +19,7 @@ limitations under the License. import React, { ReactNode } from 'react'; import { logger } from 'matrix-js-sdk/src/logger'; import { createClient } from "matrix-js-sdk/src/matrix"; +import { sleep } from 'matrix-js-sdk/src/utils'; import { _t, _td } from '../../../languageHandler'; import Modal from "../../../Modal"; @@ -43,6 +44,8 @@ import Spinner from '../../views/elements/Spinner'; import { formatSeconds } from '../../../DateUtils'; import AutoDiscoveryUtils from '../../../utils/AutoDiscoveryUtils'; +const emailCheckInterval = 2000; + enum Phase { // Show email input EnterEmail = 1, @@ -83,6 +86,10 @@ interface State { } export default class ForgotPassword extends React.Component { + public static defaultProps: Partial = { + onLoginClick: () => {}, + }; + private reset: PasswordReset; private fieldPassword: Field | null = null; private fieldPasswordConfirm: Field | null = null; @@ -277,22 +284,43 @@ export default class ForgotPassword extends React.Component { { email: this.state.email, errorText: this.state.errorText, + onCloseClick: () => { + modal.close(); + this.setState({ phase: Phase.PasswordInput }); + }, + onReEnterEmailClick: () => { + modal.close(); + this.setState({ phase: Phase.EnterEmail }); + }, onResendClick: this.sendVerificationMail, }, "mx_VerifyEMailDialog", false, false, { - // this modal cannot be dismissed except reset is done or forced onBeforeClose: async (reason?: string) => { - return this.state.phase === Phase.Done || reason === "force"; + if (reason === "backgroundClick") { + // Modal dismissed by clicking the background. + // Go one phase back. + this.setState({ phase: Phase.PasswordInput }); + } + + return true; }, }, ); - await this.reset.retrySetNewPassword(this.state.password); - this.phase = Phase.Done; - modal.close(); + // Don't retry if the phase changed. For example when going back to email input. + while (this.state.phase === Phase.ResettingPassword) { + try { + await this.reset.setNewPassword(this.state.password); + this.setState({ phase: Phase.Done }); + modal.close(); + } catch (e) { + // Email not confirmed, yet. Retry after a while. + await sleep(emailCheckInterval); + } + } } private onSubmitForm = async (ev: React.FormEvent): Promise => { @@ -339,6 +367,7 @@ export default class ForgotPassword extends React.Component { homeserver={this.props.serverConfig.hsName} loading={this.state.phase === Phase.SendingEmail} onInputChanged={this.onInputChanged} + onLoginClick={this.props.onLoginClick!} // set by default props onSubmitForm={this.onSubmitForm} />; } @@ -374,6 +403,7 @@ export default class ForgotPassword extends React.Component { return this.setState({ phase: Phase.EnterEmail })} onResendClick={this.sendVerificationMail} onSubmitForm={this.onSubmitForm} />; diff --git a/src/components/structures/auth/forgot-password/CheckEmail.tsx b/src/components/structures/auth/forgot-password/CheckEmail.tsx index 27fa82f25e1d..b1faba936e96 100644 --- a/src/components/structures/auth/forgot-password/CheckEmail.tsx +++ b/src/components/structures/auth/forgot-password/CheckEmail.tsx @@ -27,6 +27,7 @@ import { ErrorMessage } from "../../ErrorMessage"; interface CheckEmailProps { email: string; errorText: string | ReactNode | null; + onReEnterEmailClick: () => void; onResendClick: () => Promise; onSubmitForm: (ev: React.FormEvent) => void; } @@ -37,6 +38,7 @@ interface CheckEmailProps { export const CheckEmail: React.FC = ({ email, errorText, + onReEnterEmailClick, onSubmitForm, onResendClick, }) => { @@ -50,13 +52,32 @@ export const CheckEmail: React.FC = ({ return <>

{ _t("Check your email to continue") }

-

- { _t( - "Follow the instructions sent to %(email)s", - { email: email }, - { b: t => { t } }, - ) } -

+
+

+ { _t( + "Follow the instructions sent to %(email)s", + { email: email }, + { b: t => { t } }, + ) } +

+
+ { _t("Wrong email address?") } + + { _t("Re-enter email address") } + +
+
+ { errorText && } +
{ _t("Did not receive it?") } = ({ />
- { errorText && } - ; }; diff --git a/src/components/structures/auth/forgot-password/EnterEmail.tsx b/src/components/structures/auth/forgot-password/EnterEmail.tsx index a630291ae26b..3201349b3d8f 100644 --- a/src/components/structures/auth/forgot-password/EnterEmail.tsx +++ b/src/components/structures/auth/forgot-password/EnterEmail.tsx @@ -22,6 +22,7 @@ import EmailField from "../../../views/auth/EmailField"; import { ErrorMessage } from "../../ErrorMessage"; import Spinner from "../../../views/elements/Spinner"; import Field from "../../../views/elements/Field"; +import AccessibleButton from "../../../views/elements/AccessibleButton"; interface EnterEmailProps { email: string; @@ -29,6 +30,7 @@ interface EnterEmailProps { homeserver: string; loading: boolean; onInputChanged: (stateKey: string, ev: React.FormEvent) => void; + onLoginClick: () => void; onSubmitForm: (ev: React.FormEvent) => void; } @@ -41,6 +43,7 @@ export const EnterEmail: React.FC = ({ homeserver, loading, onInputChanged, + onLoginClick, onSubmitForm, }) => { const submitButtonChild = loading @@ -92,6 +95,15 @@ export const EnterEmail: React.FC = ({ > { submitButtonChild } +
+ + { _t("Sign in instead") } + +
; diff --git a/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx b/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx index d63e4c97d79c..41bdb7a05181 100644 --- a/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx +++ b/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx @@ -27,12 +27,16 @@ import { ErrorMessage } from "../../ErrorMessage"; interface Props { email: string; errorText: string | null; + onCloseClick: () => void; + onReEnterEmailClick: () => void; onResendClick: () => Promise; } export const VerifyEmailModal: React.FC = ({ email, errorText, + onCloseClick, + onReEnterEmailClick, onResendClick, }) => { const { toggle: toggleTooltipVisible, value: tooltipVisible } = useTimeoutToggle(false, 2500); @@ -57,7 +61,8 @@ export const VerifyEmailModal: React.FC = ({ }, ) }

-
+ +
{ _t("Did not receive it?") } = ({ { errorText && }
+ +
+ { _t("Wrong email address?") } + + { _t("Re-enter email address") } + +
+ + ; }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 7e8ba41bfae8..f469e52b5353 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3496,6 +3496,8 @@ "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", + "Wrong email address?": "Wrong email address?", + "Re-enter email address": "Re-enter email address", "Did not receive it?": "Did not receive it?", "Verification link email resent!": "Verification link email resent!", "Send email": "Send email", @@ -3503,6 +3505,7 @@ "%(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.", + "Sign in instead": "Sign in instead", "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", diff --git a/test/components/structures/auth/ForgotPassword-test.tsx b/test/components/structures/auth/ForgotPassword-test.tsx index 9f4b192aa9b3..97e26a11505b 100644 --- a/test/components/structures/auth/ForgotPassword-test.tsx +++ b/test/components/structures/auth/ForgotPassword-test.tsx @@ -38,6 +38,7 @@ describe("", () => { let client: MatrixClient; let serverConfig: ValidatedServerConfig; let onComplete: () => void; + let onLoginClick: () => void; let renderResult: RenderResult; let restoreConsole: () => void; @@ -49,9 +50,16 @@ describe("", () => { }); }; - const submitForm = async (submitLabel: string): Promise => { + const clickButton = async (label: string): Promise => { await act(async () => { - await userEvent.click(screen.getByText(submitLabel), { delay: null }); + await userEvent.click(screen.getByText(label), { delay: null }); + }); + }; + + const itShouldCloseTheDialogAndShowThePasswordInput = (): void => { + it("should close the dialog and show the password input", () => { + expect(screen.queryByText("Verify your email to continue")).not.toBeInTheDocument(); + expect(screen.getByText("Reset your password")).toBeInTheDocument(); }); }; @@ -70,6 +78,7 @@ describe("", () => { serverConfig.hsName = "example.com"; onComplete = jest.fn(); + onLoginClick = jest.fn(); jest.spyOn(AutoDiscoveryUtils, "validateServerConfigWithStaticUrls").mockResolvedValue(serverConfig); jest.spyOn(AutoDiscoveryUtils, "authComponentStateForError"); @@ -94,6 +103,7 @@ describe("", () => { renderResult = render(); }); @@ -108,6 +118,7 @@ describe("", () => { renderResult.rerender(); }); @@ -116,6 +127,16 @@ describe("", () => { }); }); + describe("when clicking »Sign in instead«", () => { + beforeEach(async () => { + await clickButton("Sign in instead"); + }); + + it("should call onLoginClick()", () => { + expect(onLoginClick).toHaveBeenCalled(); + }); + }); + describe("when entering a non-email value", () => { beforeEach(async () => { await typeIntoField("Email address", "not en email"); @@ -132,7 +153,7 @@ describe("", () => { mocked(client).requestPasswordEmailToken.mockRejectedValue({ errcode: "M_THREEPID_NOT_FOUND", }); - await submitForm("Send email"); + await clickButton("Send email"); }); it("should show an email not found message", () => { @@ -146,7 +167,7 @@ describe("", () => { mocked(client).requestPasswordEmailToken.mockRejectedValue({ name: "ConnectionError", }); - await submitForm("Send email"); + await clickButton("Send email"); }); it("should show an info about that", () => { @@ -166,7 +187,7 @@ describe("", () => { serverIsAlive: false, serverDeadError: "server down", }); - await submitForm("Send email"); + await clickButton("Send email"); }); it("should show the server error", () => { @@ -180,7 +201,7 @@ describe("", () => { mocked(client).requestPasswordEmailToken.mockResolvedValue({ sid: testSid, }); - await submitForm("Send email"); + await clickButton("Send email"); }); it("should send the mail and show the check email view", () => { @@ -193,6 +214,16 @@ describe("", () => { expect(screen.getByText(testEmail)).toBeInTheDocument(); }); + describe("when clicking re-enter email", () => { + beforeEach(async () => { + await clickButton("Re-enter email address"); + }); + + it("go back to the email input", () => { + expect(screen.queryByText("Enter your email to reset password")).toBeInTheDocument(); + }); + }); + describe("when clicking resend email", () => { beforeEach(async () => { await userEvent.click(screen.getByText("Resend"), { delay: null }); @@ -212,7 +243,7 @@ describe("", () => { describe("when clicking next", () => { beforeEach(async () => { - await submitForm("Next"); + await clickButton("Next"); }); it("should show the password input view", () => { @@ -246,7 +277,7 @@ describe("", () => { retry_after_ms: (13 * 60 + 37) * 1000, }, }); - await submitForm("Reset password"); + await clickButton("Reset password"); }); it("should show the rate limit error message", () => { @@ -258,7 +289,7 @@ describe("", () => { describe("and submitting it", () => { beforeEach(async () => { - await submitForm("Reset password"); + await clickButton("Reset password"); // double flush promises for the modal to appear await flushPromisesWithFakeTimers(); await flushPromisesWithFakeTimers(); @@ -284,6 +315,46 @@ describe("", () => { expect(screen.getByText(testEmail)).toBeInTheDocument(); }); + describe("and dismissing the dialog by clicking the background", () => { + beforeEach(async () => { + await act(async () => { + await userEvent.click(screen.getByTestId("dialog-background"), { delay: null }); + }); + // double flush promises for the modal to disappear + await flushPromisesWithFakeTimers(); + await flushPromisesWithFakeTimers(); + }); + + itShouldCloseTheDialogAndShowThePasswordInput(); + }); + + describe("and dismissing the dialog", () => { + beforeEach(async () => { + await act(async () => { + await userEvent.click(screen.getByLabelText("Close dialog"), { delay: null }); + }); + // double flush promises for the modal to disappear + await flushPromisesWithFakeTimers(); + await flushPromisesWithFakeTimers(); + }); + + itShouldCloseTheDialogAndShowThePasswordInput(); + }); + + describe("when clicking re-enter email", () => { + beforeEach(async () => { + await clickButton("Re-enter email address"); + // double flush promises for the modal to disappear + await flushPromisesWithFakeTimers(); + await flushPromisesWithFakeTimers(); + }); + + it("should close the dialog and go back to the email input", () => { + expect(screen.queryByText("Verify your email to continue")).not.toBeInTheDocument(); + expect(screen.queryByText("Enter your email to reset password")).toBeInTheDocument(); + }); + }); + describe("when validating the link from the mail", () => { beforeEach(async () => { mocked(client.setPassword).mockResolvedValue({});