Merge pull request #4599 from matrix-org/t3chguy/progress_colour

Consolidate password/passphrase fields into a component & add dynamic colour to progress
This commit is contained in:
Michael Telatynski 2020-05-18 11:08:08 +01:00 committed by GitHub
commit 1eea203db6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 292 additions and 272 deletions

View file

@ -120,6 +120,7 @@
"@types/modernizr": "^3.5.3", "@types/modernizr": "^3.5.3",
"@types/qrcode": "^1.3.4", "@types/qrcode": "^1.3.4",
"@types/react": "16.9", "@types/react": "16.9",
"@types/zxcvbn": "^4.4.0",
"babel-eslint": "^10.0.3", "babel-eslint": "^10.0.3",
"babel-jest": "^24.9.0", "babel-jest": "^24.9.0",
"chokidar": "^3.3.1", "chokidar": "^3.3.1",

View file

@ -41,6 +41,7 @@
@import "./views/auth/_CountryDropdown.scss"; @import "./views/auth/_CountryDropdown.scss";
@import "./views/auth/_InteractiveAuthEntryComponents.scss"; @import "./views/auth/_InteractiveAuthEntryComponents.scss";
@import "./views/auth/_LanguageSelector.scss"; @import "./views/auth/_LanguageSelector.scss";
@import "./views/auth/_PassphraseField.scss";
@import "./views/auth/_ServerConfig.scss"; @import "./views/auth/_ServerConfig.scss";
@import "./views/auth/_ServerTypeSelector.scss"; @import "./views/auth/_ServerTypeSelector.scss";
@import "./views/auth/_Welcome.scss"; @import "./views/auth/_Welcome.scss";

View file

@ -146,27 +146,3 @@ limitations under the License.
.mx_AuthBody_spinner { .mx_AuthBody_spinner {
margin: 1em 0; margin: 1em 0;
} }
.mx_AuthBody_passwordScore {
width: 100%;
appearance: none;
height: 4px;
border: 0;
border-radius: 2px;
position: absolute;
top: -12px;
&::-moz-progress-bar {
border-radius: 2px;
background-color: $accent-color;
}
&::-webkit-progress-bar,
&::-webkit-progress-value {
border-radius: 2px;
}
&::-webkit-progress-value {
background-color: $accent-color;
}
}

View file

@ -0,0 +1,55 @@
/*
Copyright 2020 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.
*/
$PassphraseStrengthHigh: $accent-color;
$PassphraseStrengthMedium: $username-variant5-color;
$PassphraseStrengthLow: $notice-primary-color;
@define-mixin ProgressBarColour $colour {
color: $colour;
&::-moz-progress-bar {
background-color: $colour;
}
&::-webkit-progress-value {
background-color: $colour;
}
}
progress.mx_PassphraseField_progress {
appearance: none;
width: 100%;
border: 0;
height: 4px;
position: absolute;
top: -12px;
border-radius: 2px;
&::-moz-progress-bar {
border-radius: 2px;
}
&::-webkit-progress-bar,
&::-webkit-progress-value {
border-radius: 2px;
}
@mixin ProgressBarColour $PassphraseStrengthLow;
&[value="2"], &[value="3"] {
@mixin ProgressBarColour $PassphraseStrengthMedium;
}
&[value="4"] {
@mixin ProgressBarColour $PassphraseStrengthHigh;
}
}

View file

