From 3237b6fb041cc6b61c9883278c1ba3aa44398936 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 14 Sep 2022 08:30:21 +0200 Subject: [PATCH] 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 | 51 ++- src/components/structures/ErrorMessage.tsx | 40 ++ .../structures/auth/ForgotPassword.tsx | 426 ++++++++---------- .../auth/forgot-password/CheckEmail.tsx | 89 ++++ .../auth/forgot-password/EnterEmail.tsx | 96 ++++ .../auth/forgot-password/VerifyEmailModal.tsx | 75 +++ .../views/dialogs/QuestionDialog.tsx | 6 +- src/components/views/elements/Field.tsx | 2 +- src/i18n/strings/en_EN.json | 24 +- tsconfig.json | 1 + 23 files changed, 716 insertions(+), 270 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 diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 5e290e1375d3..67c07b5d9732 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 a40558ccc0fc..8513e7dda7d3 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 000000000000..ece0d7ea25ae --- /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 2638daf87695..d48e47d6b77a 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-bottom: $spacing-16; &.mx_AccessibleButton_disabled { cursor: not-allowed; diff --git a/res/css/views/auth/_AuthBody.pcss b/res/css/views/auth/_AuthBody.pcss index bd139a2eacbe..f5e63b666148 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 000000000000..78cc190022c4 --- /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 dd3d0945e023..35b90bd7f4c2 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 c1527971be9c..f3d05cf285a7 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 7c49d2b59a26..d8d51cc80446 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 000000000000..f1ee8b7dc291 --- /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 000000000000..c92b153cf07d --- /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 06fe52a39189..5ad69d9f1a94 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 54e9f9c5807d..6e5b8651fcea 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 df812bafb2ca..265d7541bc60 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,9 @@ 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; /** * Configure the endpoints for password resetting. @@ -74,6 +76,47 @@ export default class PasswordReset { }); } + /** + * 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 { + return this.client.requestPasswordEmailToken(emailAddress, this.clientSecret, 1).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; + }); + } + + 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 +141,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 000000000000..477ef9f5f656 --- /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 83a8e3e56519..b23e1c7b9746 100644 --- a/src/components/structures/auth/ForgotPassword.tsx +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -17,130 +17,96 @@ 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'; 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 +120,48 @@ 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; + await this.sendVerificationMail(); + this.phase = Phase.EmailSent; } - private onVerify = async (ev: React.MouseEvent): Promise => { - ev.preventDefault(); - if (!this.reset) { - logger.error("onVerify called before submitPasswordReset!"); - return; - } - if (this.state.currentHttpRequest) return; - + private sendVerificationMail = async (): Promise => { try { - await this.handleHttpRequest(this.reset.checkEmailLinkClicked()); - this.setState({ phase: Phase.Done }); - } catch (err) { - this.showErrorDialog(err.message); - } - }; - - private onSubmitForm = async (ev: React.FormEvent): Promise => { - ev.preventDefault(); - if (this.state.currentHttpRequest) return; - - // refresh the server errors, just in case the server came back online - await this.handleHttpRequest(this.checkServerLiveliness(this.props.serverConfig)); - - const allFieldsValid = await this.verifyFieldsBeforeSubmit(); - if (!allFieldsValid) { + await this.reset.requestResetToken(this.state.email); + } catch (err: any) { + this.setState({ + errorText: err.message, + phase: Phase.EnterEmail, + }); 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'), - }); - const [confirmed] = await finished; - - if (!confirmed) return; - } + private async onPhaseEmailSentSubmit() { + this.setState({ + phase: Phase.PasswordInput, + }); + } - this.submitPasswordReset(this.state.email, this.state.password, this.state.logoutDevices); - }; + 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 +177,62 @@ 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; + } + + try { + await this.reset.setNewPassword(this.state.password); + } catch (err: any) { + if (err.httpStatus !== 401) { + // 401 = waiting for email verification, else unknown error + this.setState({ + errorText: err.message, + }); + return; + } + } + + const modal = Modal.createDialog( + () => , + {}, + "mx_VerifyEMailDialog", + ); + + await this.reset.retrySetNewPassword(this.state.password); + modal.close(); + + this.setState({ + phase: Phase.Done, + }); + } + + 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,89 +241,75 @@ 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 }
; - } - - 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 } -
- ); - } + renderCheckEmail(): JSX.Element { + return ; + } + renderSetPassword(): JSX.Element { return
- { errorText } - { serverDeadSection } - + +

{ _t("Reset your password") }

-
- this[ForgotPasswordField.Email] = field} - autoFocus={true} - onChange={this.onInputChanged.bind(this, "email")} - /> -
this[ForgotPasswordField.Password] = field} + fieldRef={field => this.fieldPassword = field} onChange={this.onInputChanged.bind(this, "password")} autoComplete="new-password" /> this[ForgotPasswordField.PasswordConfirm] = field} + fieldRef={field => this.fieldPasswordConfirm = field} onChange={this.onInputChanged.bind(this, "password2")} autoComplete="new-password" /> @@ -355,49 +317,24 @@ export default class ForgotPassword extends React.Component { { this.state.serverSupportsControlOfDevicesLogout ?
this.setState({ logoutDevices: !this.state.logoutDevices })} checked={this.state.logoutDevices}> - { _t("Sign out all devices") } + { _t("Sign out of all devices") }
: null } - { _t( - 'A verification email will be sent to your inbox to confirm ' + - 'setting your new password.', - ) } + { 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 " + @@ -415,29 +352,38 @@ export default class ForgotPassword extends React.Component { } 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: + resetPasswordJsx = this.renderSetPassword(); + break; + case Phase.ResettingPassword: + resetPasswordJsx =

resetting password
; 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.error(`unknown forgot passwort 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 000000000000..5c67bfcb2315 --- /dev/null +++ b/src/components/structures/auth/forgot-password/CheckEmail.tsx @@ -0,0 +1,89 @@ +/* +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, { useEffect, useRef, useState } 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"; + +interface CheckEmailProps { + email: string; + 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, + onSubmitForm, + onResendClick, +}) => { + const resentTooltipVisibleTimerId = useRef(null); + const [resentTooltipVisible, setResentTooltipVisible] = useState(false); + + const onResendClickFn = async (): Promise => { + await onResendClick(); + setResentTooltipVisible(true); + resentTooltipVisibleTimerId.current = setTimeout(() => setResentTooltipVisible(false), 2500); + }; + + useEffect(() => { + return () => { + if (resentTooltipVisibleTimerId.current) { + clearTimeout(resentTooltipVisibleTimerId.current); + } + }; + }); + + 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") } + + +
+ +
; +}; 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 000000000000..6eef4275f00c --- /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; + errorMessage: 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, + errorMessage, + 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} + /> +
+ { errorMessage && } + +
+
+
; +}; 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 000000000000..0f3a9fca8b8e --- /dev/null +++ b/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx @@ -0,0 +1,75 @@ +/* +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, useState } 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"; + +interface Props { + email: string; + onResendClick: () => Promise; +} + +export const VerifyEmailModal: React.FC = ({ + email, + onResendClick, +}) => { + const resentTooltipVisibleTimerId = useRef(null); + const [resentTooltipVisible, setResentTooltipVisible] = useState(false); + + const onResendClickFn = async (): Promise => { + await onResendClick(); + setResentTooltipVisible(true); + resentTooltipVisibleTimerId.current = setTimeout(() => setResentTooltipVisible(false), 2500); + }; + + 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") } + + +
+
; +}; diff --git a/src/components/views/dialogs/QuestionDialog.tsx b/src/components/views/dialogs/QuestionDialog.tsx index b1e913dc8ea3..bc91d0a0f9c2 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 59f16caacd19..3a0c3ef155f7 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 = %(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", + "Did not receive it?": "Did not receive it?", "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 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", + "Reset password": "Reset password", "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 +3495,13 @@ "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", + "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.", "Commands": "Commands", "Command Autocomplete": "Command Autocomplete", "Emoji Autocomplete": "Emoji Autocomplete", diff --git a/tsconfig.json b/tsconfig.json index 69749ab96bcf..12590d680df7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "outDir": "./lib", "declaration": true, "jsx": "react", + "strict": true, "lib": [ "es2020", "dom",