From ac17125ba6048b0b38484c18e4e13803674bce8c Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 14 Sep 2022 08:30:21 +0200 Subject: [PATCH 01/11] Start new login flow implementation --- res/css/_components.pcss | 2 + res/css/compound/_Icon.pcss | 9 + res/css/structures/_ErrorMessage.pcss | 25 + res/css/structures/auth/_Login.pcss | 4 +- res/css/views/auth/_AuthBody.pcss | 78 ++- res/css/views/dialogs/_VerifyEMailDialog.pcss | 35 ++ res/css/views/elements/_AccessibleButton.pcss | 1 + res/css/views/elements/_Field.pcss | 2 - res/css/views/elements/_Tooltip.pcss | 2 +- res/img/element-icons/Checkbox.svg | 3 + res/img/element-icons/Email-icon.svg | 3 + res/img/element-icons/lock.svg | 4 +- res/img/element-icons/retry.svg | 8 +- src/PasswordReset.ts | 56 +- src/components/structures/ErrorMessage.tsx | 40 ++ .../structures/auth/ForgotPassword.tsx | 513 +++++++++--------- .../auth/forgot-password/CheckEmail.tsx | 84 +++ .../auth/forgot-password/EnterEmail.tsx | 96 ++++ .../auth/forgot-password/VerifyEmailModal.tsx | 78 +++ .../views/dialogs/QuestionDialog.tsx | 6 +- src/components/views/elements/Field.tsx | 2 +- src/hooks/useTimeoutToggle.ts | 44 ++ src/i18n/strings/en_EN.json | 26 +- .../structures/auth/ForgotPassword-test.tsx | 239 ++++++++ test/test-utils/console.ts | 39 ++ test/test-utils/test-utils.ts | 4 + 26 files changed, 1109 insertions(+), 294 deletions(-) create mode 100644 res/css/structures/_ErrorMessage.pcss create mode 100644 res/css/views/dialogs/_VerifyEMailDialog.pcss create mode 100644 res/img/element-icons/Checkbox.svg create mode 100644 res/img/element-icons/Email-icon.svg create mode 100644 src/components/structures/ErrorMessage.tsx create mode 100644 src/components/structures/auth/forgot-password/CheckEmail.tsx create mode 100644 src/components/structures/auth/forgot-password/EnterEmail.tsx create mode 100644 src/components/structures/auth/forgot-password/VerifyEmailModal.tsx create mode 100644 src/hooks/useTimeoutToggle.ts create mode 100644 test/components/structures/auth/ForgotPassword-test.tsx create mode 100644 test/test-utils/console.ts diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 5e290e1375d..67c07b5d973 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -49,6 +49,7 @@ @import "./structures/_BackdropPanel.pcss"; @import "./structures/_CompatibilityPage.pcss"; @import "./structures/_ContextualMenu.pcss"; +@import "./structures/_ErrorMessage.pcss"; @import "./structures/_FileDropTarget.pcss"; @import "./structures/_FilePanel.pcss"; @import "./structures/_GenericDropdownMenu.pcss"; @@ -157,6 +158,7 @@ @import "./views/dialogs/_UntrustedDeviceDialog.pcss"; @import "./views/dialogs/_UploadConfirmDialog.pcss"; @import "./views/dialogs/_UserSettingsDialog.pcss"; +@import "./views/dialogs/_VerifyEMailDialog.pcss"; @import "./views/dialogs/_WidgetCapabilitiesPromptDialog.pcss"; @import "./views/dialogs/security/_AccessSecretStorageDialog.pcss"; @import "./views/dialogs/security/_CreateCrossSigningDialog.pcss"; diff --git a/res/css/compound/_Icon.pcss b/res/css/compound/_Icon.pcss index a40558ccc0f..8513e7dda7d 100644 --- a/res/css/compound/_Icon.pcss +++ b/res/css/compound/_Icon.pcss @@ -30,3 +30,12 @@ limitations under the License. flex: 0 0 16px; width: 16px; } + +.mx_Icon_32 { + height: 32px; + width: 32px; +} + +.mx_Icon_accent { + color: $accent; +} diff --git a/res/css/structures/_ErrorMessage.pcss b/res/css/structures/_ErrorMessage.pcss new file mode 100644 index 00000000000..ece0d7ea25a --- /dev/null +++ b/res/css/structures/_ErrorMessage.pcss @@ -0,0 +1,25 @@ +/* +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. +*/ + +.mx_ErrorMessage { + align-items: center; + color: $alert; + display: flex; + font-size: $font-12px; + gap: $spacing-8; + line-height: 1.2em; + min-height: 2.4em; +} diff --git a/res/css/structures/auth/_Login.pcss b/res/css/structures/auth/_Login.pcss index 2638daf8769..329c2710cb1 100644 --- a/res/css/structures/auth/_Login.pcss +++ b/res/css/structures/auth/_Login.pcss @@ -17,6 +17,8 @@ limitations under the License. .mx_Login_submit { @mixin mx_DialogButton; + font-size: 15px; + font-weight: 600; width: 100%; margin-top: 24px; margin-bottom: 24px; @@ -87,7 +89,7 @@ limitations under the License. div.mx_AccessibleButton_kind_link.mx_Login_forgot { display: block; - margin: 0 auto; + margin-top: 24px; &.mx_AccessibleButton_disabled { cursor: not-allowed; diff --git a/res/css/views/auth/_AuthBody.pcss b/res/css/views/auth/_AuthBody.pcss index bd139a2eacb..f5e63b66614 100644 --- a/res/css/views/auth/_AuthBody.pcss +++ b/res/css/views/auth/_AuthBody.pcss @@ -17,12 +17,17 @@ limitations under the License. .mx_AuthBody { width: 500px; - font-size: $font-12px; - color: $authpage-secondary-color; + font-size: $font-14px; + color: $primary-content; background-color: $background; border-radius: 0 4px 4px 0; - padding: 25px 60px; + padding: 50px 32px; box-sizing: border-box; + min-height: 600px; + + b { + font-weight: 600; + } &.mx_AuthBody_flex { display: flex; @@ -32,7 +37,8 @@ limitations under the License. h1 { font-size: $font-24px; font-weight: $font-semi-bold; - margin-top: 8px; + margin-bottom: $spacing-20; + margin-top: $spacing-24; color: $authpage-primary-color; } @@ -52,6 +58,23 @@ limitations under the License. @mixin mx_Dialog_link; } + fieldset { + display: block; + } + + .mx_AuthBody_icon { + width: 40px; + } + + .mx_AuthBody_lockIcon { + height: 29px; + } + + .mx_AuthBody_text { + margin-bottom: $spacing-48; + margin-top: 0; + } + input[type="text"], input[type="password"] { color: $authpage-primary-color; @@ -76,6 +99,16 @@ limitations under the License. color: $alert; } + .mx_Login_submit { + height: 33px; + margin-top: $spacing-16; + } + + .mx_ErrorMessage { + margin-bottom: 12px; + margin-top: 2px; + } + .mx_Field input { box-sizing: border-box; } @@ -101,6 +134,43 @@ limitations under the License. } } +.mx_AuthBody_did-not-receive { + align-items: center; + 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 { + align-items: center; + border-radius: 8px; + color: $accent; + display: flex; + gap: $spacing-4; + padding: 4px; + + &:hover { + background-color: $system; + } +} + +.mx_AuthBody_emailPromptIcon { + width: 57px; +} + +.mx_AuthBody_emailPromptIcon--shifted { + margin-bottom: -17px; // Prevent layout jump by relative positioning. + position: relative; + top: -17px; // This icon is higher than the other icons. Shift up to prevent icon jumping. + width: 57px; +} + .mx_AuthBody_fieldRow { display: flex; margin-bottom: 10px; diff --git a/res/css/views/dialogs/_VerifyEMailDialog.pcss b/res/css/views/dialogs/_VerifyEMailDialog.pcss new file mode 100644 index 00000000000..78cc190022c --- /dev/null +++ b/res/css/views/dialogs/_VerifyEMailDialog.pcss @@ -0,0 +1,35 @@ +/* +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. +*/ + +.mx_VerifyEMailDialog { + .mx_Dialog { + color: $primary-content; + font-size: 14px; + padding: 16px; + text-align: center; + width: 485px; + + h1 { + font-size: $font-24px; + font-weight: 600; + } + + .mx_VerifyEMailDialog_text-light { + color: $secondary-content; + line-height: 20px; + } + } +} diff --git a/res/css/views/elements/_AccessibleButton.pcss b/res/css/views/elements/_AccessibleButton.pcss index dd3d0945e02..35b90bd7f4c 100644 --- a/res/css/views/elements/_AccessibleButton.pcss +++ b/res/css/views/elements/_AccessibleButton.pcss @@ -24,6 +24,7 @@ limitations under the License. &.mx_AccessibleButton_kind_primary_outline, &.mx_AccessibleButton_kind_primary_sm, &.mx_AccessibleButton_kind_link, + &.mx_AccessibleButton_kind_link_accent, &.mx_AccessibleButton_kind_link_inline, &.mx_AccessibleButton_kind_danger_inline, &.mx_AccessibleButton_kind_content_inline, diff --git a/res/css/views/elements/_Field.pcss b/res/css/views/elements/_Field.pcss index c1527971be9..f3d05cf285a 100644 --- a/res/css/views/elements/_Field.pcss +++ b/res/css/views/elements/_Field.pcss @@ -174,8 +174,6 @@ limitations under the License. } .mx_Field_tooltip { - margin-top: -12px; - margin-left: 4px; width: 200px; } diff --git a/res/css/views/elements/_Tooltip.pcss b/res/css/views/elements/_Tooltip.pcss index 7c49d2b59a2..d8d51cc8044 100644 --- a/res/css/views/elements/_Tooltip.pcss +++ b/res/css/views/elements/_Tooltip.pcss @@ -38,7 +38,7 @@ limitations under the License. .mx_Tooltip_chevron { position: absolute; left: -7px; - top: 10px; + top: calc(50% - 6px); width: 0; height: 0; border-top: 7px solid transparent; diff --git a/res/img/element-icons/Checkbox.svg b/res/img/element-icons/Checkbox.svg new file mode 100644 index 00000000000..f1ee8b7dc29 --- /dev/null +++ b/res/img/element-icons/Checkbox.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/Email-icon.svg b/res/img/element-icons/Email-icon.svg new file mode 100644 index 00000000000..c92b153cf07 --- /dev/null +++ b/res/img/element-icons/Email-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/lock.svg b/res/img/element-icons/lock.svg index 06fe52a3918..5ad69d9f1a9 100644 --- a/res/img/element-icons/lock.svg +++ b/res/img/element-icons/lock.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/res/img/element-icons/retry.svg b/res/img/element-icons/retry.svg index 54e9f9c5807..6e5b8651fce 100644 --- a/res/img/element-icons/retry.svg +++ b/res/img/element-icons/retry.svg @@ -1,3 +1,7 @@ - - + + diff --git a/src/PasswordReset.ts b/src/PasswordReset.ts index df812bafb2c..4ebf69e2cc1 100644 --- a/src/PasswordReset.ts +++ b/src/PasswordReset.ts @@ -19,6 +19,8 @@ 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. * @@ -29,9 +31,10 @@ import { _t } from './languageHandler'; export default class PasswordReset { private client: MatrixClient; private clientSecret: string; - private password: string; - private sessionId: string; - private logoutDevices: boolean; + private password = ""; + private sessionId = ""; + private logoutDevices = false; + private sendAttempt = 0; /** * Configure the endpoints for password resetting. @@ -61,7 +64,27 @@ export default class PasswordReset { ): Promise { this.password = newPassword; this.logoutDevices = logoutDevices; - return this.client.requestPasswordEmailToken(emailAddress, this.clientSecret, 1).then((res) => { + this.sendAttempt++; + return this.client.requestPasswordEmailToken(emailAddress, this.clientSecret, this.sendAttempt).then((res) => { + this.sessionId = res.sid; + return res; + }, function(err) { + if (err.errcode === 'M_THREEPID_NOT_FOUND') { + err.message = _t('This email address was not found'); + } else if (err.httpStatus) { + err.message = err.message + ` (Status ${err.httpStatus})`; + } + throw err; + }); + } + + /** + * Request a password reset token. + * This will trigger a side-effect of sending an email to the provided email address. + */ + public requestResetToken(emailAddress: string): Promise { + this.sendAttempt++; + return this.client.requestPasswordEmailToken(emailAddress, this.clientSecret, this.sendAttempt).then((res) => { this.sessionId = res.sid; return res; }, function(err) { @@ -74,6 +97,29 @@ export default class PasswordReset { }); } + public async setNewPassword(password: string): Promise { + this.password = password; + 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(() => { + 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. @@ -98,7 +144,7 @@ export default class PasswordReset { threepid_creds: creds, threepidCreds: creds, }, this.password, this.logoutDevices); - } catch (err) { + } catch (err: any) { if (err.httpStatus === 401) { err.message = _t('Failed to verify email address: make sure you clicked the link in the email'); } else if (err.httpStatus === 404) { diff --git a/src/components/structures/ErrorMessage.tsx b/src/components/structures/ErrorMessage.tsx new file mode 100644 index 00000000000..477ef9f5f65 --- /dev/null +++ b/src/components/structures/ErrorMessage.tsx @@ -0,0 +1,40 @@ +/* +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 { Icon as WarningBadgeIcon } from "../../../res/img/element-icons/warning-badge.svg"; + +interface ErrorMessageProps { + message: string | null; +} + +/** + * Error message component. + * Reserves two lines to display errors to prevent layout shifts when the error pops up. + */ +export const ErrorMessage: React.FC = ({ + message, +}) => { + const icon = message + ? + : null; + + return
+ { icon } + { message } +
; +}; diff --git a/src/components/structures/auth/ForgotPassword.tsx b/src/components/structures/auth/ForgotPassword.tsx index 83a8e3e5651..ba84c75ea74 100644 --- a/src/components/structures/auth/ForgotPassword.tsx +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -17,130 +17,98 @@ limitations under the License. */ import React from 'react'; -import classNames from 'classnames'; -import { logger } from "matrix-js-sdk/src/logger"; +import { logger } from 'matrix-js-sdk/src/logger'; import { createClient } from "matrix-js-sdk/src/matrix"; import { _t, _td } from '../../../languageHandler'; import Modal from "../../../Modal"; import PasswordReset from "../../../PasswordReset"; -import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; import AuthPage from "../../views/auth/AuthPage"; -import ServerPicker from "../../views/elements/ServerPicker"; -import EmailField from "../../views/auth/EmailField"; import PassphraseField from '../../views/auth/PassphraseField'; import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm'; -import InlineSpinner from '../../views/elements/InlineSpinner'; -import Spinner from "../../views/elements/Spinner"; -import QuestionDialog from "../../views/dialogs/QuestionDialog"; -import ErrorDialog from "../../views/dialogs/ErrorDialog"; import AuthHeader from "../../views/auth/AuthHeader"; import AuthBody from "../../views/auth/AuthBody"; import PassphraseConfirmField from "../../views/auth/PassphraseConfirmField"; -import AccessibleButton from '../../views/elements/AccessibleButton'; import StyledCheckbox from '../../views/elements/StyledCheckbox'; import { ValidatedServerConfig } from '../../../utils/ValidatedServerConfig'; +import { Icon as LockIcon } from "../../../../res/img/element-icons/lock.svg"; +import QuestionDialog from '../../views/dialogs/QuestionDialog'; +import { EnterEmail } from './forgot-password/EnterEmail'; +import { CheckEmail } from './forgot-password/CheckEmail'; +import Field from '../../views/elements/Field'; +import { ErrorMessage } from '../ErrorMessage'; +import { Icon as CheckboxIcon } from "../../../../res/img/element-icons/Checkbox.svg"; +import { VerifyEmailModal } from './forgot-password/VerifyEmailModal'; +import Spinner from '../../views/elements/Spinner'; +import { formatSeconds } from '../../../DateUtils'; enum Phase { - // Show the forgot password inputs - Forgot = 1, + // Show email input + EnterEmail = 1, // Email is in the process of being sent SendingEmail = 2, // Email has been sent EmailSent = 3, - // User has clicked the link in email and completed reset - Done = 4, + // Show new password input + PasswordInput = 4, + // Password is in the process of being reset + ResettingPassword = 5, + // All done + Done = 6, } -interface IProps { +interface Props { serverConfig: ValidatedServerConfig; onServerConfigChange: (serverConfig: ValidatedServerConfig) => void; onLoginClick?: () => void; onComplete: () => void; } -interface IState { +interface State { phase: Phase; email: string; password: string; password2: string; - errorText: string; - - // We perform liveliness checks later, but for now suppress the errors. - // We also track the server dead errors independently of the regular errors so - // that we can render it differently, and override any other error the user may - // be seeing. - serverIsAlive: boolean; - serverErrorIsFatal: boolean; - serverDeadError: string; - - currentHttpRequest?: Promise; + errorText: string | null; serverSupportsControlOfDevicesLogout: boolean; logoutDevices: boolean; } -enum ForgotPasswordField { - Email = 'field_email', - Password = 'field_password', - PasswordConfirm = 'field_password_confirm', -} - -export default class ForgotPassword extends React.Component { +export default class ForgotPassword extends React.Component { private reset: PasswordReset; + private fieldPassword: Field | null = null; + private fieldPasswordConfirm: Field | null = null; - state: IState = { - phase: Phase.Forgot, + state: State = { + phase: Phase.EnterEmail, email: "", password: "", password2: "", errorText: null, - - // We perform liveliness checks later, but for now suppress the errors. - // We also track the server dead errors independently of the regular errors so - // that we can render it differently, and override any other error the user may - // be seeing. - serverIsAlive: true, - serverErrorIsFatal: false, - serverDeadError: "", serverSupportsControlOfDevicesLogout: false, logoutDevices: false, }; + public constructor(props: Props) { + super(props); + this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl); + } + public componentDidMount() { - this.reset = null; - this.checkServerLiveliness(this.props.serverConfig); this.checkServerCapabilities(this.props.serverConfig); } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event // eslint-disable-next-line - public UNSAFE_componentWillReceiveProps(newProps: IProps): void { + public UNSAFE_componentWillReceiveProps(newProps: Props): void { if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; - // Do a liveliness check on the new URLs - this.checkServerLiveliness(newProps.serverConfig); - // Do capabilities check on new URLs this.checkServerCapabilities(newProps.serverConfig); } - private async checkServerLiveliness(serverConfig): Promise { - try { - await AutoDiscoveryUtils.validateServerConfigWithStaticUrls( - serverConfig.hsUrl, - serverConfig.isUrl, - ); - - this.setState({ - serverIsAlive: true, - }); - } catch (e) { - this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password") as IState); - } - } - private async checkServerCapabilities(serverConfig: ValidatedServerConfig): Promise { const tempClient = createClient({ baseUrl: serverConfig.hsUrl, @@ -154,94 +122,77 @@ export default class ForgotPassword extends React.Component { }); } - public submitPasswordReset(email: string, password: string, logoutDevices = true): void { - this.setState({ - phase: Phase.SendingEmail, - }); - this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl); - this.reset.resetPassword(email, password, logoutDevices).then(() => { - this.setState({ - phase: Phase.EmailSent, - }); - }, (err) => { - this.showErrorDialog(_t('Failed to send email') + ": " + err.message); - this.setState({ - phase: Phase.Forgot, - }); - }); - } + private async onPhaseEmailInputSubmit() { + this.phase = Phase.SendingEmail; - private onVerify = async (ev: React.MouseEvent): Promise => { - ev.preventDefault(); - if (!this.reset) { - logger.error("onVerify called before submitPasswordReset!"); + if (await this.sendVerificationMail()) { + this.phase = Phase.EmailSent; return; } - if (this.state.currentHttpRequest) return; + this.phase = Phase.EnterEmail; + } + + private sendVerificationMail = async (): Promise => { try { - await this.handleHttpRequest(this.reset.checkEmailLinkClicked()); - this.setState({ phase: Phase.Done }); - } catch (err) { - this.showErrorDialog(err.message); + await this.reset.requestResetToken(this.state.email); + return true; + } catch (err: any) { + this.handleError(err); } + + return false; }; - private onSubmitForm = async (ev: React.FormEvent): Promise => { - ev.preventDefault(); - if (this.state.currentHttpRequest) return; + private handleError(err: any): void { + if (err?.httpStatus === 429) { + // 429: rate limit + const retryAfterMs = parseInt(err?.data?.retry_after_ms, 10); - // refresh the server errors, just in case the server came back online - await this.handleHttpRequest(this.checkServerLiveliness(this.props.serverConfig)); + const errorText = isNaN(retryAfterMs) + ? _t("Too many attempts in a short time. Wait some time before trying again.") + : _t( + "Too many attempts in a short time. Retry after %(timeout)s.", + { + timeout: formatSeconds(retryAfterMs / 1000), + }, + ); - const allFieldsValid = await this.verifyFieldsBeforeSubmit(); - if (!allFieldsValid) { + this.setState({ + errorText, + }); return; - } - - if (this.state.logoutDevices) { - 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'), + } else { + this.setState({ + errorText: err.message, }); - const [confirmed] = await finished; - - if (!confirmed) return; } + } - this.submitPasswordReset(this.state.email, this.state.password, this.state.logoutDevices); - }; + private async onPhaseEmailSentSubmit() { + this.setState({ + phase: Phase.PasswordInput, + }); + } + + private set phase(phase: Phase) { + this.setState({ phase }); + } - private async verifyFieldsBeforeSubmit() { + private async verifyFieldsBeforeSubmit(): Promise { const fieldIdsInDisplayOrder = [ - ForgotPasswordField.Email, - ForgotPasswordField.Password, - ForgotPasswordField.PasswordConfirm, + this.fieldPassword, + this.fieldPasswordConfirm, ]; - const invalidFields = []; - for (const fieldId of fieldIdsInDisplayOrder) { - const valid = await this[fieldId].validate({ allowEmpty: false }); + const invalidFields: Field[] = []; + + for (const field of fieldIdsInDisplayOrder) { + if (!field) continue; + + const valid = await field.validate({ allowEmpty: false }); if (!valid) { - invalidFields.push(this[fieldId]); + invalidFields.push(field); } } @@ -257,6 +208,66 @@ export default class ForgotPassword extends React.Component { return false; } + private async onPhasePasswordInputSubmit(): Promise { + if (!await this.verifyFieldsBeforeSubmit()) return; + + if (this.state.logoutDevices) { + const logoutDevicesConfirmation = await this.renderConfirmLogoutDevicesDialog(); + if (!logoutDevicesConfirmation) return; + } + + this.phase = Phase.ResettingPassword; + + try { + await this.reset.setNewPassword(this.state.password); + } catch (err: any) { + if (err.httpStatus !== 401) { + // 401 = waiting for email verification, else unknown error + this.handleError(err); + return; + } + } + + const modal = Modal.createDialog( + VerifyEmailModal, + { + email: this.state.email, + errorText: this.state.errorText, + onResendClick: this.sendVerificationMail, + }, + "mx_VerifyEMailDialog", + false, + false, + { + // this modal cannot be dismissed + onBeforeClose: async () => this.phase === Phase.Done, + }, + ); + + await this.reset.retrySetNewPassword(this.state.password); + this.phase = Phase.Done; + modal.close(); + } + + private onSubmitForm = (ev: React.FormEvent): void => { + ev.preventDefault(); + this.setState({ + errorText: "", + }); + + switch (this.state.phase) { + case Phase.EnterEmail: + this.onPhaseEmailInputSubmit(); + break; + case Phase.EmailSent: + this.onPhaseEmailSentSubmit(); + break; + case Phase.PasswordInput: + this.onPhasePasswordInputSubmit(); + break; + } + }; + private onInputChanged = (stateKey: string, ev: React.FormEvent) => { let value = ev.currentTarget.value; if (stateKey === "email") value = value.trim(); @@ -265,139 +276,108 @@ export default class ForgotPassword extends React.Component { } as any); }; - private onLoginClick = (ev: React.MouseEvent): void => { - ev.preventDefault(); - ev.stopPropagation(); - this.props.onLoginClick(); - }; - - public showErrorDialog(description: string, title?: string) { - Modal.createDialog(ErrorDialog, { - title, - description, - }); + renderEnterEmail(): JSX.Element { + return ; } - private handleHttpRequest(request: Promise): Promise { - this.setState({ - currentHttpRequest: request, - }); - return request.finally(() => { - this.setState({ - currentHttpRequest: undefined, - }); + async renderConfirmLogoutDevicesDialog(): Promise { + 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[ForgotPasswordField.Email] = field} - autoFocus={true} - onChange={this.onInputChanged.bind(this, "email")} - /> -
-
- this[ForgotPasswordField.Password] = field} - onChange={this.onInputChanged.bind(this, "password")} - autoComplete="new-password" - /> - this[ForgotPasswordField.PasswordConfirm] = field} - onChange={this.onInputChanged.bind(this, "password2")} - autoComplete="new-password" - /> -
- { this.state.serverSupportsControlOfDevicesLogout ? +
- this.setState({ logoutDevices: !this.state.logoutDevices })} checked={this.state.logoutDevices}> - { _t("Sign out all devices") } - -
: null - } - { _t( - 'A verification email will be sent to your inbox to confirm ' + - 'setting your new password.', - ) } - + this.fieldPassword = field} + onChange={this.onInputChanged.bind(this, "password")} + autoComplete="new-password" + /> + this.fieldPasswordConfirm = field} + onChange={this.onInputChanged.bind(this, "password2")} + autoComplete="new-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 } }, + ) + } +

+
+
+
+ ) => onInputChanged("email", event)} + fieldRef={emailFieldRef} + /> +
+ { errorText && } + +
+
+ ; +}; 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); From 8e62d4faa0f5ee03bf02c7d9f4b81fd7efd6b001 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Fri, 18 Nov 2022 10:47:28 +0100 Subject: [PATCH 02/11] Revert unrelated changes --- res/css/views/elements/_Field.pcss | 2 + res/css/views/elements/_Tooltip.pcss | 2 +- .../views/dialogs/QuestionDialog.tsx | 6 +-- .../structures/auth/ForgotPassword-test.tsx | 8 ---- test/test-utils/console.ts | 39 ------------------- 5 files changed, 5 insertions(+), 52 deletions(-) delete mode 100644 test/test-utils/console.ts diff --git a/res/css/views/elements/_Field.pcss b/res/css/views/elements/_Field.pcss index f3d05cf285a..c1527971be9 100644 --- a/res/css/views/elements/_Field.pcss +++ b/res/css/views/elements/_Field.pcss @@ -174,6 +174,8 @@ limitations under the License. } .mx_Field_tooltip { + margin-top: -12px; + margin-left: 4px; width: 200px; } diff --git a/res/css/views/elements/_Tooltip.pcss b/res/css/views/elements/_Tooltip.pcss index d8d51cc8044..7c49d2b59a2 100644 --- a/res/css/views/elements/_Tooltip.pcss +++ b/res/css/views/elements/_Tooltip.pcss @@ -38,7 +38,7 @@ limitations under the License. .mx_Tooltip_chevron { position: absolute; left: -7px; - top: calc(50% - 6px); + top: 10px; width: 0; height: 0; border-top: 7px solid transparent; diff --git a/src/components/views/dialogs/QuestionDialog.tsx b/src/components/views/dialogs/QuestionDialog.tsx index bc91d0a0f9c..b1e913dc8ea 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; } -const QuestionDialog: React.ComponentClass = class extends React.Component { +export default class QuestionDialog extends React.Component { public static defaultProps: Partial = { title: "", description: "", @@ -90,6 +90,4 @@ const QuestionDialog: React.ComponentClass = class extends ); } -}; - -export default QuestionDialog; +} diff --git a/test/components/structures/auth/ForgotPassword-test.tsx b/test/components/structures/auth/ForgotPassword-test.tsx index f79c4f1ff5f..db4911c58f5 100644 --- a/test/components/structures/auth/ForgotPassword-test.tsx +++ b/test/components/structures/auth/ForgotPassword-test.tsx @@ -23,7 +23,6 @@ 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"), @@ -38,7 +37,6 @@ describe("", () => { 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 () => { @@ -55,11 +53,6 @@ describe("", () => { }; 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); @@ -70,7 +63,6 @@ describe("", () => { afterAll(() => { jest.useRealTimers(); - restoreConsole(); }); describe("when starting a password reset flow", () => { diff --git a/test/test-utils/console.ts b/test/test-utils/console.ts deleted file mode 100644 index b695df6c00e..00000000000 --- a/test/test-utils/console.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* -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; - }; -}; From 062f4197dbc6e04575509df5ee2a4ed7e15543c9 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Fri, 18 Nov 2022 11:01:51 +0100 Subject: [PATCH 03/11] Replace then with await --- src/PasswordReset.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/PasswordReset.ts b/src/PasswordReset.ts index 4ebf69e2cc1..8f3c9bd91ec 100644 --- a/src/PasswordReset.ts +++ b/src/PasswordReset.ts @@ -57,7 +57,7 @@ export default class PasswordReset { * @param {boolean} logoutDevices Should all devices be signed out after the reset? Defaults to `true`. * @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked(). */ - public resetPassword( + public async resetPassword( emailAddress: string, newPassword: string, logoutDevices = true, @@ -65,17 +65,23 @@ export default class PasswordReset { this.password = newPassword; this.logoutDevices = logoutDevices; this.sendAttempt++; - return this.client.requestPasswordEmailToken(emailAddress, this.clientSecret, this.sendAttempt).then((res) => { - this.sessionId = res.sid; - return res; - }, function(err) { + + try { + const result = await this.client.requestPasswordEmailToken( + emailAddress, + this.clientSecret, + this.sendAttempt, + ); + this.sessionId = result.sid; + return result; + } catch (err: any) { if (err.errcode === 'M_THREEPID_NOT_FOUND') { err.message = _t('This email address was not found'); } else if (err.httpStatus) { err.message = err.message + ` (Status ${err.httpStatus})`; } throw err; - }); + } } /** From 94d774cdd450ae90b598db6780f55e0f6bdd4146 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Fri, 18 Nov 2022 11:07:37 +0100 Subject: [PATCH 04/11] Set initial state in ctor --- .../structures/auth/ForgotPassword.tsx | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/components/structures/auth/ForgotPassword.tsx b/src/components/structures/auth/ForgotPassword.tsx index ba84c75ea74..ae0dfe6dca2 100644 --- a/src/components/structures/auth/ForgotPassword.tsx +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -80,18 +80,17 @@ export default class ForgotPassword extends React.Component { private fieldPassword: Field | null = null; private fieldPasswordConfirm: Field | null = null; - state: State = { - phase: Phase.EnterEmail, - email: "", - password: "", - password2: "", - errorText: null, - serverSupportsControlOfDevicesLogout: false, - logoutDevices: false, - }; - public constructor(props: Props) { super(props); + this.state = { + phase: Phase.EnterEmail, + email: "", + password: "", + password2: "", + errorText: null, + serverSupportsControlOfDevicesLogout: false, + logoutDevices: false, + }; this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl); } From ef0c5f6f35e640c564f38b5f1b5749e800fcd0a6 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Fri, 18 Nov 2022 11:15:40 +0100 Subject: [PATCH 05/11] Correctly display homeserver name --- src/components/structures/auth/ForgotPassword.tsx | 1 + src/components/structures/auth/forgot-password/EnterEmail.tsx | 4 +++- test/components/structures/auth/ForgotPassword-test.tsx | 4 +++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/structures/auth/ForgotPassword.tsx b/src/components/structures/auth/ForgotPassword.tsx index ae0dfe6dca2..83075d11e57 100644 --- a/src/components/structures/auth/ForgotPassword.tsx +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -279,6 +279,7 @@ export default class ForgotPassword extends React.Component { return ) => void; onSubmitForm: (ev: React.FormEvent) => void; @@ -37,6 +38,7 @@ interface EnterEmailProps { export const EnterEmail: React.FC = ({ email, errorText, + homeserver, loading, onInputChanged, onSubmitForm, @@ -64,7 +66,7 @@ export const EnterEmail: React.FC = ({ { _t( "%(homeserver)s will send you a verification link to let you reset your password.", - { homeserver: "matrix.org" }, + { homeserver }, { b: t => { t } }, ) } diff --git a/test/components/structures/auth/ForgotPassword-test.tsx b/test/components/structures/auth/ForgotPassword-test.tsx index db4911c58f5..5ba88dc3dcb 100644 --- a/test/components/structures/auth/ForgotPassword-test.tsx +++ b/test/components/structures/auth/ForgotPassword-test.tsx @@ -55,6 +55,7 @@ describe("", () => { beforeAll(() => { client = stubClient(); serverConfig = new ValidatedServerConfig(); + serverConfig.hsName = "example.com"; mocked(createClient).mockReturnValue(client); jest.useFakeTimers(); onServerConfigChange = jest.fn(); @@ -74,8 +75,9 @@ describe("", () => { />); }); - it("should show the email input", () => { + it("should show the email input and mention the homeserver", () => { expect(screen.getByLabelText("Email address")).toBeInTheDocument(); + expect(screen.getByText("example.com")).toBeInTheDocument(); }); describe("when entering a non-email value", () => { From 622a1e930d8943848603e36f824882edd96cfa4d Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Fri, 18 Nov 2022 11:56:19 +0100 Subject: [PATCH 06/11] Add connection error handling --- .../structures/auth/ForgotPassword.tsx | 19 +++++++++--- .../structures/auth/ForgotPassword-test.tsx | 30 +++++++++++++++++-- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/components/structures/auth/ForgotPassword.tsx b/src/components/structures/auth/ForgotPassword.tsx index 83075d11e57..e76531b32d4 100644 --- a/src/components/structures/auth/ForgotPassword.tsx +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -161,11 +161,19 @@ export default class ForgotPassword extends React.Component { errorText, }); return; - } else { + } + + if (err?.name === "ConnectionError") { this.setState({ - errorText: err.message, + errorText: _t("Cannot reach homeserver") + ": " + + _t("Ensure you have a stable internet connection, or get in touch with the server admin"), }); + return; } + + this.setState({ + errorText: err.message, + }); } private async onPhaseEmailSentSubmit() { @@ -238,8 +246,11 @@ export default class ForgotPassword extends React.Component { false, false, { - // this modal cannot be dismissed - onBeforeClose: async () => this.phase === Phase.Done, + // this modal cannot be dismissed until reset is done + onBeforeClose: async () => { + console.log("miw phase", this.state.phase); + return this.state.phase === Phase.Done; + }, }, ); diff --git a/test/components/structures/auth/ForgotPassword-test.tsx b/test/components/structures/auth/ForgotPassword-test.tsx index 5ba88dc3dcb..d06e9634b0f 100644 --- a/test/components/structures/auth/ForgotPassword-test.tsx +++ b/test/components/structures/auth/ForgotPassword-test.tsx @@ -104,6 +104,23 @@ describe("", () => { }); }); + describe("when the homeserver is not reachable", () => { + beforeEach(async () => { + await typeIntoField("Email address", testEmail); + mocked(client).requestPasswordEmailToken.mockRejectedValue({ + name: "ConnectionError", + }); + await submitForm("Send email"); + }); + + it("should show an info about that", () => { + expect(screen.getByText( + "Cannot reach homeserver: " + + "Ensure you have a stable internet connection, or get in touch with the server admin", + )).toBeInTheDocument(); + }); + }); + describe("when submitting an known email", () => { beforeEach(async () => { await typeIntoField("Email address", testEmail); @@ -218,11 +235,18 @@ describe("", () => { beforeEach(async () => { mocked(client.setPassword).mockResolvedValue({}); // be sure the next set password attempt was sent - jest.advanceTimersByTime(5000); + jest.advanceTimersByTime(3000); + // for the modal to disappear + await flushPromisesWithFakeTimers(); + await flushPromisesWithFakeTimers(); + jest.advanceTimersByTime(500); + await flushPromisesWithFakeTimers(); + await flushPromisesWithFakeTimers(); }); - it("should display the confirm reset view", () => { - expect(screen.getByText("Your password has been reset.")).toBeInTheDocument(); + it("should display the confirm reset view and now show the dialog", () => { + expect(screen.queryByText("Your password has been reset.")).toBeInTheDocument(); + expect(screen.queryByText("Verify your email to continue")).not.toBeInTheDocument(); }); }); }); From e8fe8d3e8f4cdfaa716679f3109a31d02eac4c99 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Fri, 18 Nov 2022 12:31:20 +0100 Subject: [PATCH 07/11] Fix tests --- .../structures/auth/ForgotPassword.tsx | 6 ++++-- .../structures/auth/ForgotPassword-test.tsx | 16 +++++++++++----- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/components/structures/auth/ForgotPassword.tsx b/src/components/structures/auth/ForgotPassword.tsx index 985d4711e97..077dd6dc113 100644 --- a/src/components/structures/auth/ForgotPassword.tsx +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -244,8 +244,10 @@ export default class ForgotPassword extends React.Component { false, false, { - // this modal cannot be dismissed until reset is done - onBeforeClose: async () => this.state.phase === Phase.Done, + // this modal cannot be dismissed except reset is done or forced + onBeforeClose: async (reason?: string) => { + return this.state.phase === Phase.Done || reason === "force"; + }, }, ); diff --git a/test/components/structures/auth/ForgotPassword-test.tsx b/test/components/structures/auth/ForgotPassword-test.tsx index 7be3f25bd98..d39d555a11f 100644 --- a/test/components/structures/auth/ForgotPassword-test.tsx +++ b/test/components/structures/auth/ForgotPassword-test.tsx @@ -16,13 +16,14 @@ limitations under the License. import React from "react"; import { mocked } from "jest-mock"; -import { act, render, screen } from "@testing-library/react"; +import { act, render, RenderResult, 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 Modal from "../../../../src/Modal"; jest.mock("matrix-js-sdk/src/matrix", () => ({ ...jest.requireActual("matrix-js-sdk/src/matrix"), @@ -36,6 +37,7 @@ describe("", () => { let client: MatrixClient; let serverConfig: ValidatedServerConfig; let onComplete: () => void; + let renderResult: RenderResult; const typeIntoField = async (label: string, value: string): Promise => { await act(async () => { @@ -51,6 +53,11 @@ describe("", () => { }); }; + afterEach(() => { + // clean up modals + Modal.closeCurrentModal("force"); + }); + beforeAll(() => { client = stubClient(); serverConfig = new ValidatedServerConfig(); @@ -66,7 +73,7 @@ describe("", () => { describe("when starting a password reset flow", () => { beforeEach(() => { - render(); @@ -80,7 +87,7 @@ describe("", () => { describe("and updating the server config", () => { beforeEach(() => { serverConfig.hsName = "example2.com"; - render(); @@ -247,10 +254,9 @@ describe("", () => { mocked(client.setPassword).mockResolvedValue({}); // be sure the next set password attempt was sent jest.advanceTimersByTime(3000); - // for the modal to disappear + // quad flush promises for the modal to disappear await flushPromisesWithFakeTimers(); await flushPromisesWithFakeTimers(); - jest.advanceTimersByTime(500); await flushPromisesWithFakeTimers(); await flushPromisesWithFakeTimers(); }); From 79815a0baaaf8211a4a489afdbb89290a9c62ebd Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Fri, 18 Nov 2022 13:54:07 +0100 Subject: [PATCH 08/11] Bring back server liveness checks --- src/components/structures/ErrorMessage.tsx | 4 +- .../structures/auth/ForgotPassword.tsx | 55 ++++++++++++++++++- .../auth/forgot-password/CheckEmail.tsx | 4 +- .../auth/forgot-password/EnterEmail.tsx | 4 +- .../structures/auth/ForgotPassword-test.tsx | 38 +++++++++++-- 5 files changed, 90 insertions(+), 15 deletions(-) diff --git a/src/components/structures/ErrorMessage.tsx b/src/components/structures/ErrorMessage.tsx index 477ef9f5f65..9532eab30f3 100644 --- a/src/components/structures/ErrorMessage.tsx +++ b/src/components/structures/ErrorMessage.tsx @@ -14,12 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import { Icon as WarningBadgeIcon } from "../../../res/img/element-icons/warning-badge.svg"; interface ErrorMessageProps { - message: string | null; + message: string | ReactNode | null; } /** diff --git a/src/components/structures/auth/ForgotPassword.tsx b/src/components/structures/auth/ForgotPassword.tsx index 077dd6dc113..6d9103b2110 100644 --- a/src/components/structures/auth/ForgotPassword.tsx +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -16,7 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import { logger } from 'matrix-js-sdk/src/logger'; import { createClient } from "matrix-js-sdk/src/matrix"; @@ -41,6 +41,7 @@ import { Icon as CheckboxIcon } from "../../../../res/img/element-icons/Checkbox import { VerifyEmailModal } from './forgot-password/VerifyEmailModal'; import Spinner from '../../views/elements/Spinner'; import { formatSeconds } from '../../../DateUtils'; +import AutoDiscoveryUtils from '../../../utils/AutoDiscoveryUtils'; enum Phase { // Show email input @@ -68,7 +69,14 @@ interface State { email: string; password: string; password2: string; - errorText: string | null; + errorText: string | ReactNode | null; + + // We perform liveliness checks later, but for now suppress the errors. + // We also track the server dead errors independently of the regular errors so + // that we can render it differently, and override any other error the user may + // be seeing. + serverIsAlive: boolean; + serverDeadError: string; serverSupportsControlOfDevicesLogout: boolean; logoutDevices: boolean; @@ -87,6 +95,12 @@ export default class ForgotPassword extends React.Component { password: "", password2: "", errorText: null, + // We perform liveliness checks later, but for now suppress the errors. + // We also track the server dead errors independently of the regular errors so + // that we can render it differently, and override any other error the user may + // be seeing. + serverIsAlive: true, + serverDeadError: "", serverSupportsControlOfDevicesLogout: false, logoutDevices: false, }; @@ -101,11 +115,36 @@ export default class ForgotPassword extends React.Component { if (prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl || prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl ) { + // Do a liveliness check on the new URLs + this.checkServerLiveliness(this.props.serverConfig); + // Do capabilities check on new URLs this.checkServerCapabilities(this.props.serverConfig); } } + private async checkServerLiveliness(serverConfig: ValidatedServerConfig): Promise { + try { + await AutoDiscoveryUtils.validateServerConfigWithStaticUrls( + serverConfig.hsUrl, + serverConfig.isUrl, + ); + + this.setState({ + serverIsAlive: true, + }); + } catch (e) { + const { + serverIsAlive, + serverDeadError, + } = AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password"); + this.setState({ + serverIsAlive, + errorText: serverDeadError, + }); + } + } + private async checkServerCapabilities(serverConfig: ValidatedServerConfig): Promise { const tempClient = createClient({ baseUrl: serverConfig.hsUrl, @@ -256,12 +295,22 @@ export default class ForgotPassword extends React.Component { modal.close(); } - private onSubmitForm = (ev: React.FormEvent): void => { + private onSubmitForm = async (ev: React.FormEvent): Promise => { ev.preventDefault(); + + // Should not happen because of disabled forms, but just return if currently doing an action. + if ([Phase.SendingEmail, Phase.ResettingPassword].includes(this.state.phase)) return; + this.setState({ errorText: "", }); + // Refresh the server errors. Just in case the server came back online of went offline. + await this.checkServerLiveliness(this.props.serverConfig); + + // Server error + if (!this.state.serverIsAlive) return; + switch (this.state.phase) { case Phase.EnterEmail: this.onPhaseEmailInputSubmit(); diff --git a/src/components/structures/auth/forgot-password/CheckEmail.tsx b/src/components/structures/auth/forgot-password/CheckEmail.tsx index ee4e6081848..27fa82f25e1 100644 --- a/src/components/structures/auth/forgot-password/CheckEmail.tsx +++ b/src/components/structures/auth/forgot-password/CheckEmail.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { ReactNode } from "react"; import AccessibleButton from "../../../views/elements/AccessibleButton"; import { Icon as EMailPromptIcon } from "../../../../../res/img/element-icons/email-prompt.svg"; @@ -26,7 +26,7 @@ import { ErrorMessage } from "../../ErrorMessage"; interface CheckEmailProps { email: string; - errorText: string | null; + errorText: string | ReactNode | null; onResendClick: () => Promise; onSubmitForm: (ev: React.FormEvent) => void; } diff --git a/src/components/structures/auth/forgot-password/EnterEmail.tsx b/src/components/structures/auth/forgot-password/EnterEmail.tsx index 177fc5ed0bc..6221bb65dfd 100644 --- a/src/components/structures/auth/forgot-password/EnterEmail.tsx +++ b/src/components/structures/auth/forgot-password/EnterEmail.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useRef } from "react"; +import React, { ReactNode, useRef } from "react"; import { Icon as EMailIcon } from "../../../../../res/img/element-icons/Email-icon.svg"; import { _t, _td } from '../../../../languageHandler'; @@ -25,7 +25,7 @@ import Field from "../../../views/elements/Field"; interface EnterEmailProps { email: string; - errorText: string | null; + errorText: string | ReactNode | null; homeserver: string; loading: boolean; onInputChanged: (stateKey: string, ev: React.FormEvent) => void; diff --git a/test/components/structures/auth/ForgotPassword-test.tsx b/test/components/structures/auth/ForgotPassword-test.tsx index d39d555a11f..cb379153c42 100644 --- a/test/components/structures/auth/ForgotPassword-test.tsx +++ b/test/components/structures/auth/ForgotPassword-test.tsx @@ -24,6 +24,7 @@ import ForgotPassword from "../../../../src/components/structures/auth/ForgotPas import { ValidatedServerConfig } from "../../../../src/utils/ValidatedServerConfig"; import { flushPromisesWithFakeTimers, stubClient } from "../../../test-utils"; import Modal from "../../../../src/Modal"; +import AutoDiscoveryUtils from "../../../../src/utils/AutoDiscoveryUtils"; jest.mock("matrix-js-sdk/src/matrix", () => ({ ...jest.requireActual("matrix-js-sdk/src/matrix"), @@ -53,18 +54,26 @@ describe("", () => { }); }; + beforeEach(() => { + client = stubClient(); + mocked(createClient).mockReturnValue(client); + + serverConfig = new ValidatedServerConfig(); + serverConfig.hsName = "example.com"; + + onComplete = jest.fn(); + + jest.spyOn(AutoDiscoveryUtils, "validateServerConfigWithStaticUrls").mockResolvedValue(serverConfig); + jest.spyOn(AutoDiscoveryUtils, "authComponentStateForError"); + }); + afterEach(() => { // clean up modals Modal.closeCurrentModal("force"); }); beforeAll(() => { - client = stubClient(); - serverConfig = new ValidatedServerConfig(); - serverConfig.hsName = "example.com"; - mocked(createClient).mockReturnValue(client); jest.useFakeTimers(); - onComplete = jest.fn(); }); afterAll(() => { @@ -122,7 +131,7 @@ describe("", () => { }); }); - describe("when the homeserver is not reachable", () => { + describe("when a connection error occurs", () => { beforeEach(async () => { await typeIntoField("Email address", testEmail); mocked(client).requestPasswordEmailToken.mockRejectedValue({ @@ -139,6 +148,23 @@ describe("", () => { }); }); + describe("when the server liveness check fails", () => { + beforeEach(async () => { + await typeIntoField("Email address", testEmail); + mocked(AutoDiscoveryUtils.validateServerConfigWithStaticUrls).mockRejectedValue({}); + mocked(AutoDiscoveryUtils.authComponentStateForError).mockReturnValue({ + serverErrorIsFatal: true, + serverIsAlive: false, + serverDeadError: "server down", + }); + await submitForm("Send email"); + }); + + it("should show the server error", () => { + expect(screen.queryByText("server down")).toBeInTheDocument(); + }); + }); + describe("when submitting an known email", () => { beforeEach(async () => { await typeIntoField("Email address", testEmail); From 39a2f7d89326f87a0ef608fd6adf37a146c9fe98 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Fri, 18 Nov 2022 14:17:14 +0100 Subject: [PATCH 09/11] Remove onServerConfigChange for pass reset in MatrixChat --- src/components/structures/MatrixChat.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index c64bc2b843c..59c9ec32b83 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -2086,7 +2086,6 @@ export default class MatrixChat extends React.PureComponent { ); From 7eb0b0b18abe3ae370bb5a5da9b3dd0517936321 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 21 Nov 2022 08:52:43 +0100 Subject: [PATCH 10/11] =?UTF-8?q?EMail=20=E2=86=92=20Email?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/structures/auth/forgot-password/EnterEmail.tsx | 4 ++-- .../structures/auth/forgot-password/VerifyEmailModal.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/structures/auth/forgot-password/EnterEmail.tsx b/src/components/structures/auth/forgot-password/EnterEmail.tsx index 6221bb65dfd..a630291ae26 100644 --- a/src/components/structures/auth/forgot-password/EnterEmail.tsx +++ b/src/components/structures/auth/forgot-password/EnterEmail.tsx @@ -16,7 +16,7 @@ limitations under the License. import React, { ReactNode, useRef } from "react"; -import { Icon as EMailIcon } from "../../../../../res/img/element-icons/Email-icon.svg"; +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"; @@ -60,7 +60,7 @@ export const EnterEmail: React.FC = ({ }; return <> - +

{ _t("Enter your email to reset password") }

{ diff --git a/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx b/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx index c18f4caded4..d63e4c97d79 100644 --- a/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx +++ b/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx @@ -19,7 +19,7 @@ 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 { 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"; @@ -43,7 +43,7 @@ export const VerifyEmailModal: React.FC = ({ }; return <> - +

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

{ _t( From 25ba3fc46639cf390e35ed048c876e8fdeae7c3c Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Mon, 21 Nov 2022 08:55:20 +0100 Subject: [PATCH 11/11] Fix strict type checks --- src/components/structures/auth/ForgotPassword.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/structures/auth/ForgotPassword.tsx b/src/components/structures/auth/ForgotPassword.tsx index 6d9103b2110..8171825fab3 100644 --- a/src/components/structures/auth/ForgotPassword.tsx +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -133,7 +133,7 @@ export default class ForgotPassword extends React.Component { this.setState({ serverIsAlive: true, }); - } catch (e) { + } catch (e: any) { const { serverIsAlive, serverDeadError,