Further password reset flow enhancements (#9662)

This commit is contained in:
Michael Weimann 2022-12-06 10:01:25 +01:00 committed by GitHub
parent 82ad8d5aa2
commit 89439d4f10
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 241 additions and 67 deletions

View file

@ -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 {

View file

@ -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;
}
}
}

View file

@ -347,7 +347,11 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
<div className="mx_Dialog">
{ this.staticModal.elem }
</div>
<div className="mx_Dialog_background mx_Dialog_staticBackground" onClick={this.onBackgroundClick} />
<div
data-testid="dialog-background"
className="mx_Dialog_background mx_Dialog_staticBackground"
onClick={this.onBackgroundClick}
/>
</div>
);
@ -368,7 +372,11 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
<div className="mx_Dialog">
{ modal.elem }
</div>
<div className="mx_Dialog_background" onClick={this.onBackgroundClick} />
<div
data-testid="dialog-background"
className="mx_Dialog_background"
onClick={this.onBackgroundClick}
/>
</div>
);

View file

@ -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<void> {
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.

View file

@ -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<Props, State> {
{
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<void> => {
@ -339,6 +363,7 @@ export default class ForgotPassword extends React.Component<Props, State> {
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<Props, State> {
return <CheckEmail
email={this.state.email}
errorText={this.state.errorText}
onReEnterEmailClick={() => this.setState({ phase: Phase.EnterEmail })}
onResendClick={this.sendVerificationMail}
onSubmitForm={this.onSubmitForm}
/>;

View file

@ -27,6 +27,7 @@ import { ErrorMessage } from "../../ErrorMessage";
interface CheckEmailProps {
email: string;
errorText: string | ReactNode | null;
onReEnterEmailClick: () => void;
onResendClick: () => Promise<boolean>;
onSubmitForm: (ev: React.FormEvent) => void;
}
@ -37,6 +38,7 @@ interface CheckEmailProps {
export const CheckEmail: React.FC<CheckEmailProps> = ({
email,
errorText,
onReEnterEmailClick,
onSubmitForm,
onResendClick,
}) => {
@ -50,13 +52,32 @@ export const CheckEmail: React.FC<CheckEmailProps> = ({
return <>
<EMailPromptIcon className="mx_AuthBody_emailPromptIcon--shifted" />
<h1>{ _t("Check your email to continue") }</h1>
<p>
{ _t(
"Follow the instructions sent to <b>%(email)s</b>",
{ email: email },
{ b: t => <b>{ t }</b> },
) }
</p>
<div className="mx_AuthBody_text">
<p>
{ _t(
"Follow the instructions sent to <b>%(email)s</b>",
{ email: email },
{ b: t => <b>{ t }</b> },
) }
</p>
<div className="mx_AuthBody_did-not-receive">
<span className="mx_VerifyEMailDialog_text-light">{ _t("Wrong email address?") }</span>
<AccessibleButton
className="mx_AuthBody_resend-button"
kind="link"
onClick={onReEnterEmailClick}
>
{ _t("Re-enter email address") }
</AccessibleButton>
</div>
</div>
{ errorText && <ErrorMessage message={errorText} /> }
<input
onClick={onSubmitForm}
type="button"
className="mx_Login_submit"
value={_t("Next")}
/>
<div className="mx_AuthBody_did-not-receive">
<span className="mx_VerifyEMailDialog_text-light">{ _t("Did not receive it?") }</span>
<AccessibleButton
@ -73,12 +94,5 @@ export const CheckEmail: React.FC<CheckEmailProps> = ({
/>
</AccessibleButton>
</div>
{ errorText && <ErrorMessage message={errorText} /> }
<input
onClick={onSubmitForm}
type="button"
className="mx_Login_submit"
value={_t("Next")}
/>
</>;
};

View file

@ -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<HTMLInputElement>) => void;
onLoginClick: () => void;
onSubmitForm: (ev: React.FormEvent) => void;
}
@ -41,6 +43,7 @@ export const EnterEmail: React.FC<EnterEmailProps> = ({
homeserver,
loading,
onInputChanged,
onLoginClick,
onSubmitForm,
}) => {
const submitButtonChild = loading
@ -92,6 +95,15 @@ export const EnterEmail: React.FC<EnterEmailProps> = ({
>
{ submitButtonChild }
</button>
<div className="mx_AuthBody_button-container">
<AccessibleButton
className="mx_AuthBody_sign-in-instead-button"
element="button"
kind="link"
onClick={onLoginClick}>
{ _t("Sign in instead") }
</AccessibleButton>
</div>
</fieldset>
</form>
</>;

View file

@ -27,12 +27,16 @@ import { ErrorMessage } from "../../ErrorMessage";
interface Props {
email: string;
errorText: string | null;
onCloseClick: () => void;
onReEnterEmailClick: () => void;
onResendClick: () => Promise<boolean>;
}
export const VerifyEmailModal: React.FC<Props> = ({
email,
errorText,
onCloseClick,
onReEnterEmailClick,
onResendClick,
}) => {
const { toggle: toggleTooltipVisible, value: tooltipVisible } = useTimeoutToggle(false, 2500);
@ -57,7 +61,8 @@ export const VerifyEmailModal: React.FC<Props> = ({
},
) }
</p>
<div className="mx_AuthBody_did-not-receive mx_AuthBody_did-not-receive--centered">
<div className="mx_AuthBody_did-not-receive">
<span className="mx_VerifyEMailDialog_text-light">{ _t("Did not receive it?") }</span>
<AccessibleButton
className="mx_AuthBody_resend-button"
@ -74,5 +79,22 @@ export const VerifyEmailModal: React.FC<Props> = ({
</AccessibleButton>
{ errorText && <ErrorMessage message={errorText} /> }
</div>
<div className="mx_AuthBody_did-not-receive">
<span className="mx_VerifyEMailDialog_text-light">{ _t("Wrong email address?") }</span>
<AccessibleButton
className="mx_AuthBody_resend-button"
kind="link"
onClick={onReEnterEmailClick}
>
{ _t("Re-enter email address") }
</AccessibleButton>
</div>
<AccessibleButton
onClick={onCloseClick}
className="mx_Dialog_cancelButton"
aria-label={_t("Close dialog")}
/>
</>;
};

View file

@ -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 <b>%(email)s</b>": "Follow the instructions sent to <b>%(email)s</b>",
"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 @@
"<b>%(homeserver)s</b> will send you a verification link to let you reset your password.": "<b>%(homeserver)s</b> 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 its you before resetting your password.\n Click the link in the email we just sent to <b>%(email)s</b>": "We need to know its you before resetting your password.\n Click the link in the email we just sent to <b>%(email)s</b>",
"Commands": "Commands",

View file

@ -38,6 +38,7 @@ describe("<ForgotPassword>", () => {
let client: MatrixClient;
let serverConfig: ValidatedServerConfig;
let onComplete: () => void;
let onLoginClick: () => void;
let renderResult: RenderResult;
let restoreConsole: () => void;
@ -49,9 +50,16 @@ describe("<ForgotPassword>", () => {
});
};
const submitForm = async (submitLabel: string): Promise<void> => {
const clickButton = async (label: string): Promise<void> => {
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("<ForgotPassword>", () => {
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("<ForgotPassword>", () => {
renderResult = render(<ForgotPassword
serverConfig={serverConfig}
onComplete={onComplete}
onLoginClick={onLoginClick}
/>);
});
@ -108,6 +118,7 @@ describe("<ForgotPassword>", () => {
renderResult.rerender(<ForgotPassword
serverConfig={serverConfig}
onComplete={onComplete}
onLoginClick={onLoginClick}
/>);
});
@ -116,6 +127,16 @@ describe("<ForgotPassword>", () => {
});
});
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("<ForgotPassword>", () => {
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("<ForgotPassword>", () => {
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("<ForgotPassword>", () => {
serverIsAlive: false,
serverDeadError: "server down",
});
await submitForm("Send email");
await clickButton("Send email");
});
it("should show the server error", () => {
@ -180,7 +201,7 @@ describe("<ForgotPassword>", () => {
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("<ForgotPassword>", () => {
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("<ForgotPassword>", () => {
describe("when clicking next", () => {
beforeEach(async () => {
await submitForm("Next");
await clickButton("Next");
});
it("should show the password input view", () => {
@ -246,7 +277,7 @@ describe("<ForgotPassword>", () => {
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("<ForgotPassword>", () => {
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("<ForgotPassword>", () => {
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({});