/* Copyright 2018-2024 New Vector Ltd. Copyright 2015, 2016 OpenMarket Ltd SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ import React from "react"; import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import Field from "../elements/Field"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import AccessibleButton, { type AccessibleButtonKind } from "../elements/AccessibleButton"; import Spinner from "../elements/Spinner"; import withValidation, { type IFieldState, type IValidationResult } from "../elements/Validation"; import { UserFriendlyError, _t, _td } from "../../../languageHandler"; import Modal from "../../../Modal"; import PassphraseField from "../auth/PassphraseField"; import { PASSWORD_MIN_SCORE } from "../auth/RegistrationForm"; import SetEmailDialog from "../dialogs/SetEmailDialog"; const FIELD_OLD_PASSWORD = "field_old_password"; const FIELD_NEW_PASSWORD = "field_new_password"; const FIELD_NEW_PASSWORD_CONFIRM = "field_new_password_confirm"; type FieldType = typeof FIELD_OLD_PASSWORD | typeof FIELD_NEW_PASSWORD | typeof FIELD_NEW_PASSWORD_CONFIRM; enum Phase { Edit = "edit", Uploading = "uploading", Error = "error", } interface IProps { onFinished: (outcome: { didSetEmail?: boolean }) => void; onError: (error: Error) => void; rowClassName?: string; buttonClassName?: string; buttonKind?: AccessibleButtonKind; buttonLabel?: string; confirm?: boolean; // Whether to autoFocus the new password input autoFocusNewPasswordInput?: boolean; className?: string; shouldAskForEmail?: boolean; } interface IState { fieldValid: Partial>; phase: Phase; oldPassword: string; newPassword: string; newPasswordConfirm: string; } export default class ChangePassword extends React.Component { private [FIELD_OLD_PASSWORD]: Field | null = null; private [FIELD_NEW_PASSWORD]: Field | null = null; private [FIELD_NEW_PASSWORD_CONFIRM]: Field | null = null; public static defaultProps: Partial = { onFinished() {}, onError() {}, confirm: true, }; public constructor(props: IProps) { super(props); this.state = { fieldValid: {}, phase: Phase.Edit, oldPassword: "", newPassword: "", newPasswordConfirm: "", }; } private async onChangePassword(oldPassword: string, newPassword: string): Promise { const cli = MatrixClientPeg.safeGet(); this.changePassword(cli, oldPassword, newPassword); } private changePassword(cli: MatrixClient, oldPassword: string, newPassword: string): void { const authDict = { type: "m.login.password", identifier: { type: "m.id.user", user: cli.credentials.userId, }, password: oldPassword, }; this.setState({ phase: Phase.Uploading, }); cli.setPassword(authDict, newPassword, false) .then( () => { if (this.props.shouldAskForEmail) { return this.optionallySetEmail().then((confirmed) => { this.props.onFinished({ didSetEmail: confirmed, }); }); } else { this.props.onFinished({}); } }, (err) => { if (err instanceof Error) { this.props.onError(err); } else { this.props.onError( new UserFriendlyError("auth|change_password_error", { error: String(err), cause: undefined, }), ); } }, ) .finally(() => { this.setState({ phase: Phase.Edit, oldPassword: "", newPassword: "", newPasswordConfirm: "", }); }); } /** * Checks the `newPass` and throws an error if it is unacceptable. * @param oldPass The old password * @param newPass The new password that the user is trying to be set * @param confirmPass The confirmation password where the user types the `newPass` * again for confirmation and should match the `newPass` before we accept their new * password. */ private checkPassword(oldPass: string, newPass: string, confirmPass: string): void { if (newPass !== confirmPass) { throw new UserFriendlyError("auth|change_password_mismatch"); } else if (!newPass || newPass.length === 0) { throw new UserFriendlyError("auth|change_password_empty"); } } private optionallySetEmail(): Promise { // Ask for an email otherwise the user has no way to reset their password const modal = Modal.createDialog(SetEmailDialog, { title: _t("auth|set_email_prompt"), }); return modal.finished.then(([confirmed]) => !!confirmed); } private markFieldValid(fieldID: FieldType, valid?: boolean): void { const { fieldValid } = this.state; fieldValid[fieldID] = valid; this.setState({ fieldValid, }); } private onChangeOldPassword = (ev: React.ChangeEvent): void => { this.setState({ oldPassword: ev.target.value, }); }; private onOldPasswordValidate = async (fieldState: IFieldState): Promise => { const result = await this.validateOldPasswordRules(fieldState); this.markFieldValid(FIELD_OLD_PASSWORD, result.valid); return result; }; private validateOldPasswordRules = withValidation({ rules: [ { key: "required", test: ({ value, allowEmpty }) => allowEmpty || !!value, invalid: () => _t("auth|change_password_empty"), }, ], }); private onChangeNewPassword = (ev: React.ChangeEvent): void => { this.setState({ newPassword: ev.target.value, }); }; private onNewPasswordValidate = (result: IValidationResult): void => { this.markFieldValid(FIELD_NEW_PASSWORD, result.valid); }; private onChangeNewPasswordConfirm = (ev: React.ChangeEvent): void => { this.setState({ newPasswordConfirm: ev.target.value, }); }; private onNewPasswordConfirmValidate = async (fieldState: IFieldState): Promise => { const result = await this.validatePasswordConfirmRules(fieldState); this.markFieldValid(FIELD_NEW_PASSWORD_CONFIRM, result.valid); return result; }; private validatePasswordConfirmRules = withValidation({ rules: [ { key: "required", test: ({ value, allowEmpty }) => allowEmpty || !!value, invalid: () => _t("auth|change_password_confirm_label"), }, { key: "match", test({ value }) { return !value || value === this.state.newPassword; }, invalid: () => _t("auth|change_password_confirm_invalid"), }, ], }); private onClickChange = async (ev: React.MouseEvent | React.FormEvent): Promise => { ev.preventDefault(); const allFieldsValid = await this.verifyFieldsBeforeSubmit(); if (!allFieldsValid) { return; } const oldPassword = this.state.oldPassword; const newPassword = this.state.newPassword; const confirmPassword = this.state.newPasswordConfirm; try { // TODO: We can remove this check (but should add some Playwright tests to // sanity check this flow). This logic is redundant with the input field // validation we do and `verifyFieldsBeforeSubmit()` above. See // https://github.com/matrix-org/matrix-react-sdk/pull/10615#discussion_r1167364214 this.checkPassword(oldPassword, newPassword, confirmPassword); return this.onChangePassword(oldPassword, newPassword); } catch (err) { if (err instanceof Error) { this.props.onError(err); } else { this.props.onError( new UserFriendlyError("auth|change_password_error", { error: String(err), cause: undefined, }), ); } } }; private async verifyFieldsBeforeSubmit(): Promise { // Blur the active element if any, so we first run its blur validation, // which is less strict than the pass we're about to do below for all fields. const activeElement = document.activeElement as HTMLElement; if (activeElement) { activeElement.blur(); } const fieldIDsInDisplayOrder: FieldType[] = [ FIELD_OLD_PASSWORD, FIELD_NEW_PASSWORD, FIELD_NEW_PASSWORD_CONFIRM, ]; // Run all fields with stricter validation that no longer allows empty // values for required fields. for (const fieldID of fieldIDsInDisplayOrder) { const field = this[fieldID]; if (!field) { continue; } // We must wait for these validations to finish before queueing // up the setState below so our setState goes in the queue after // all the setStates from these validate calls (that's how we // know they've finished). await field.validate({ allowEmpty: false }); } // Validation and state updates are async, so we need to wait for them to complete // first. Queue a `setState` callback and wait for it to resolve. await new Promise((resolve) => this.setState({}, resolve)); if (this.allFieldsValid()) { return true; } const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder); if (!invalidField) { return true; } // Focus the first invalid field and show feedback in the stricter mode // that no longer allows empty values for required fields. invalidField.focus(); invalidField.validate({ allowEmpty: false, focused: true }); return false; } private allFieldsValid(): boolean { return Object.values(this.state.fieldValid).every(Boolean); } private findFirstInvalidField(fieldIDs: FieldType[]): Field | null { for (const fieldID of fieldIDs) { if (!this.state.fieldValid[fieldID] && this[fieldID]) { return this[fieldID]; } } return null; } public render(): React.ReactNode { const rowClassName = this.props.rowClassName; const buttonClassName = this.props.buttonClassName; switch (this.state.phase) { case Phase.Edit: return (
{ this[FIELD_OLD_PASSWORD] = field; }} type="password" label={_t("auth|change_password_current_label")} value={this.state.oldPassword} onChange={this.onChangeOldPassword} onValidate={this.onOldPasswordValidate} />
{ this[FIELD_NEW_PASSWORD] = field; }} type="password" label={_td("auth|change_password_new_label")} minScore={PASSWORD_MIN_SCORE} value={this.state.newPassword} autoFocus={this.props.autoFocusNewPasswordInput} onChange={this.onChangeNewPassword} onValidate={this.onNewPasswordValidate} autoComplete="new-password" />
{ this[FIELD_NEW_PASSWORD_CONFIRM] = field; }} type="password" label={_t("auth|change_password_confirm_label")} value={this.state.newPasswordConfirm} onChange={this.onChangeNewPasswordConfirm} onValidate={this.onNewPasswordConfirmValidate} autoComplete="new-password" />
{this.props.buttonLabel || _t("auth|change_password_action")}
); case Phase.Uploading: return (
); } } }