@ -35,17 +35,6 @@ limitations under the License.
align-items: flex-start; align-items: flex-start;
} }
.mx_CreateKeyBackupDialog_passPhraseHelp {
flex: 1;
height: 85px;
margin-left: 20px;
font-size: 80%;
}
.mx_CreateKeyBackupDialog_passPhraseHelp progress {
width: 100%;
}
.mx_CreateKeyBackupDialog_passPhraseInput { .mx_CreateKeyBackupDialog_passPhraseInput {
flex: none; flex: none;
width: 250px; width: 250px;

View file

@ -68,17 +68,6 @@ limitations under the License.
margin-top: 0px; margin-top: 0px;
} }
.mx_CreateSecretStorageDialog_passPhraseHelp {
flex: 1;
height: 64px;
margin-left: 20px;
font-size: 80%;
}
.mx_CreateSecretStorageDialog_passPhraseHelp progress {
width: 100%;
}
.mx_CreateSecretStorageDialog_passPhraseMatch { .mx_CreateSecretStorageDialog_passPhraseMatch {
width: 200px; width: 200px;
margin-left: 20px; margin-left: 20px;

View file

@ -15,17 +15,17 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, {createRef} from 'react';
import FileSaver from 'file-saver'; import FileSaver from 'file-saver';
import * as sdk from '../../../../index'; import * as sdk from '../../../../index';
import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../../MatrixClientPeg';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { scorePassword } from '../../../../utils/PasswordScorer'; import {_t, _td} from '../../../../languageHandler';
import { _t } from '../../../../languageHandler';
import { accessSecretStorage } from '../../../../CrossSigningManager'; import { accessSecretStorage } from '../../../../CrossSigningManager';
import SettingsStore from '../../../../settings/SettingsStore'; import SettingsStore from '../../../../settings/SettingsStore';
import AccessibleButton from "../../../../components/views/elements/AccessibleButton"; import AccessibleButton from "../../../../components/views/elements/AccessibleButton";
import {copyNode} from "../../../../utils/strings"; import {copyNode} from "../../../../utils/strings";
import PassphraseField from "../../../../components/views/auth/PassphraseField";
const PHASE_PASSPHRASE = 0; const PHASE_PASSPHRASE = 0;
const PHASE_PASSPHRASE_CONFIRM = 1; const PHASE_PASSPHRASE_CONFIRM = 1;
@ -36,7 +36,6 @@ const PHASE_DONE = 5;
const PHASE_OPTOUT_CONFIRM = 6; const PHASE_OPTOUT_CONFIRM = 6;
const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc. const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc.
const PASSPHRASE_FEEDBACK_DELAY = 500; // How long after keystroke to offer passphrase feedback, ms.
/* /*
* Walks the user through the process of creating an e2e key backup * Walks the user through the process of creating an e2e key backup
@ -52,17 +51,18 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
this._recoveryKeyNode = null; this._recoveryKeyNode = null;
this._keyBackupInfo = null; this._keyBackupInfo = null;
this._setZxcvbnResultTimeout = null;
this.state = { this.state = {
secureSecretStorage: null, secureSecretStorage: null,
phase: PHASE_PASSPHRASE, phase: PHASE_PASSPHRASE,
passPhrase: '', passPhrase: '',
passPhraseValid: false,
passPhraseConfirm: '', passPhraseConfirm: '',
copied: false, copied: false,
downloaded: false, downloaded: false,
zxcvbnResult: null,
}; };
this._passphraseField = createRef();
} }
async componentDidMount() { async componentDidMount() {
@ -81,12 +81,6 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
} }
} }
componentWillUnmount() {
if (this._setZxcvbnResultTimeout !== null) {
clearTimeout(this._setZxcvbnResultTimeout);
}
}
_collectRecoveryKeyNode = (n) => { _collectRecoveryKeyNode = (n) => {
this._recoveryKeyNode = n; this._recoveryKeyNode = n;
} }
@ -180,22 +174,16 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
_onPassPhraseNextClick = async (e) => { _onPassPhraseNextClick = async (e) => {
e.preventDefault(); e.preventDefault();
if (!this._passphraseField.current) return; // unmounting
// If we're waiting for the timeout before updating the result at this point, await this._passphraseField.current.validate({ allowEmpty: false });
// skip ahead and do it now, otherwise we'll deny the attempt to proceed if (!this._passphraseField.current.state.valid) {
// even if the user entered a valid passphrase this._passphraseField.current.focus();
if (this._setZxcvbnResultTimeout !== null) { this._passphraseField.current.validate({ allowEmpty: false, focused: true });
clearTimeout(this._setZxcvbnResultTimeout); return;
this._setZxcvbnResultTimeout = null;
await new Promise((resolve) => {
this.setState({
zxcvbnResult: scorePassword(this.state.passPhrase),
}, resolve);
});
}
if (this._passPhraseIsValid()) {
this.setState({phase: PHASE_PASSPHRASE_CONFIRM});
} }
this.setState({phase: PHASE_PASSPHRASE_CONFIRM});
}; };
_onPassPhraseConfirmNextClick = async (e) => { _onPassPhraseConfirmNextClick = async (e) => {
@ -214,9 +202,9 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
_onSetAgainClick = () => { _onSetAgainClick = () => {
this.setState({ this.setState({
passPhrase: '', passPhrase: '',
passPhraseValid: false,
passPhraseConfirm: '', passPhraseConfirm: '',
phase: PHASE_PASSPHRASE, phase: PHASE_PASSPHRASE,
zxcvbnResult: null,
}); });
} }
@ -226,23 +214,16 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
}); });
} }
_onPassPhraseValidate = (result) => {
this.setState({
passPhraseValid: result.valid,
});
};
_onPassPhraseChange = (e) => { _onPassPhraseChange = (e) => {
this.setState({ this.setState({
passPhrase: e.target.value, passPhrase: e.target.value,
}); });
if (this._setZxcvbnResultTimeout !== null) {
clearTimeout(this._setZxcvbnResultTimeout);
}
this._setZxcvbnResultTimeout = setTimeout(() => {
this._setZxcvbnResultTimeout = null;
this.setState({
// precompute this and keep it in state: zxcvbn is fast but
// we use it in a couple of different places so no point recomputing
// it unnecessarily.
zxcvbnResult: scorePassword(this.state.passPhrase),
});
}, PASSPHRASE_FEEDBACK_DELAY);
} }
_onPassPhraseConfirmChange = (e) => { _onPassPhraseConfirmChange = (e) => {
@ -251,35 +232,9 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
}); });
} }
_passPhraseIsValid() {
return this.state.zxcvbnResult && this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE;
}
_renderPhasePassPhrase() { _renderPhasePassPhrase() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
let strengthMeter;
let helpText;
if (this.state.zxcvbnResult) {
if (this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE) {
helpText = _t("Great! This recovery passphrase looks strong enough.");
} else {
const suggestions = [];
for (let i = 0; i < this.state.zxcvbnResult.feedback.suggestions.length; ++i) {
suggestions.push(<div key={i}>{this.state.zxcvbnResult.feedback.suggestions[i]}</div>);
}
const suggestionBlock = <div>{suggestions.length > 0 ? suggestions : _t("Keep going...")}</div>;
helpText = <div>
{this.state.zxcvbnResult.feedback.warning}
{suggestionBlock}
</div>;
}
strengthMeter = <div>
<progress max={PASSWORD_MIN_SCORE} value={this.state.zxcvbnResult.score} />
</div>;
}
return <form onSubmit={this._onPassPhraseNextClick}> return <form onSubmit={this._onPassPhraseNextClick}>
<p>{_t( <p>{_t(
"<b>Warning</b>: You should only set up key backup from a trusted computer.", {}, "<b>Warning</b>: You should only set up key backup from a trusted computer.", {},
@ -293,17 +248,19 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
<div className="mx_CreateKeyBackupDialog_primaryContainer"> <div className="mx_CreateKeyBackupDialog_primaryContainer">
<div className="mx_CreateKeyBackupDialog_passPhraseContainer"> <div className="mx_CreateKeyBackupDialog_passPhraseContainer">
<input type="password" <PassphraseField
onChange={this._onPassPhraseChange}
value={this.state.passPhrase}
className="mx_CreateKeyBackupDialog_passPhraseInput" className="mx_CreateKeyBackupDialog_passPhraseInput"
placeholder={_t("Enter a recovery passphrase...")} onChange={this._onPassPhraseChange}
minScore={PASSWORD_MIN_SCORE}
value={this.state.passPhrase}
onValidate={this._onPassPhraseValidate}
fieldRef={this._passphraseField}
autoFocus={true} autoFocus={true}
label={_td("Enter a recovery passphrase")}
labelEnterPassword={_td("Enter a recovery passphrase")}
labelStrongPassword={_td("Great! This recovery passphrase looks strong enough.")}
labelAllowedButUnsafe={_td("Great! This recovery passphrase looks strong enough.")}
/> />
<div className="mx_CreateKeyBackupDialog_passPhraseHelp">
{strengthMeter}
{helpText}
</div>
</div> </div>
</div> </div>
@ -311,7 +268,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
primaryButton={_t('Next')} primaryButton={_t('Next')}
onPrimaryButtonClick={this._onPassPhraseNextClick} onPrimaryButtonClick={this._onPassPhraseNextClick}
hasCancel={false} hasCancel={false}
disabled={!this._passPhraseIsValid()} disabled={!this.state.passPhraseValid}
/> />
<details> <details>

View file

@ -15,17 +15,17 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, {createRef} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import * as sdk from '../../../../index'; import * as sdk from '../../../../index';
import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../../MatrixClientPeg';
import { scorePassword } from '../../../../utils/PasswordScorer';
import FileSaver from 'file-saver'; import FileSaver from 'file-saver';
import { _t } from '../../../../languageHandler'; import {_t, _td} from '../../../../languageHandler';
import Modal from '../../../../Modal'; import Modal from '../../../../Modal';
import { promptForBackupPassphrase } from '../../../../CrossSigningManager'; import { promptForBackupPassphrase } from '../../../../CrossSigningManager';
import {copyNode} from "../../../../utils/strings"; import {copyNode} from "../../../../utils/strings";
import {SSOAuthEntry} from "../../../../components/views/auth/InteractiveAuthEntryComponents"; import {SSOAuthEntry} from "../../../../components/views/auth/InteractiveAuthEntryComponents";
import PassphraseField from "../../../../components/views/auth/PassphraseField";
const PHASE_LOADING = 0; const PHASE_LOADING = 0;
const PHASE_LOADERROR = 1; const PHASE_LOADERROR = 1;
@ -39,7 +39,6 @@ const PHASE_DONE = 8;
const PHASE_CONFIRM_SKIP = 9; const PHASE_CONFIRM_SKIP = 9;
const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc. const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc.
const PASSPHRASE_FEEDBACK_DELAY = 500; // How long after keystroke to offer passphrase feedback, ms.
/* /*
* Walks the user through the process of creating a passphrase to guard Secure * Walks the user through the process of creating a passphrase to guard Secure
@ -62,16 +61,15 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
this._recoveryKey = null; this._recoveryKey = null;
this._recoveryKeyNode = null; this._recoveryKeyNode = null;
this._setZxcvbnResultTimeout = null;
this._backupKey = null; this._backupKey = null;
this.state = { this.state = {
phase: PHASE_LOADING, phase: PHASE_LOADING,
passPhrase: '', passPhrase: '',
passPhraseValid: false,
passPhraseConfirm: '', passPhraseConfirm: '',
copied: false, copied: false,
downloaded: false, downloaded: false,
zxcvbnResult: null,
backupInfo: null, backupInfo: null,
backupSigStatus: null, backupSigStatus: null,
// does the server offer a UI auth flow with just m.login.password // does the server offer a UI auth flow with just m.login.password
@ -83,6 +81,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
useKeyBackup: true, useKeyBackup: true,
}; };
this._passphraseField = createRef();
this._fetchBackupInfo(); this._fetchBackupInfo();
if (this.state.accountPassword) { if (this.state.accountPassword) {
// If we have an account password in memory, let's simplify and // If we have an account password in memory, let's simplify and
@ -99,9 +99,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
componentWillUnmount() { componentWillUnmount() {
MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatusChange); MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
if (this._setZxcvbnResultTimeout !== null) {
clearTimeout(this._setZxcvbnResultTimeout);
}
} }
async _fetchBackupInfo() { async _fetchBackupInfo() {
@ -364,22 +361,16 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
_onPassPhraseNextClick = async (e) => { _onPassPhraseNextClick = async (e) => {
e.preventDefault(); e.preventDefault();
if (!this._passphraseField.current) return; // unmounting
// If we're waiting for the timeout before updating the result at this point, await this._passphraseField.current.validate({ allowEmpty: false });
// skip ahead and do it now, otherwise we'll deny the attempt to proceed if (!this._passphraseField.current.state.valid) {
// even if the user entered a valid passphrase this._passphraseField.current.focus();
if (this._setZxcvbnResultTimeout !== null) { this._passphraseField.current.validate({ allowEmpty: false, focused: true });
clearTimeout(this._setZxcvbnResultTimeout); return;
this._setZxcvbnResultTimeout = null;
await new Promise((resolve) => {
this.setState({
zxcvbnResult: scorePassword(this.state.passPhrase),
}, resolve);
});
}
if (this._passPhraseIsValid()) {
this.setState({phase: PHASE_PASSPHRASE_CONFIRM});
} }
this.setState({phase: PHASE_PASSPHRASE_CONFIRM});
}; };
_onPassPhraseConfirmNextClick = async (e) => { _onPassPhraseConfirmNextClick = async (e) => {
@ -399,9 +390,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
_onSetAgainClick = () => { _onSetAgainClick = () => {
this.setState({ this.setState({
passPhrase: '', passPhrase: '',
passPhraseValid: false,
passPhraseConfirm: '', passPhraseConfirm: '',
phase: PHASE_PASSPHRASE, phase: PHASE_PASSPHRASE,
zxcvbnResult: null,
}); });
} }
@ -411,23 +402,16 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
}); });
} }
_onPassPhraseValidate = (result) => {
this.setState({
passPhraseValid: result.valid,
});
};
_onPassPhraseChange = (e) => { _onPassPhraseChange = (e) => {
this.setState({ this.setState({
passPhrase: e.target.value, passPhrase: e.target.value,
}); });
if (this._setZxcvbnResultTimeout !== null) {
clearTimeout(this._setZxcvbnResultTimeout);
}
this._setZxcvbnResultTimeout = setTimeout(() => {
this._setZxcvbnResultTimeout = null;
this.setState({
// precompute this and keep it in state: zxcvbn is fast but
// we use it in a couple of different places so no point recomputing
// it unnecessarily.
zxcvbnResult: scorePassword(this.state.passPhrase),
});
}, PASSPHRASE_FEEDBACK_DELAY);
} }
_onPassPhraseConfirmChange = (e) => { _onPassPhraseConfirmChange = (e) => {
@ -436,10 +420,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
}); });
} }
_passPhraseIsValid() {
return this.state.zxcvbnResult && this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE;
}
_onAccountPasswordChange = (e) => { _onAccountPasswordChange = (e) => {
this.setState({ this.setState({
accountPassword: e.target.value, accountPassword: e.target.value,
@ -502,37 +482,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
_renderPhasePassPhrase() { _renderPhasePassPhrase() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Field = sdk.getComponent('views.elements.Field');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const LabelledToggleSwitch = sdk.getComponent('views.elements.LabelledToggleSwitch'); const LabelledToggleSwitch = sdk.getComponent('views.elements.LabelledToggleSwitch');
let strengthMeter;
let helpText;
if (this.state.zxcvbnResult) {
if (this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE) {
helpText = _t("Great! This recovery passphrase looks strong enough.");
} else {
// We take the warning from zxcvbn or failing that, the first
// suggestion. In practice The first is generally the most relevant
// and it's probably better to present the user with one thing to
// improve about their password than a whole collection - it can
// spit out a warning and multiple suggestions which starts getting
// very information-dense.
const suggestion = (
this.state.zxcvbnResult.feedback.warning ||
this.state.zxcvbnResult.feedback.suggestions[0]
);
const suggestionBlock = <div>{suggestion || _t("Keep going...")}</div>;
helpText = <div>
{suggestionBlock}
</div>;
}
strengthMeter = <div>
<progress max={PASSWORD_MIN_SCORE} value={this.state.zxcvbnResult.score} />
</div>;
}
return <form onSubmit={this._onPassPhraseNextClick}> return <form onSubmit={this._onPassPhraseNextClick}>
<p>{_t( <p>{_t(
"Set a recovery passphrase to secure encrypted information and recover it if you log out. " + "Set a recovery passphrase to secure encrypted information and recover it if you log out. " +
@ -540,19 +492,19 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
)}</p> )}</p>
<div className="mx_CreateSecretStorageDialog_passPhraseContainer"> <div className="mx_CreateSecretStorageDialog_passPhraseContainer">
<Field <PassphraseField
type="password"
className="mx_CreateSecretStorageDialog_passPhraseField" className="mx_CreateSecretStorageDialog_passPhraseField"
onChange={this._onPassPhraseChange} onChange={this._onPassPhraseChange}
minScore={PASSWORD_MIN_SCORE}
value={this.state.passPhrase} value={this.state.passPhrase}
label={_t("Enter a recovery passphrase")} onValidate={this._onPassPhraseValidate}
fieldRef={this._passphraseField}
autoFocus={true} autoFocus={true}
autoComplete="new-password" label={_td("Enter a recovery passphrase")}
labelEnterPassword={_td("Enter a recovery passphrase")}
labelStrongPassword={_td("Great! This recovery passphrase looks strong enough.")}
labelAllowedButUnsafe={_td("Great! This recovery passphrase looks strong enough.")}
/> />
<div className="mx_CreateSecretStorageDialog_passPhraseHelp">
{strengthMeter}
{helpText}
</div>
</div> </div>
<LabelledToggleSwitch <LabelledToggleSwitch
@ -564,7 +516,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
primaryButton={_t('Continue')} primaryButton={_t('Continue')}
onPrimaryButtonClick={this._onPassPhraseNextClick} onPrimaryButtonClick={this._onPassPhraseNextClick}
hasCancel={false} hasCancel={false}
disabled={!this._passPhraseIsValid()} disabled={!this.state.passPhraseValid}
> >
<button type="button" <button type="button"
onClick={this._onSkipSetupClick} onClick={this._onSkipSetupClick}

View file

@ -0,0 +1,125 @@
/*
Copyright 2020 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, {PureComponent, RefCallback, RefObject} from "react";
import classNames from "classnames";
import zxcvbn from "zxcvbn";
import SdkConfig from "../../../SdkConfig";
import withValidation, {IFieldState, IValidationResult} from "../elements/Validation";
import {_t, _td} from "../../../languageHandler";
import Field from "../elements/Field";
interface IProps {
autoFocus?: boolean;
id?: string;
className?: string;
minScore: 0 | 1 | 2 | 3 | 4;
value: string;
fieldRef?: RefCallback<Field> | RefObject<Field>;
label?: string;
labelEnterPassword?: string;
labelStrongPassword?: string;
labelAllowedButUnsafe?: string;
onChange(ev: KeyboardEvent);
onValidate(result: IValidationResult);
}
interface IState {
complexity: zxcvbn.ZXCVBNResult;
}
class PassphraseField extends PureComponent<IProps, IState> {
static defaultProps = {
label: _td("Password"),
labelEnterPassword: _td("Enter password"),
labelStrongPassword: _td("Nice, strong password!"),
labelAllowedButUnsafe: _td("Password is allowed, but unsafe"),
};
state = { complexity: null };
public readonly validate = withValidation<this>({
description: function() {
const complexity = this.state.complexity;
const score = complexity ? complexity.score : 0;
return <progress className="mx_PassphraseField_progress" max={4} value={score} />;
},
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t(this.props.labelEnterPassword),
},
{
key: "complexity",
test: async function({ value }) {
if (!value) {
return false;
}
const { scorePassword } = await import('../../../utils/PasswordScorer');
const complexity = scorePassword(value);
this.setState({ complexity });
const safe = complexity.score >= this.props.minScore;
const allowUnsafe = SdkConfig.get()["dangerously_allow_unsafe_and_insecure_passwords"];
return allowUnsafe || safe;
},
valid: function() {
// 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 (this.state.complexity.score >= this.props.minScore) {
return _t(this.props.labelStrongPassword);
}
return _t(this.props.labelAllowedButUnsafe);
},
invalid: function() {
const complexity = this.state.complexity;
if (!complexity) {
return null;
}
const { feedback } = complexity;
return feedback.warning || feedback.suggestions[0] || _t("Keep going...");
},
},
],
});
onValidate = async (fieldState: IFieldState) => {
const result = await this.validate(fieldState);
this.props.onValidate(result);
return result;
};
render() {
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}
/>
}
}
export default PassphraseField;

View file

@ -29,6 +29,7 @@ import SdkConfig from '../../../SdkConfig';
import { SAFE_LOCALPART_REGEX } from '../../../Registration'; import { SAFE_LOCALPART_REGEX } from '../../../Registration';
import withValidation from '../elements/Validation'; import withValidation from '../elements/Validation';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import PassphraseField from "./PassphraseField";
const FIELD_EMAIL = 'field_email'; const FIELD_EMAIL = 'field_email';
const FIELD_PHONE_NUMBER = 'field_phone_number'; const FIELD_PHONE_NUMBER = 'field_phone_number';
@ -78,7 +79,6 @@ export default createReactClass({
password: this.props.defaultPassword || "", password: this.props.defaultPassword || "",
passwordConfirm: this.props.defaultPassword || "", passwordConfirm: this.props.defaultPassword || "",
passwordComplexity: null, passwordComplexity: null,
passwordSafe: false,
}; };
}, },
@ -264,65 +264,10 @@ export default createReactClass({
}); });
}, },
async onPasswordValidate(fieldState) { onPasswordValidate(result) {
const result = await this.validatePasswordRules(fieldState);
this.markFieldValid(FIELD_PASSWORD, result.valid); this.markFieldValid(FIELD_PASSWORD, result.valid);
return result;
}, },
validatePasswordRules: withValidation({
description: function() {
const complexity = this.state.passwordComplexity;
const score = complexity ? complexity.score : 0;
return <progress
className="mx_AuthBody_passwordScore"
max={PASSWORD_MIN_SCORE}
value={score}
/>;
},
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("Enter password"),
},
{
key: "complexity",
test: async function({ value }) {
if (!value) {
return false;
}
const { scorePassword } = await import('../../../utils/PasswordScorer');
const complexity = scorePassword(value);
const safe = complexity.score >= PASSWORD_MIN_SCORE;
const allowUnsafe = SdkConfig.get()["dangerously_allow_unsafe_and_insecure_passwords"];
this.setState({
passwordComplexity: complexity,
passwordSafe: safe,
});
return allowUnsafe || safe;
},
valid: function() {
// 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 (!this.state.passwordSafe) {
return _t("Password is allowed, but unsafe");
}
return _t("Nice, strong password!");
},
invalid: function() {
const complexity = this.state.passwordComplexity;
if (!complexity) {
return null;
}
const { feedback } = complexity;
return feedback.warning || feedback.suggestions[0] || _t("Keep going...");
},
},
],
}),
onPasswordConfirmChange(ev) { onPasswordConfirmChange(ev) {
this.setState({ this.setState({
passwordConfirm: ev.target.value, passwordConfirm: ev.target.value,
@ -484,13 +429,10 @@ export default createReactClass({
}, },
renderPassword() { renderPassword() {
const Field = sdk.getComponent('elements.Field'); return <PassphraseField
return <Field
id="mx_RegistrationForm_password" id="mx_RegistrationForm_password"
ref={field => this[FIELD_PASSWORD] = field} fieldRef={field => this[FIELD_PASSWORD] = field}
type="password" minScore={PASSWORD_MIN_SCORE}
autoComplete="new-password"
label={_t("Password")}
value={this.state.password} value={this.state.password}
onChange={this.onPasswordChange} onChange={this.onPasswordChange}
onValidate={this.onPasswordValidate} onValidate={this.onPasswordValidate}

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2019 New Vector Ltd Copyright 2019 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,11 +16,39 @@ limitations under the License.
*/ */
/* eslint-disable babel/no-invalid-this */ /* eslint-disable babel/no-invalid-this */
import React from "react";
import classNames from "classnames";
import classNames from 'classnames'; type Data = Pick<IFieldState, "value" | "allowEmpty">;
interface IRule<T> {
key: string;
final?: boolean;
skip?(this: T, data: Data): boolean;
test(this: T, data: Data): boolean | Promise<boolean>;
valid?(this: T): string;
invalid?(this: T): string;
}
interface IArgs<T> {
rules: IRule<T>[];
description(this: T): React.ReactChild;
}
export interface IFieldState {
value: string;
focused: boolean;
allowEmpty: boolean;
}
export interface IValidationResult {
valid?: boolean;
feedback?: React.ReactChild;
}
/** /**
* Creates a validation function from a set of rules describing what to validate. * Creates a validation function from a set of rules describing what to validate.
* Generic T is the "this" type passed to the rule methods
* *
* @param {Function} description * @param {Function} description
* Function that returns a string summary of the kind of value that will * Function that returns a string summary of the kind of value that will
@ -37,8 +66,8 @@ import classNames from 'classnames';
* A validation function that takes in the current input value and returns * A validation function that takes in the current input value and returns
* the overall validity and a feedback UI that can be rendered for more detail. * the overall validity and a feedback UI that can be rendered for more detail.
*/ */
export default function withValidation({ description, rules }) { export default function withValidation<T = undefined>({ description, rules }: IArgs<T>) {
return async function onValidate({ value, focused, allowEmpty = true }) { return async function onValidate({ value, focused, allowEmpty = true }: IFieldState): Promise<IValidationResult> {
if (!value && allowEmpty) { if (!value && allowEmpty) {
return { return {
valid: null, valid: null,

View file

@ -1889,6 +1889,10 @@
"Your Modular server": "Your Modular server", "Your Modular server": "Your Modular server",
"Enter the location of your Modular homeserver. It may use your own domain name or be a subdomain of <a>modular.im</a>.": "Enter the location of your Modular homeserver. It may use your own domain name or be a subdomain of <a>modular.im</a>.", "Enter the location of your Modular homeserver. It may use your own domain name or be a subdomain of <a>modular.im</a>.": "Enter the location of your Modular homeserver. It may use your own domain name or be a subdomain of <a>modular.im</a>.",
"Server Name": "Server Name", "Server Name": "Server Name",
"Enter password": "Enter password",
"Nice, strong password!": "Nice, strong password!",
"Password is allowed, but unsafe": "Password is allowed, but unsafe",
"Keep going...": "Keep going...",
"The email field must not be blank.": "The email field must not be blank.", "The email field must not be blank.": "The email field must not be blank.",
"The username field must not be blank.": "The username field must not be blank.", "The username field must not be blank.": "The username field must not be blank.",
"The phone number field must not be blank.": "The phone number field must not be blank.", "The phone number field must not be blank.": "The phone number field must not be blank.",
@ -1903,10 +1907,6 @@
"Use an email address to recover your account": "Use an email address to recover your account", "Use an email address to recover your account": "Use an email address to recover your account",
"Enter email address (required on this homeserver)": "Enter email address (required on this homeserver)", "Enter email address (required on this homeserver)": "Enter email address (required on this homeserver)",
"Doesn't look like a valid email address": "Doesn't look like a valid email address", "Doesn't look like a valid email address": "Doesn't look like a valid email address",
"Enter password": "Enter password",
"Password is allowed, but unsafe": "Password is allowed, but unsafe",
"Nice, strong password!": "Nice, strong password!",
"Keep going...": "Keep going...",
"Passwords don't match": "Passwords don't match", "Passwords don't match": "Passwords don't match",
"Other users can invite you to rooms using your contact details": "Other users can invite you to rooms using your contact details", "Other users can invite you to rooms using your contact details": "Other users can invite you to rooms using your contact details",
"Enter phone number (required on this homeserver)": "Enter phone number (required on this homeserver)", "Enter phone number (required on this homeserver)": "Enter phone number (required on this homeserver)",
@ -2200,9 +2200,9 @@
"Restore": "Restore", "Restore": "Restore",
"You'll need to authenticate with the server to confirm the upgrade.": "You'll need to authenticate with the server to confirm the upgrade.", "You'll need to authenticate with the server to confirm the upgrade.": "You'll need to authenticate with the server to confirm the upgrade.",
"Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.": "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.", "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.": "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.",
"Great! This recovery passphrase looks strong enough.": "Great! This recovery passphrase looks strong enough.",
"Set a recovery passphrase to secure encrypted information and recover it if you log out. This should be different to your account password:": "Set a recovery passphrase to secure encrypted information and recover it if you log out. This should be different to your account password:", "Set a recovery passphrase to secure encrypted information and recover it if you log out. This should be different to your account password:": "Set a recovery passphrase to secure encrypted information and recover it if you log out. This should be different to your account password:",
"Enter a recovery passphrase": "Enter a recovery passphrase", "Enter a recovery passphrase": "Enter a recovery passphrase",
"Great! This recovery passphrase looks strong enough.": "Great! This recovery passphrase looks strong enough.",
"Back up encrypted message keys": "Back up encrypted message keys", "Back up encrypted message keys": "Back up encrypted message keys",
"Set up with a recovery key": "Set up with a recovery key", "Set up with a recovery key": "Set up with a recovery key",
"That matches!": "That matches!", "That matches!": "That matches!",
@ -2230,7 +2230,6 @@
"Unable to set up secret storage": "Unable to set up secret storage", "Unable to set up secret storage": "Unable to set up secret storage",
"We'll store an encrypted copy of your keys on our server. Secure your backup with a recovery passphrase.": "We'll store an encrypted copy of your keys on our server. Secure your backup with a recovery passphrase.", "We'll store an encrypted copy of your keys on our server. Secure your backup with a recovery passphrase.": "We'll store an encrypted copy of your keys on our server. Secure your backup with a recovery passphrase.",
"For maximum security, this should be different from your account password.": "For maximum security, this should be different from your account password.", "For maximum security, this should be different from your account password.": "For maximum security, this should be different from your account password.",
"Enter a recovery passphrase...": "Enter a recovery passphrase...",
"Please enter your recovery passphrase a second time to confirm.": "Please enter your recovery passphrase a second time to confirm.", "Please enter your recovery passphrase a second time to confirm.": "Please enter your recovery passphrase a second time to confirm.",
"Repeat your recovery passphrase...": "Repeat your recovery passphrase...", "Repeat your recovery passphrase...": "Repeat your recovery passphrase...",
"Your keys are being backed up (the first backup could take a few minutes).": "Your keys are being backed up (the first backup could take a few minutes).", "Your keys are being backed up (the first backup could take a few minutes).": "Your keys are being backed up (the first backup could take a few minutes).",

View file

@ -63,7 +63,7 @@ _td("Short keyboard patterns are easy to guess");
* @param {string} password Password to score * @param {string} password Password to score
* @returns {object} Score result with `score` and `feedback` properties * @returns {object} Score result with `score` and `feedback` properties
*/ */
export function scorePassword(password) { export function scorePassword(password: string) {
if (password.length === 0) return null; if (password.length === 0) return null;
const userInputs = ZXCVBN_USER_INPUTS.slice(); const userInputs = ZXCVBN_USER_INPUTS.slice();

View file

@ -1325,6 +1325,11 @@
dependencies: dependencies:
"@types/yargs-parser" "*" "@types/yargs-parser" "*"
"@types/zxcvbn@^4.4.0":
version "4.4.0"
resolved "https://registry.yarnpkg.com/@types/zxcvbn/-/zxcvbn-4.4.0.tgz#fbc1d941cc6d9d37d18405c513ba6b294f89b609"
integrity sha512-GQLOT+SN20a+AI51y3fAimhyTF4Y0RG+YP3gf91OibIZ7CJmPFgoZi+ZR5a+vRbS01LbQosITWum4ATmJ1Z6Pg==
"@typescript-eslint/experimental-utils@^2.5.0": "@typescript-eslint/experimental-utils@^2.5.0":
version "2.27.0" version "2.27.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-2.27.0.tgz#801a952c10b58e486c9a0b36cf21e2aab1e9e01a" resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-2.27.0.tgz#801a952c10b58e486c9a0b36cf21e2aab1e9e01a"