diff --git a/res/css/_components.scss b/res/css/_components.scss index 1f896d270d..8bea138acb 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -100,6 +100,7 @@ @import "./views/elements/_ToggleSwitch.scss"; @import "./views/elements/_ToolTipButton.scss"; @import "./views/elements/_Tooltip.scss"; +@import "./views/elements/_Validation.scss"; @import "./views/globals/_MatrixToolbar.scss"; @import "./views/groups/_GroupPublicityToggle.scss"; @import "./views/groups/_GroupRoomList.scss"; diff --git a/res/css/views/auth/_AuthBody.scss b/res/css/views/auth/_AuthBody.scss index fa034095b6..16ac876869 100644 --- a/res/css/views/auth/_AuthBody.scss +++ b/res/css/views/auth/_AuthBody.scss @@ -130,3 +130,27 @@ limitations under the License. .mx_AuthBody_spinner { 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; + } +} diff --git a/res/css/views/elements/_Field.scss b/res/css/views/elements/_Field.scss index 20b1efd28b..147bb3b471 100644 --- a/res/css/views/elements/_Field.scss +++ b/res/css/views/elements/_Field.scss @@ -168,6 +168,7 @@ limitations under the License. .mx_Field_tooltip { margin-top: -12px; margin-left: 4px; + width: 200px; } .mx_Field_tooltip.mx_Field_valid { diff --git a/res/css/views/elements/_Tooltip.scss b/res/css/views/elements/_Tooltip.scss index 2f35bd338e..43ddf6dde5 100644 --- a/res/css/views/elements/_Tooltip.scss +++ b/res/css/views/elements/_Tooltip.scss @@ -50,7 +50,6 @@ limitations under the License. .mx_Tooltip { display: none; - animation: mx_fadein 0.2s; position: fixed; border: 1px solid $menu-border-color; border-radius: 4px; @@ -66,4 +65,12 @@ limitations under the License. max-width: 200px; word-break: break-word; margin-right: 50px; + + &.mx_Tooltip_visible { + animation: mx_fadein 0.2s forwards; + } + + &.mx_Tooltip_invisible { + animation: mx_fadeout 0.1s forwards; + } } diff --git a/res/css/views/elements/_Validation.scss b/res/css/views/elements/_Validation.scss new file mode 100644 index 0000000000..1f9bd880e6 --- /dev/null +++ b/res/css/views/elements/_Validation.scss @@ -0,0 +1,69 @@ +/* +Copyright 2019 New Vector Ltd + +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_Validation { + position: relative; +} + +.mx_Validation_details { + padding-left: 20px; + margin: 0; +} + +.mx_Validation_description + .mx_Validation_details { + margin: 1em 0 0; +} + +.mx_Validation_detail { + position: relative; + font-weight: normal; + list-style: none; + margin-bottom: 0.5em; + + &:last-child { + margin-bottom: 0; + } + + &::before { + content: ""; + position: absolute; + width: 14px; + height: 14px; + top: 0; + left: -18px; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + } + + &.mx_Validation_valid { + color: $input-valid-border-color; + + &::before { + mask-image: url('$(res)/img/feather-customised/check.svg'); + background-color: $input-valid-border-color; + } + } + + &.mx_Validation_invalid { + color: $input-invalid-border-color; + + &::before { + mask-image: url('$(res)/img/feather-customised/x.svg'); + background-color: $input-invalid-border-color; + } + } +} diff --git a/res/img/feather-customised/check.svg b/res/img/feather-customised/check.svg new file mode 100644 index 0000000000..5c600f8649 --- /dev/null +++ b/res/img/feather-customised/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/feather-customised/x.svg b/res/img/feather-customised/x.svg new file mode 100644 index 0000000000..5468caa8aa --- /dev/null +++ b/res/img/feather-customised/x.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/structures/auth/Registration.js b/src/components/structures/auth/Registration.js index 0d36e592f8..df87c1b9ca 100644 --- a/src/components/structures/auth/Registration.js +++ b/src/components/structures/auth/Registration.js @@ -28,8 +28,6 @@ import SdkConfig from '../../../SdkConfig'; import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; import * as ServerType from '../../views/auth/ServerTypeSelector'; -const MIN_PASSWORD_LENGTH = 6; - // Phases // Show controls to configure server details const PHASE_SERVER_DETAILS = 0; @@ -308,58 +306,6 @@ module.exports = React.createClass({ }); }, - onFormValidationChange: function(fieldErrors) { - // `fieldErrors` is an object mapping field IDs to error codes when there is an - // error or `null` for no error, so the values array will be something like: - // `[ null, "RegistrationForm.ERR_PASSWORD_MISSING", null]` - // Find the first non-null error code and show that. - const errCode = Object.values(fieldErrors).find(value => !!value); - if (!errCode) { - this.setState({ - errorText: null, - }); - return; - } - - let errMsg; - switch (errCode) { - case "RegistrationForm.ERR_PASSWORD_MISSING": - errMsg = _t('Missing password.'); - break; - case "RegistrationForm.ERR_PASSWORD_MISMATCH": - errMsg = _t('Passwords don\'t match.'); - break; - case "RegistrationForm.ERR_PASSWORD_LENGTH": - errMsg = _t('Password too short (min %(MIN_PASSWORD_LENGTH)s).', {MIN_PASSWORD_LENGTH}); - break; - case "RegistrationForm.ERR_EMAIL_INVALID": - errMsg = _t('This doesn\'t look like a valid email address.'); - break; - case "RegistrationForm.ERR_PHONE_NUMBER_INVALID": - errMsg = _t('This doesn\'t look like a valid phone number.'); - break; - case "RegistrationForm.ERR_MISSING_EMAIL": - errMsg = _t('An email address is required to register on this homeserver.'); - break; - case "RegistrationForm.ERR_MISSING_PHONE_NUMBER": - errMsg = _t('A phone number is required to register on this homeserver.'); - break; - case "RegistrationForm.ERR_USERNAME_INVALID": - errMsg = _t("A username can only contain lower case letters, numbers and '=_-./'"); - break; - case "RegistrationForm.ERR_USERNAME_BLANK": - errMsg = _t('You need to enter a username.'); - break; - default: - console.error("Unknown error code: %s", errCode); - errMsg = _t('An unknown error occurred.'); - break; - } - this.setState({ - errorText: errMsg, - }); - }, - onLoginClick: function(ev) { ev.preventDefault(); ev.stopPropagation(); @@ -534,8 +480,6 @@ module.exports = React.createClass({ defaultPhoneCountry={this.state.formVals.phoneCountry} defaultPhoneNumber={this.state.formVals.phoneNumber} defaultPassword={this.state.formVals.password} - minPasswordLength={MIN_PASSWORD_LENGTH} - onValidationChange={this.onFormValidationChange} onRegisterClick={this.onFormSubmit} onEditServerDetailsClick={onEditServerDetailsClick} flows={this.state.flows} diff --git a/src/components/views/auth/RegistrationForm.js b/src/components/views/auth/RegistrationForm.js index 1784ab61c3..6e55581af0 100644 --- a/src/components/views/auth/RegistrationForm.js +++ b/src/components/views/auth/RegistrationForm.js @@ -25,6 +25,7 @@ import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; import { SAFE_LOCALPART_REGEX } from '../../../Registration'; +import withValidation from '../elements/Validation'; const FIELD_EMAIL = 'field_email'; const FIELD_PHONE_NUMBER = 'field_phone_number'; @@ -32,6 +33,8 @@ const FIELD_USERNAME = 'field_username'; const FIELD_PASSWORD = 'field_password'; const FIELD_PASSWORD_CONFIRM = 'field_password_confirm'; +const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc. + /** * A pure UI component which displays a registration form. */ @@ -45,8 +48,6 @@ module.exports = React.createClass({ defaultPhoneNumber: PropTypes.string, defaultUsername: PropTypes.string, defaultPassword: PropTypes.string, - minPasswordLength: PropTypes.number, - onValidationChange: PropTypes.func, onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise onEditServerDetailsClick: PropTypes.func, flows: PropTypes.arrayOf(PropTypes.object).isRequired, @@ -59,7 +60,6 @@ module.exports = React.createClass({ getDefaultProps: function() { return { - minPasswordLength: 6, onValidationChange: console.error, }; }, @@ -67,7 +67,7 @@ module.exports = React.createClass({ getInitialState: function() { return { // Field error codes by field ID - fieldErrors: {}, + fieldValid: {}, // The ISO2 country code selected in the phone number entry phoneCountry: this.props.defaultPhoneCountry, username: "", @@ -75,44 +75,37 @@ module.exports = React.createClass({ phoneNumber: "", password: "", passwordConfirm: "", + passwordComplexity: null, }; }, - onSubmit: function(ev) { + onSubmit: async function(ev) { ev.preventDefault(); - // validate everything, in reverse order so - // the error that ends up being displayed - // is the one from the first invalid field. - // It's not super ideal that this just calls - // onValidationChange once for each invalid field. - this.validateField(FIELD_PHONE_NUMBER, ev.type); - this.validateField(FIELD_EMAIL, ev.type); - this.validateField(FIELD_PASSWORD_CONFIRM, ev.type); - this.validateField(FIELD_PASSWORD, ev.type); - this.validateField(FIELD_USERNAME, ev.type); + const allFieldsValid = await this.verifyFieldsBeforeSubmit(); + if (!allFieldsValid) { + return; + } const self = this; - if (this.allFieldsValid()) { - if (this.state.email == '') { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, { - title: _t("Warning!"), - description: -
- { _t("If you don't specify an email address, you won't be able to reset your password. " + - "Are you sure?") } -
, - button: _t("Continue"), - onFinished: function(confirmed) { - if (confirmed) { - self._doSubmit(ev); - } - }, - }); - } else { - self._doSubmit(ev); - } + if (this.state.email == '') { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, { + title: _t("Warning!"), + description: +
+ { _t("If you don't specify an email address, you won't be able to reset your password. " + + "Are you sure?") } +
, + button: _t("Continue"), + onFinished: function(confirmed) { + if (confirmed) { + self._doSubmit(ev); + } + }, + }); + } else { + self._doSubmit(ev); } }, @@ -134,118 +127,81 @@ module.exports = React.createClass({ } }, + async verifyFieldsBeforeSubmit() { + // 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; + if (activeElement) { + activeElement.blur(); + } + + const fieldIDsInDisplayOrder = [ + FIELD_USERNAME, + FIELD_PASSWORD, + FIELD_PASSWORD_CONFIRM, + FIELD_EMAIL, + FIELD_PHONE_NUMBER, + ]; + + // 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; + } + 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; + }, + /** * @returns {boolean} true if all fields were valid last time they were validated. */ allFieldsValid: function() { - const keys = Object.keys(this.state.fieldErrors); + const keys = Object.keys(this.state.fieldValid); for (let i = 0; i < keys.length; ++i) { - if (this.state.fieldErrors[keys[i]]) { + if (!this.state.fieldValid[keys[i]]) { return false; } } return true; }, - validateField: function(fieldID, eventType) { - const pwd1 = this.state.password.trim(); - const pwd2 = this.state.passwordConfirm.trim(); - const allowEmpty = eventType === "blur"; - - switch (fieldID) { - case FIELD_EMAIL: { - const email = this.state.email; - const emailValid = email === '' || Email.looksValid(email); - if (this._authStepIsRequired('m.login.email.identity') && (!emailValid || email === '')) { - this.markFieldValid(fieldID, false, "RegistrationForm.ERR_MISSING_EMAIL"); - } else this.markFieldValid(fieldID, emailValid, "RegistrationForm.ERR_EMAIL_INVALID"); - break; + findFirstInvalidField(fieldIDs) { + for (const fieldID of fieldIDs) { + if (!this.state.fieldValid[fieldID] && this[fieldID]) { + return this[fieldID]; } - case FIELD_PHONE_NUMBER: { - const phoneNumber = this.state.phoneNumber; - const phoneNumberValid = phoneNumber === '' || phoneNumberLooksValid(phoneNumber); - if (this._authStepIsRequired('m.login.msisdn') && (!phoneNumberValid || phoneNumber === '')) { - this.markFieldValid(fieldID, false, "RegistrationForm.ERR_MISSING_PHONE_NUMBER"); - } else this.markFieldValid(fieldID, phoneNumberValid, "RegistrationForm.ERR_PHONE_NUMBER_INVALID"); - break; - } - case FIELD_USERNAME: { - const username = this.state.username; - if (allowEmpty && username === '') { - this.markFieldValid(fieldID, true); - } else if (!SAFE_LOCALPART_REGEX.test(username)) { - this.markFieldValid( - fieldID, - false, - "RegistrationForm.ERR_USERNAME_INVALID", - ); - } else if (username == '') { - this.markFieldValid( - fieldID, - false, - "RegistrationForm.ERR_USERNAME_BLANK", - ); - } else { - this.markFieldValid(fieldID, true); - } - break; - } - case FIELD_PASSWORD: - if (allowEmpty && pwd1 === "") { - this.markFieldValid(fieldID, true); - } else if (pwd1 == '') { - this.markFieldValid( - fieldID, - false, - "RegistrationForm.ERR_PASSWORD_MISSING", - ); - } else if (pwd1.length < this.props.minPasswordLength) { - this.markFieldValid( - fieldID, - false, - "RegistrationForm.ERR_PASSWORD_LENGTH", - ); - } else { - this.markFieldValid(fieldID, true); - } - break; - case FIELD_PASSWORD_CONFIRM: - if (allowEmpty && pwd2 === "") { - this.markFieldValid(fieldID, true); - } else { - this.markFieldValid( - fieldID, pwd1 == pwd2, - "RegistrationForm.ERR_PASSWORD_MISMATCH", - ); - } - break; } + return null; }, - markFieldValid: function(fieldID, valid, errorCode) { - const { fieldErrors } = this.state; - if (valid) { - fieldErrors[fieldID] = null; - } else { - fieldErrors[fieldID] = errorCode; - } + markFieldValid: function(fieldID, valid) { + const { fieldValid } = this.state; + fieldValid[fieldID] = valid; this.setState({ - fieldErrors, + fieldValid, }); - this.props.onValidationChange(fieldErrors); - }, - - _classForField: function(fieldID, ...baseClasses) { - let cls = baseClasses.join(' '); - if (this.state.fieldErrors[fieldID]) { - if (cls) cls += ' '; - cls += 'error'; - } - return cls; - }, - - onEmailBlur(ev) { - this.validateField(FIELD_EMAIL, ev.type); }, onEmailChange(ev) { @@ -254,26 +210,113 @@ module.exports = React.createClass({ }); }, - onPasswordBlur(ev) { - this.validateField(FIELD_PASSWORD, ev.type); + async onEmailValidate(fieldState) { + const result = await this.validateEmailRules(fieldState); + this.markFieldValid(FIELD_EMAIL, result.valid); + return result; }, + validateEmailRules: withValidation({ + description: () => _t("Use an email address to recover your account"), + rules: [ + { + key: "required", + test: function({ value, allowEmpty }) { + return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value; + }, + invalid: () => _t("Enter email address (required on this homeserver)"), + }, + { + key: "email", + test: ({ value }) => !value || Email.looksValid(value), + invalid: () => _t("Doesn't look like a valid email address"), + }, + ], + }), + onPasswordChange(ev) { this.setState({ password: ev.target.value, }); }, - onPasswordConfirmBlur(ev) { - this.validateField(FIELD_PASSWORD_CONFIRM, ev.type); + async onPasswordValidate(fieldState) { + const result = await this.validatePasswordRules(fieldState); + this.markFieldValid(FIELD_PASSWORD, result.valid); + return result; }, + validatePasswordRules: withValidation({ + description: function() { + const complexity = this.state.passwordComplexity; + const score = complexity ? complexity.score : 0; + return ; + }, + 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); + this.setState({ + passwordComplexity: complexity, + }); + return complexity.score >= PASSWORD_MIN_SCORE; + }, + valid: () => _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) { this.setState({ passwordConfirm: ev.target.value, }); }, + async onPasswordConfirmValidate(fieldState) { + const result = await this.validatePasswordConfirmRules(fieldState); + this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid); + return result; + }, + + validatePasswordConfirmRules: withValidation({ + rules: [ + { + key: "required", + test: ({ value, allowEmpty }) => allowEmpty || !!value, + invalid: () => _t("Confirm password"), + }, + { + key: "match", + test: function({ value }) { + return !value || value === this.state.password; + }, + invalid: () => _t("Passwords don't match"), + }, + ], + }), + onPhoneCountryChange(newVal) { this.setState({ phoneCountry: newVal.iso2, @@ -281,26 +324,64 @@ module.exports = React.createClass({ }); }, - onPhoneNumberBlur(ev) { - this.validateField(FIELD_PHONE_NUMBER, ev.type); - }, - onPhoneNumberChange(ev) { this.setState({ phoneNumber: ev.target.value, }); }, - onUsernameBlur(ev) { - this.validateField(FIELD_USERNAME, ev.type); + async onPhoneNumberValidate(fieldState) { + const result = await this.validatePhoneNumberRules(fieldState); + this.markFieldValid(FIELD_PHONE_NUMBER, result.valid); + return result; }, + validatePhoneNumberRules: withValidation({ + description: () => _t("Other users can invite you to rooms using your contact details"), + rules: [ + { + key: "required", + test: function({ value, allowEmpty }) { + return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value; + }, + invalid: () => _t("Enter phone number (required on this homeserver)"), + }, + { + key: "email", + test: ({ value }) => !value || phoneNumberLooksValid(value), + invalid: () => _t("Doesn't look like a valid phone number"), + }, + ], + }), + onUsernameChange(ev) { this.setState({ username: ev.target.value, }); }, + async onUsernameValidate(fieldState) { + const result = await this.validateUsernameRules(fieldState); + this.markFieldValid(FIELD_USERNAME, result.valid); + return result; + }, + + validateUsernameRules: withValidation({ + description: () => _t("Use letters, numbers, dashes and underscores only"), + rules: [ + { + key: "required", + test: ({ value, allowEmpty }) => allowEmpty || !!value, + invalid: () => _t("Enter username"), + }, + { + key: "safeLocalpart", + test: ({ value }) => !value || SAFE_LOCALPART_REGEX.test(value), + invalid: () => _t("Some characters not allowed"), + }, + ], + }), + /** * A step is required if all flows include that step. * @@ -325,9 +406,99 @@ module.exports = React.createClass({ }); }, - render: function() { + renderEmail() { + if (!this._authStepIsUsed('m.login.email.identity')) { + return null; + } const Field = sdk.getComponent('elements.Field'); + const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ? + _t("Email") : + _t("Email (optional)"); + return this[FIELD_EMAIL] = field} + type="text" + label={emailPlaceholder} + defaultValue={this.props.defaultEmail} + value={this.state.email} + onChange={this.onEmailChange} + onValidate={this.onEmailValidate} + />; + }, + renderPassword() { + const Field = sdk.getComponent('elements.Field'); + return this[FIELD_PASSWORD] = field} + type="password" + label={_t("Password")} + defaultValue={this.props.defaultPassword} + value={this.state.password} + onChange={this.onPasswordChange} + onValidate={this.onPasswordValidate} + />; + }, + + renderPasswordConfirm() { + const Field = sdk.getComponent('elements.Field'); + return this[FIELD_PASSWORD_CONFIRM] = field} + type="password" + label={_t("Confirm")} + defaultValue={this.props.defaultPassword} + value={this.state.passwordConfirm} + onChange={this.onPasswordConfirmChange} + onValidate={this.onPasswordConfirmValidate} + />; + }, + + renderPhoneNumber() { + const threePidLogin = !SdkConfig.get().disable_3pid_login; + if (!threePidLogin || !this._authStepIsUsed('m.login.msisdn')) { + return null; + } + const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown'); + const Field = sdk.getComponent('elements.Field'); + const phoneLabel = this._authStepIsRequired('m.login.msisdn') ? + _t("Phone") : + _t("Phone (optional)"); + const phoneCountry = ; + return this[FIELD_PHONE_NUMBER] = field} + type="text" + label={phoneLabel} + defaultValue={this.props.defaultPhoneNumber} + value={this.state.phoneNumber} + prefix={phoneCountry} + onChange={this.onPhoneNumberChange} + onValidate={this.onPhoneNumberValidate} + />; + }, + + renderUsername() { + const Field = sdk.getComponent('elements.Field'); + return this[FIELD_USERNAME] = field} + type="text" + autoFocus={true} + label={_t("Username")} + defaultValue={this.props.defaultUsername} + value={this.state.username} + onChange={this.onUsernameChange} + onValidate={this.onUsernameValidate} + />; + }, + + render: function() { let yourMatrixAccountText = _t('Create your Matrix account'); if (this.props.hsName) { yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', { @@ -353,53 +524,6 @@ module.exports = React.createClass({ ; } - let emailSection; - if (this._authStepIsUsed('m.login.email.identity')) { - const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ? - _t("Email") : - _t("Email (optional)"); - - emailSection = ( - - ); - } - - const threePidLogin = !SdkConfig.get().disable_3pid_login; - const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown'); - let phoneSection; - if (threePidLogin && this._authStepIsUsed('m.login.msisdn')) { - const phoneLabel = this._authStepIsRequired('m.login.msisdn') ? - _t("Phone") : - _t("Phone (optional)"); - const phoneCountry = ; - - phoneSection = ; - } - const registerButton = ( ); @@ -412,48 +536,18 @@ module.exports = React.createClass({
- + {this.renderUsername()}
- - + {this.renderPassword()} + {this.renderPasswordConfirm()}
- { emailSection } - { phoneSection } + {this.renderEmail()} + {this.renderPhoneNumber()}
- {_t( - "Use an email address to recover your account. Other users " + - "can invite you to rooms using your contact details.", - )} + {_t("Use an email address to recover your account.") + " "} + {_t("Other users can invite you to rooms using your contact details.")} { registerButton }
diff --git a/src/components/views/elements/Field.js b/src/components/views/elements/Field.js index 12e20ad789..93bea70fc8 100644 --- a/src/components/views/elements/Field.js +++ b/src/components/views/elements/Field.js @@ -18,6 +18,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import sdk from '../../../index'; +import { throttle } from 'lodash'; + +// Invoke validation from user input (when typing, etc.) at most once every N ms. +const VALIDATION_THROTTLE_MS = 200; export default class Field extends React.PureComponent { static propTypes = { @@ -53,20 +57,73 @@ export default class Field extends React.PureComponent { }; } - onChange = (ev) => { - if (this.props.onValidate) { - const result = this.props.onValidate(ev.target.value); - this.setState({ - valid: result.valid, - feedback: result.feedback, - }); + onFocus = (ev) => { + this.validate({ + focused: true, + }); + // Parent component may have supplied its own `onFocus` as well + if (this.props.onFocus) { + this.props.onFocus(ev); } + }; + + onChange = (ev) => { + this.validateOnChange(); // Parent component may have supplied its own `onChange` as well if (this.props.onChange) { this.props.onChange(ev); } }; + onBlur = (ev) => { + this.validate({ + focused: false, + }); + // Parent component may have supplied its own `onBlur` as well + if (this.props.onBlur) { + this.props.onBlur(ev); + } + }; + + focus() { + this.input.focus(); + } + + async validate({ focused, allowEmpty = true }) { + if (!this.props.onValidate) { + return; + } + const value = this.input ? this.input.value : null; + const { valid, feedback } = await this.props.onValidate({ + value, + focused, + allowEmpty, + }); + + if (feedback) { + this.setState({ + valid, + feedback, + feedbackVisible: true, + }); + } else { + // When we receive null `feedback`, we want to hide the tooltip. + // We leave the previous `feedback` content in state without updating it, + // so that we can hide the tooltip containing the most recent feedback + // via CSS animation. + this.setState({ + valid, + feedbackVisible: false, + }); + } + } + + validateOnChange = throttle(() => { + this.validate({ + focused: true, + }); + }, VALIDATION_THROTTLE_MS); + render() { const { element, prefix, onValidate, children, ...inputProps } = this.props; @@ -74,10 +131,12 @@ export default class Field extends React.PureComponent { // Set some defaults for the element inputProps.type = inputProps.type || "text"; - inputProps.ref = "fieldInput"; + inputProps.ref = input => this.input = input; inputProps.placeholder = inputProps.placeholder || inputProps.label; + inputProps.onFocus = this.onFocus; inputProps.onChange = this.onChange; + inputProps.onBlur = this.onBlur; const fieldInput = React.createElement(inputElement, inputProps, children); @@ -95,12 +154,13 @@ export default class Field extends React.PureComponent { mx_Field_invalid: onValidate && this.state.valid === false, }); - // handle displaying feedback on validity + // Handle displaying feedback on validity const Tooltip = sdk.getComponent("elements.Tooltip"); - let feedback; + let tooltip; if (this.state.feedback) { - feedback = ; } @@ -109,7 +169,7 @@ export default class Field extends React.PureComponent { {prefixContainer} {fieldInput} - {feedback} + {tooltip} ; } } diff --git a/src/components/views/elements/Tooltip.js b/src/components/views/elements/Tooltip.js index 473aeb3bdc..1cc82978ed 100644 --- a/src/components/views/elements/Tooltip.js +++ b/src/components/views/elements/Tooltip.js @@ -31,10 +31,20 @@ module.exports = React.createClass({ className: React.PropTypes.string, // Class applied to the tooltip itself tooltipClassName: React.PropTypes.string, + // Whether the tooltip is visible or hidden. + // The hidden state allows animating the tooltip away via CSS. + // Defaults to visible if unset. + visible: React.PropTypes.bool, // the react element to put into the tooltip label: React.PropTypes.node, }, + getDefaultProps() { + return { + visible: true, + }; + }, + // Create a wrapper for the tooltip outside the parent and attach it to the body element componentDidMount: function() { this.tooltipContainer = document.createElement("div"); @@ -85,7 +95,10 @@ module.exports = React.createClass({ style = this._updatePosition(style); style.display = "block"; - const tooltipClasses = classNames("mx_Tooltip", this.props.tooltipClassName); + const tooltipClasses = classNames("mx_Tooltip", this.props.tooltipClassName, { + "mx_Tooltip_visible": this.props.visible, + "mx_Tooltip_invisible": !this.props.visible, + }); const tooltip = (
diff --git a/src/components/views/elements/Validation.js b/src/components/views/elements/Validation.js new file mode 100644 index 0000000000..31363b87c8 --- /dev/null +++ b/src/components/views/elements/Validation.js @@ -0,0 +1,131 @@ +/* +Copyright 2019 New Vector Ltd + +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. +*/ + +/* eslint-disable babel/no-invalid-this */ + +import classNames from 'classnames'; + +/** + * Creates a validation function from a set of rules describing what to validate. + * + * @param {Function} description + * Function that returns a string summary of the kind of value that will + * meet the validation rules. Shown at the top of the validation feedback. + * @param {Object} rules + * An array of rules describing how to check to input value. Each rule in an object + * and may have the following properties: + * - `key`: A unique ID for the rule. Required. + * - `test`: A function used to determine the rule's current validity. Required. + * - `valid`: Function returning text to show when the rule is valid. Only shown if set. + * - `invalid`: Function returning text to show when the rule is invalid. Only shown if set. + * @returns {Function} + * 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. + */ +export default function withValidation({ description, rules }) { + return async function onValidate({ value, focused, allowEmpty = true }) { + if (!value && allowEmpty) { + return { + valid: null, + feedback: null, + }; + } + + const results = []; + let valid = true; + if (rules && rules.length) { + for (const rule of rules) { + if (!rule.key || !rule.test) { + continue; + } + // We're setting `this` to whichever component holds the validation + // function. That allows rules to access the state of the component. + const ruleValid = await rule.test.call(this, { value, allowEmpty }); + valid = valid && ruleValid; + if (ruleValid && rule.valid) { + // If the rule's result is valid and has text to show for + // the valid state, show it. + const text = rule.valid.call(this); + if (!text) { + continue; + } + results.push({ + key: rule.key, + valid: true, + text, + }); + } else if (!ruleValid && rule.invalid) { + // If the rule's result is invalid and has text to show for + // the invalid state, show it. + const text = rule.invalid.call(this); + if (!text) { + continue; + } + results.push({ + key: rule.key, + valid: false, + text, + }); + } + } + } + + // Hide feedback when not focused + if (!focused) { + return { + valid, + feedback: null, + }; + } + + let details; + if (results && results.length) { + details =
    + {results.map(result => { + const classes = classNames({ + "mx_Validation_detail": true, + "mx_Validation_valid": result.valid, + "mx_Validation_invalid": !result.valid, + }); + return
  • + {result.text} +
  • ; + })} +
; + } + + let summary; + if (description) { + // We're setting `this` to whichever component holds the validation + // function. That allows rules to access the state of the component. + const content = description.call(this); + summary =
{content}
; + } + + let feedback; + if (summary || details) { + feedback =
+ {summary} + {details} +
; + } + + return { + valid, + feedback, + }; + }; +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a7fae2803f..d1ff8b2695 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1322,12 +1322,26 @@ "Change": "Change", "Sign in with": "Sign in with", "If you don't specify an email address, you won't be able to reset your password. Are you sure?": "If you don't specify an email address, you won't be able to reset your password. Are you sure?", + "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)", + "Doesn't look like a valid email address": "Doesn't look like a valid email address", + "Enter password": "Enter password", + "Nice, strong password!": "Nice, strong password!", + "Keep going...": "Keep going...", + "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", + "Enter phone number (required on this homeserver)": "Enter phone number (required on this homeserver)", + "Doesn't look like a valid phone number": "Doesn't look like a valid phone number", + "Use letters, numbers, dashes and underscores only": "Use letters, numbers, dashes and underscores only", + "Enter username": "Enter username", + "Some characters not allowed": "Some characters not allowed", + "Email (optional)": "Email (optional)", + "Confirm": "Confirm", + "Phone (optional)": "Phone (optional)", "Create your Matrix account": "Create your Matrix account", "Create your Matrix account on %(serverName)s": "Create your Matrix account on %(serverName)s", - "Email (optional)": "Email (optional)", - "Phone (optional)": "Phone (optional)", - "Confirm": "Confirm", - "Use an email address to recover your account. Other users can invite you to rooms using your contact details.": "Use an email address to recover your account. Other users can invite you to rooms using your contact details.", + "Use an email address to recover your account.": "Use an email address to recover your account.", + "Other users can invite you to rooms using your contact details.": "Other users can invite you to rooms using your contact details.", "Other servers": "Other servers", "Enter custom server URLs What does this mean?": "Enter custom server URLs What does this mean?", "Homeserver URL": "Homeserver URL", @@ -1515,15 +1529,6 @@ "Registration has been disabled on this homeserver.": "Registration has been disabled on this homeserver.", "Unable to query for supported registration methods.": "Unable to query for supported registration methods.", "This server does not support authentication with a phone number.": "This server does not support authentication with a phone number.", - "Missing password.": "Missing password.", - "Passwords don't match.": "Passwords don't match.", - "Password too short (min %(MIN_PASSWORD_LENGTH)s).": "Password too short (min %(MIN_PASSWORD_LENGTH)s).", - "This doesn't look like a valid email address.": "This doesn't look like a valid email address.", - "This doesn't look like a valid phone number.": "This doesn't look like a valid phone number.", - "An email address is required to register on this homeserver.": "An email address is required to register on this homeserver.", - "A phone number is required to register on this homeserver.": "A phone number is required to register on this homeserver.", - "You need to enter a username.": "You need to enter a username.", - "An unknown error occurred.": "An unknown error occurred.", "Create your account": "Create your account", "Commands": "Commands", "Results from DuckDuckGo": "Results from DuckDuckGo", @@ -1562,7 +1567,6 @@ "File to import": "File to import", "Import": "Import", "Great! This passphrase looks strong enough.": "Great! This passphrase looks strong enough.", - "Keep going...": "Keep going...", "We'll store an encrypted copy of your keys on our server. Protect your backup with a passphrase to keep it secure.": "We'll store an encrypted copy of your keys on our server. Protect your backup with a passphrase to keep it secure.", "For maximum security, this should be different from your account password.": "For maximum security, this should be different from your account password.", "Enter a passphrase...": "Enter a passphrase...", diff --git a/src/utils/PasswordScorer.js b/src/utils/PasswordScorer.js index 647436c131..3c366a73f8 100644 --- a/src/utils/PasswordScorer.js +++ b/src/utils/PasswordScorer.js @@ -67,7 +67,9 @@ export function scorePassword(password) { if (password.length === 0) return null; const userInputs = ZXCVBN_USER_INPUTS.slice(); - userInputs.push(MatrixClientPeg.get().getUserIdLocalpart()); + if (MatrixClientPeg.get()) { + userInputs.push(MatrixClientPeg.get().getUserIdLocalpart()); + } let zxcvbnResult = zxcvbn(password, userInputs); // Work around https://github.com/dropbox/zxcvbn/issues/216