From 856a35175f590d2858154fb607a9a3eba57a6d8d Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 13 Mar 2025 14:07:16 +0000 Subject: [PATCH] Begin rewrite ChangePassword form to be compoundy --- res/css/_common.pcss | 10 +- src/components/views/auth/PassphraseField.tsx | 101 ++++++------- .../views/settings/ChangePassword.tsx | 140 ++++++++---------- 3 files changed, 119 insertions(+), 132 deletions(-) diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 75180013f6..27b34567c0 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -1,5 +1,5 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 New Vector Ltd. Copyright 2019-2023 The Matrix.org Foundation C.I.C Copyright 2017-2019 New Vector Ltd Copyright 2017 Vector Creations Ltd @@ -217,7 +217,7 @@ textarea { } input[type="text"]:focus, -input[type="password"]:focus, +:not(.mx_ChangePasswordForm input) > input[type="password"], textarea:focus { outline: none; box-shadow: none; @@ -592,6 +592,7 @@ legend { */ .mx_Dialog button:not( + .mx_ChangePasswordForm button, .mx_EncryptionUserSettingsTab button, .mx_UserProfileSettings button, .mx_ShareDialog button, @@ -620,6 +621,7 @@ legend { .mx_Dialog button:not( + .mx_ChangePasswordForm button, .mx_Dialog_nonDialogButton, [class|="maplibregl"], .mx_AccessibleButton, @@ -634,6 +636,7 @@ legend { .mx_Dialog button:not( + .mx_ChangePasswordForm button, .mx_Dialog_nonDialogButton, [class|="maplibregl"], .mx_AccessibleButton, @@ -653,6 +656,7 @@ legend { .mx_Dialog input[type="submit"].mx_Dialog_primary, .mx_Dialog_buttons button:not( + .mx_ChangePasswordForm button, .mx_Dialog_nonDialogButton, .mx_AccessibleButton, .mx_UserProfileSettings button, @@ -672,6 +676,7 @@ legend { .mx_Dialog input[type="submit"].danger, .mx_Dialog_buttons button.danger:not( + .mx_ChangePasswordForm button, .mx_Dialog_nonDialogButton, .mx_AccessibleButton, .mx_UserProfileSettings button, @@ -694,6 +699,7 @@ legend { .mx_Dialog button:not( + .mx_ChangePasswordForm button, .mx_Dialog_nonDialogButton, [class|="maplibregl"], .mx_AccessibleButton, diff --git a/src/components/views/auth/PassphraseField.tsx b/src/components/views/auth/PassphraseField.tsx index ce251d4d9b..ad99debc48 100644 --- a/src/components/views/auth/PassphraseField.tsx +++ b/src/components/views/auth/PassphraseField.tsx @@ -6,15 +6,24 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type ComponentProps, PureComponent, type RefCallback, type RefObject } from "react"; +import React, { type RefCallback, type RefObject, useCallback, useMemo, useState } from "react"; import classNames from "classnames"; -import type { ZxcvbnResult } from "@zxcvbn-ts/core"; +import type { Score, ZxcvbnResult } from "@zxcvbn-ts/core"; import SdkConfig from "../../../SdkConfig"; -import withValidation, { type IFieldState, type IValidationResult } from "../elements/Validation"; +import withValidation, { type IValidationResult } from "../elements/Validation"; import { _t, _td, type TranslationKey } from "../../../languageHandler"; -import Field, { type IInputProps } from "../elements/Field"; +import { type IInputProps } from "../elements/Field"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { Field, Label, PasswordInput, Progress } from "@vector-im/compound-web"; + +const SCORE_TINT: Record ={ + "0": "red", + "1": "red", + "2": "orange", + "3": "lime", + "4": "green" +}; interface IProps extends Omit { autoFocus?: boolean; @@ -22,43 +31,44 @@ interface IProps extends Omit { className?: string; minScore: 0 | 1 | 2 | 3 | 4; value: string; - fieldRef?: RefCallback | RefObject; + fieldRef?: RefCallback | RefObject; // Additional strings such as a username used to catch bad passwords userInputs?: string[]; label: TranslationKey; - labelEnterPassword: TranslationKey; - labelStrongPassword: TranslationKey; - labelAllowedButUnsafe: TranslationKey; - tooltipAlignment?: ComponentProps["tooltipAlignment"]; + labelEnterPassword?: TranslationKey; + labelStrongPassword?: TranslationKey; + labelAllowedButUnsafe?: TranslationKey; + // tooltipAlignment?: ComponentProps["tooltipAlignment"]; onChange(ev: React.FormEvent): void; onValidate?(result: IValidationResult): void; } -class PassphraseField extends PureComponent { - public static defaultProps = { - label: _td("common|password"), - labelEnterPassword: _td("auth|password_field_label"), - labelStrongPassword: _td("auth|password_field_strong_label"), - labelAllowedButUnsafe: _td("auth|password_field_weak_label"), - }; +const DEFAULT_PROPS = { + label: _td("common|password"), + labelEnterPassword: _td("auth|password_field_label"), + labelStrongPassword: _td("auth|password_field_strong_label"), + labelAllowedButUnsafe: _td("auth|password_field_weak_label"), +}; - public readonly validate = withValidation({ +const NewPassphraseField: React.FC = (props) => { + const { labelEnterPassword, userInputs, minScore, label, labelStrongPassword, labelAllowedButUnsafe, className, id, fieldRef, autoFocus} = {...DEFAULT_PROPS, ...props}; + const validateFn = useMemo(() => withValidation<{}, ZxcvbnResult | null>({ description: function (complexity) { const score = complexity ? complexity.score : 0; - return ; + return }, deriveData: async ({ value }): Promise => { if (!value) return null; const { scorePassword } = await import("../../../utils/PasswordScorer"); - return scorePassword(MatrixClientPeg.get(), value, this.props.userInputs); + return scorePassword(MatrixClientPeg.get(), value, userInputs); }, rules: [ { key: "required", test: ({ value, allowEmpty }) => allowEmpty || !!value, - invalid: () => _t(this.props.labelEnterPassword), + invalid: () => _t(labelEnterPassword), }, { key: "complexity", @@ -66,7 +76,7 @@ class PassphraseField extends PureComponent { if (!value || !complexity) { return false; } - const safe = complexity.score >= this.props.minScore; + const safe = complexity.score >= minScore; const allowUnsafe = SdkConfig.get("dangerously_allow_unsafe_and_insecure_passwords"); return allowUnsafe || safe; }, @@ -74,10 +84,10 @@ class PassphraseField extends PureComponent { // Unsafe passwords that are valid are only possible through a // configuration flag. We'll print some helper text to signal // to the user that their password is allowed, but unsafe. - if (complexity && complexity.score >= this.props.minScore) { - return _t(this.props.labelStrongPassword); + if (complexity && complexity.score >= minScore) { + return _t(labelStrongPassword); } - return _t(this.props.labelAllowedButUnsafe); + return _t(labelAllowedButUnsafe); }, invalid: function (complexity) { if (!complexity) { @@ -89,33 +99,24 @@ class PassphraseField extends PureComponent { }, ], memoize: true, - }); + }), [labelEnterPassword, userInputs, minScore, labelStrongPassword, labelAllowedButUnsafe]); + const [feedback, setFeedback]= useState(); - public onValidate = async (fieldState: IFieldState): Promise => { - const result = await this.validate(fieldState); - if (this.props.onValidate) { - this.props.onValidate(result); - } - return result; - }; + const onInputChange = useCallback>((ev) => { + validateFn({ + value: ev.target.value, + focused: true, + }).then((v) => { + setFeedback(v.feedback); + }) + }, [validateFn]); - public render(): React.ReactNode { - return ( - - ); - } + + return + + + {feedback} + } -export default PassphraseField; +export default NewPassphraseField; diff --git a/src/components/views/settings/ChangePassword.tsx b/src/components/views/settings/ChangePassword.tsx index 30b6bd9661..38cd6f28d1 100644 --- a/src/components/views/settings/ChangePassword.tsx +++ b/src/components/views/settings/ChangePassword.tsx @@ -9,16 +9,13 @@ 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"; +import { Root, Field as CpdField, PasswordInput, Label, InlineSpinner, HelpMessage } from "@vector-im/compound-web"; +import PassphraseField from "../auth/PassphraseField"; const FIELD_OLD_PASSWORD = "field_old_password"; const FIELD_NEW_PASSWORD = "field_new_password"; @@ -34,19 +31,13 @@ enum Phase { 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>; + fieldValid: Partial>; phase: Phase; oldPassword: string; newPassword: string; @@ -54,15 +45,13 @@ interface IState { } 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; + private [FIELD_OLD_PASSWORD]: HTMLInputElement | null = null; + private [FIELD_NEW_PASSWORD]: HTMLInputElement | null = null; + private [FIELD_NEW_PASSWORD_CONFIRM]: HTMLInputElement | null = null; public static defaultProps: Partial = { onFinished() {}, onError() {}, - - confirm: true, }; public constructor(props: IProps) { @@ -100,15 +89,7 @@ export default class ChangePassword extends React.Component { cli.setPassword(authDict, newPassword, false) .then( () => { - if (this.props.shouldAskForEmail) { - return this.optionallySetEmail().then((confirmed) => { - this.props.onFinished({ - didSetEmail: confirmed, - }); - }); - } else { - this.props.onFinished({}); - } + this.props.onFinished({}); }, (err) => { if (err instanceof Error) { @@ -149,17 +130,9 @@ export default class ChangePassword extends React.Component { } } - 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 { + private markFieldValid(fieldID: FieldType, result: IValidationResult): void { const { fieldValid } = this.state; - fieldValid[fieldID] = valid; + fieldValid[fieldID] = result; this.setState({ fieldValid, }); @@ -169,11 +142,16 @@ export default class ChangePassword extends React.Component { this.setState({ oldPassword: ev.target.value, }); + this.onOldPasswordValidate({ + value: ev.target.value, + focused: true, + allowEmpty: true, + }); }; private onOldPasswordValidate = async (fieldState: IFieldState): Promise => { const result = await this.validateOldPasswordRules(fieldState); - this.markFieldValid(FIELD_OLD_PASSWORD, result.valid); + this.markFieldValid(FIELD_OLD_PASSWORD, result); return result; }; @@ -194,18 +172,24 @@ export default class ChangePassword extends React.Component { }; private onNewPasswordValidate = (result: IValidationResult): void => { - this.markFieldValid(FIELD_NEW_PASSWORD, result.valid); + this.markFieldValid(FIELD_NEW_PASSWORD, result); }; private onChangeNewPasswordConfirm = (ev: React.ChangeEvent): void => { this.setState({ newPasswordConfirm: ev.target.value, }); + + this.onNewPasswordConfirmValidate({ + value: ev.target.value, + focused: true, + allowEmpty: true, + }); }; private onNewPasswordConfirmValidate = async (fieldState: IFieldState): Promise => { const result = await this.validatePasswordConfirmRules(fieldState); - this.markFieldValid(FIELD_NEW_PASSWORD_CONFIRM, result.valid); + this.markFieldValid(FIELD_NEW_PASSWORD_CONFIRM, result); return result; }; @@ -308,10 +292,10 @@ export default class ChangePassword extends React.Component { } private allFieldsValid(): boolean { - return Object.values(this.state.fieldValid).every(Boolean); + return Object.values(this.state.fieldValid).map(v => v.valid).every(Boolean); } - private findFirstInvalidField(fieldIDs: FieldType[]): Field | null { + private findFirstInvalidField(fieldIDs: FieldType[]): HTMLInputElement | null { for (const fieldID of fieldIDs) { if (!this.state.fieldValid[fieldID] && this[fieldID]) { return this[fieldID]; @@ -321,47 +305,43 @@ export default class ChangePassword extends React.Component { } public render(): React.ReactNode { - const rowClassName = this.props.rowClassName; const buttonClassName = this.props.buttonClassName; - switch (this.state.phase) { + const { fieldValid, phase } = this.state; + + switch (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[FIELD_OLD_PASSWORD] = field)} data-invalid={fieldValid[FIELD_OLD_PASSWORD]?.valid} value={this.state.oldPassword} onChange={this.onChangeOldPassword} /> + {fieldValid[FIELD_OLD_PASSWORD]?.feedback && + {fieldValid[FIELD_OLD_PASSWORD]?.feedback} + } + + { /* This is a compound field. */} + (this[FIELD_NEW_PASSWORD] = field)} + type="password" + label={_td("auth|change_password_new_label")} + minScore={PASSWORD_MIN_SCORE} + value={this.state.newPassword} + onChange={this.onChangeNewPassword} + onValidate={this.onNewPasswordValidate} + autoComplete="new-password" + /> + + + (this[FIELD_NEW_PASSWORD_CONFIRM] = field)} data-invalid={fieldValid[FIELD_NEW_PASSWORD_CONFIRM]} value={this.state.newPasswordConfirm} onChange={this.onChangeNewPasswordConfirm} /> + {fieldValid[FIELD_NEW_PASSWORD_CONFIRM]?.feedback && + {fieldValid[FIELD_NEW_PASSWORD_CONFIRM]?.feedback} + } + { > {this.props.buttonLabel || _t("auth|change_password_action")} - +
); case Phase.Uploading: return (
- +
); }