diff --git a/res/css/views/auth/_AuthBody.pcss b/res/css/views/auth/_AuthBody.pcss index 824f6411dfd..387c0199285 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 $spacing-8; + } + } + + .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 { @@ -168,7 +197,7 @@ limitations under the License. color: $accent; display: flex; gap: $spacing-4; - padding: 4px; + padding: $spacing-4; &:hover { background-color: $system; @@ -209,7 +238,7 @@ limitations under the License. text-align: center; .mx_AuthBody_paddedFooter_title { - margin-top: 16px; + margin-top: $spacing-16; font-size: $font-15px; line-height: $font-24px; @@ -220,7 +249,7 @@ limitations under the License. } .mx_AuthBody_paddedFooter_subtitle { - margin-top: 8px; + margin-top: $spacing-8; font-size: $font-10px; line-height: $font-14px; } @@ -236,7 +265,7 @@ limitations under the License. } .mx_SSOButtons + .mx_AuthBody_changeFlow { - margin-top: 24px; + margin-top: $spacing-24; } .mx_AuthBody_spinner { diff --git a/res/css/views/dialogs/_VerifyEMailDialog.pcss b/res/css/views/dialogs/_VerifyEMailDialog.pcss index fa36f0e114f..47541dc452a 100644 --- a/res/css/views/dialogs/_VerifyEMailDialog.pcss +++ b/res/css/views/dialogs/_VerifyEMailDialog.pcss @@ -20,8 +20,8 @@ limitations under the License. .mx_Dialog { color: $primary-content; - font-size: 14px; - padding: 16px; + font-size: $font-14px; + 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: $spacing-8; + } + + .mx_Dialog_cancelButton { + right: 10px; + } } } diff --git a/src/Modal.tsx b/src/Modal.tsx index ee24b15d54d..53e47cc01a0 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 1f2c5412703..7bcb6ac78e3 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 fe246aabf7e..4698b99ae81 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, @@ -60,7 +63,7 @@ enum Phase { interface Props { serverConfig: ValidatedServerConfig; - onLoginClick?: () => void; + onLoginClick: () => void; onComplete: () => void; } @@ -277,22 +280,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 +363,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 +399,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 27fa82f25e1..b1faba936e9 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 a630291ae26..3201349b3d8 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 d63e4c97d79..41bdb7a0518 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 4c8e4d33c36..e2daf502633 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 9f4b192aa9b..97e26a11505 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({});