Compare commits

...

2 Commits

Author SHA1 Message Date
Half-Shot
e3f43e1060 Complete the compounding 2025-03-13 14:40:00 +00:00
Half-Shot
856a35175f Begin rewrite ChangePassword form to be compoundy 2025-03-13 14:07:16 +00:00
3 changed files with 140 additions and 154 deletions

View File

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

View File

@@ -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<Score, "red" | "orange" | "lime" | "green"> ={
"0": "red",
"1": "red",
"2": "orange",
"3": "lime",
"4": "green"
};
interface IProps extends Omit<IInputProps, "onValidate" | "element"> {
autoFocus?: boolean;
@@ -22,43 +31,44 @@ interface IProps extends Omit<IInputProps, "onValidate" | "element"> {
className?: string;
minScore: 0 | 1 | 2 | 3 | 4;
value: string;
fieldRef?: RefCallback<Field> | RefObject<Field>;
fieldRef?: RefCallback<HTMLInputElement> | RefObject<HTMLInputElement>;
// Additional strings such as a username used to catch bad passwords
userInputs?: string[];
label: TranslationKey;
labelEnterPassword: TranslationKey;
labelStrongPassword: TranslationKey;
labelAllowedButUnsafe: TranslationKey;
tooltipAlignment?: ComponentProps<typeof Field>["tooltipAlignment"];
labelEnterPassword?: TranslationKey;
labelStrongPassword?: TranslationKey;
labelAllowedButUnsafe?: TranslationKey;
// tooltipAlignment?: ComponentProps<typeof Field>["tooltipAlignment"];
onChange(ev: React.FormEvent<HTMLElement>): void;
onValidate?(result: IValidationResult): void;
}
class PassphraseField extends PureComponent<IProps> {
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<this, ZxcvbnResult | null>({
const PassphraseField: React.FC<IProps> = (props) => {
const { labelEnterPassword, userInputs, minScore, label, labelStrongPassword, labelAllowedButUnsafe, className, id, fieldRef, autoFocus, onChange, onValidate} = {...DEFAULT_PROPS, ...props};
const validateFn = useMemo(() => withValidation<{}, ZxcvbnResult | null>({
description: function (complexity) {
const score = complexity ? complexity.score : 0;
return <progress className="mx_PassphraseField_progress" max={4} value={score} />;
return <Progress tint={SCORE_TINT[score]} size="sm" value={score} max={4} />
},
deriveData: async ({ value }): Promise<ZxcvbnResult | null> => {
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<IProps> {
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<IProps> {
// 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,26 @@ class PassphraseField extends PureComponent<IProps> {
},
],
memoize: true,
});
}), [labelEnterPassword, userInputs, minScore, labelStrongPassword, labelAllowedButUnsafe]);
const [feedback, setFeedback]= useState<string|JSX.Element>();
public onValidate = async (fieldState: IFieldState): Promise<IValidationResult> => {
const result = await this.validate(fieldState);
if (this.props.onValidate) {
this.props.onValidate(result);
}
return result;
};
const onInputChange = useCallback<React.ChangeEventHandler<HTMLInputElement>>((ev) => {
onChange(ev);
validateFn({
value: ev.target.value,
focused: true,
}).then((v) => {
setFeedback(v.feedback);
onValidate?.(v);
});
}, [validateFn]);
public render(): React.ReactNode {
return (
<Field
id={this.props.id}
autoFocus={this.props.autoFocus}
className={classNames("mx_PassphraseField", this.props.className)}
ref={this.props.fieldRef}
type="password"
autoComplete="new-password"
label={_t(this.props.label)}
value={this.props.value}
onChange={this.props.onChange}
onValidate={this.onValidate}
tooltipAlignment={this.props.tooltipAlignment}
/>
);
}
return <Field id={id} name="password" className={classNames("mx_PassphraseField", className)}>
<Label>{_t(label)}</Label>
<PasswordInput ref={fieldRef} autoFocus={autoFocus} onChange={onInputChange} />
{feedback}
</Field>
}
export default PassphraseField;

View File

@@ -9,16 +9,12 @@ 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, Button } 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 +30,11 @@ 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<Record<FieldType, boolean>>;
fieldValid: Partial<Record<FieldType, IValidationResult>>;
phase: Phase;
oldPassword: string;
newPassword: string;
@@ -54,15 +42,13 @@ interface IState {
}
export default class ChangePassword extends React.Component<IProps, IState> {
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<IProps> = {
onFinished() {},
onError() {},
confirm: true,
};
public constructor(props: IProps) {
@@ -100,15 +86,7 @@ export default class ChangePassword extends React.Component<IProps, IState> {
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 +127,9 @@ export default class ChangePassword extends React.Component<IProps, IState> {
}
}
private optionallySetEmail(): Promise<boolean> {
// 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 +139,16 @@ export default class ChangePassword extends React.Component<IProps, IState> {
this.setState({
oldPassword: ev.target.value,
});
this.onOldPasswordValidate({
value: ev.target.value,
focused: true,
allowEmpty: true,
});
};
private onOldPasswordValidate = async (fieldState: IFieldState): Promise<IValidationResult> => {
const result = await this.validateOldPasswordRules(fieldState);
this.markFieldValid(FIELD_OLD_PASSWORD, result.valid);
this.markFieldValid(FIELD_OLD_PASSWORD, result);
return result;
};
@@ -194,18 +169,24 @@ export default class ChangePassword extends React.Component<IProps, IState> {
};
private onNewPasswordValidate = (result: IValidationResult): void => {
this.markFieldValid(FIELD_NEW_PASSWORD, result.valid);
this.markFieldValid(FIELD_NEW_PASSWORD, result);
};
private onChangeNewPasswordConfirm = (ev: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
newPasswordConfirm: ev.target.value,
});
this.onNewPasswordConfirmValidate({
value: ev.target.value,
focused: true,
allowEmpty: true,
});
};
private onNewPasswordConfirmValidate = async (fieldState: IFieldState): Promise<IValidationResult> => {
const result = await this.validatePasswordConfirmRules(fieldState);
this.markFieldValid(FIELD_NEW_PASSWORD_CONFIRM, result.valid);
this.markFieldValid(FIELD_NEW_PASSWORD_CONFIRM, result);
return result;
};
@@ -266,26 +247,25 @@ export default class ChangePassword extends React.Component<IProps, IState> {
activeElement.blur();
}
// Run all fields with stricter validation that no longer allows empty
// values for required fields.
await this.onOldPasswordValidate({
value: this[FIELD_OLD_PASSWORD]?.value ?? null,
allowEmpty: false,
focused: true,
});
await this.onNewPasswordConfirmValidate({
value: this[FIELD_NEW_PASSWORD_CONFIRM]?.value ?? null,
allowEmpty: false,
focused: true,
});
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<void>((resolve) => this.setState({}, resolve));
@@ -303,15 +283,16 @@ export default class ChangePassword extends React.Component<IProps, IState> {
// 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 });
// TODO: HMM
// invalidField.validate({ allowEmpty: false, focused: true });
return false;
}
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,60 +302,56 @@ export default class ChangePassword extends React.Component<IProps, IState> {
}
public render(): React.ReactNode {
const rowClassName = this.props.rowClassName;
const buttonClassName = this.props.buttonClassName;
const { fieldValid, phase } = this.state;
switch (this.state.phase) {
switch (phase) {
case Phase.Edit:
return (
<form className={this.props.className} onSubmit={this.onClickChange}>
<div className={rowClassName}>
<Field
ref={(field) => (this[FIELD_OLD_PASSWORD] = field)}
type="password"
label={_t("auth|change_password_current_label")}
value={this.state.oldPassword}
onChange={this.onChangeOldPassword}
onValidate={this.onOldPasswordValidate}
/>
</div>
<div className={rowClassName}>
<PassphraseField
fieldRef={(field) => (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"
/>
</div>
<div className={rowClassName}>
<Field
ref={(field) => (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"
/>
</div>
<AccessibleButton
className={buttonClassName}
kind={this.props.buttonKind}
<Root className={"mx_ChangePasswordForm"} onSubmit={this.onClickChange}>
<CpdField name={FIELD_OLD_PASSWORD}>
<Label>
{_t("auth|change_password_current_label")}
</Label>
<PasswordInput ref={(field) => (this[FIELD_OLD_PASSWORD] = field)} data-invalid={fieldValid[FIELD_OLD_PASSWORD]?.valid === false ? true : undefined} value={this.state.oldPassword} onChange={this.onChangeOldPassword} />
{fieldValid[FIELD_OLD_PASSWORD]?.feedback && <HelpMessage>
{fieldValid[FIELD_OLD_PASSWORD]?.feedback}
</HelpMessage>}
</CpdField>
{ /* This is a compound field. */}
<PassphraseField
fieldRef={(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"
/>
<CpdField name={FIELD_NEW_PASSWORD_CONFIRM}>
<Label>
{_t("auth|change_password_confirm_label")}
</Label>
<PasswordInput autoComplete="new-password" ref={(field) => (this[FIELD_NEW_PASSWORD_CONFIRM] = field)} data-invalid={fieldValid[FIELD_NEW_PASSWORD_CONFIRM]?.valid === false ? true : undefined} value={this.state.newPasswordConfirm} onChange={this.onChangeNewPasswordConfirm} />
{fieldValid[FIELD_NEW_PASSWORD_CONFIRM]?.feedback && <HelpMessage>
{fieldValid[FIELD_NEW_PASSWORD_CONFIRM]?.feedback}
</HelpMessage>}
</CpdField>
<Button
disabled={!this.allFieldsValid()}
style={{width: "fit-content"}}
onClick={this.onClickChange}
kind="primary"
size="sm"
>
{this.props.buttonLabel || _t("auth|change_password_action")}
</AccessibleButton>
</form>
</Button>
</Root>
);
case Phase.Uploading:
return (
<div className="mx_Dialog_content">
<Spinner />
<InlineSpinner />
</div>
);
}