Merge pull request #2933 from matrix-org/jryans/auth-validation

Input validation tooltips for registration
This commit is contained in:
J. Ryan Stinnett 2019-04-25 15:59:48 +01:00 committed by GitHub
commit d5e1836e86
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 667 additions and 310 deletions

View file

@ -100,6 +100,7 @@
@import "./views/elements/_ToggleSwitch.scss"; @import "./views/elements/_ToggleSwitch.scss";
@import "./views/elements/_ToolTipButton.scss"; @import "./views/elements/_ToolTipButton.scss";
@import "./views/elements/_Tooltip.scss"; @import "./views/elements/_Tooltip.scss";
@import "./views/elements/_Validation.scss";
@import "./views/globals/_MatrixToolbar.scss"; @import "./views/globals/_MatrixToolbar.scss";
@import "./views/groups/_GroupPublicityToggle.scss"; @import "./views/groups/_GroupPublicityToggle.scss";
@import "./views/groups/_GroupRoomList.scss"; @import "./views/groups/_GroupRoomList.scss";

View file

@ -130,3 +130,27 @@ 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

@ -168,6 +168,7 @@ limitations under the License.
.mx_Field_tooltip { .mx_Field_tooltip {
margin-top: -12px; margin-top: -12px;
margin-left: 4px; margin-left: 4px;
width: 200px;
} }
.mx_Field_tooltip.mx_Field_valid { .mx_Field_tooltip.mx_Field_valid {

View file

@ -50,7 +50,6 @@ limitations under the License.
.mx_Tooltip { .mx_Tooltip {
display: none; display: none;
animation: mx_fadein 0.2s;
position: fixed; position: fixed;
border: 1px solid $menu-border-color; border: 1px solid $menu-border-color;
border-radius: 4px; border-radius: 4px;
@ -66,4 +65,12 @@ limitations under the License.
max-width: 200px; max-width: 200px;
word-break: break-word; word-break: break-word;
margin-right: 50px; margin-right: 50px;
&.mx_Tooltip_visible {
animation: mx_fadein 0.2s forwards;
}
&.mx_Tooltip_invisible {
animation: mx_fadeout 0.1s forwards;
}
} }

View file

@ -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;
}
}
}

View file

@ -0,0 +1,3 @@
<svg fill="none" height="24" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="m20 6-11 11-5-5"/>
</svg>

After

Width:  |  Height:  |  Size: 213 B

View file

@ -0,0 +1,4 @@
<svg fill="none" height="24" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="m18 6-12 12"/>
<path d="m6 6 12 12"/>
</svg>

After

Width:  |  Height:  |  Size: 236 B

View file

@ -28,8 +28,6 @@ import SdkConfig from '../../../SdkConfig';
import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import * as ServerType from '../../views/auth/ServerTypeSelector'; import * as ServerType from '../../views/auth/ServerTypeSelector';
const MIN_PASSWORD_LENGTH = 6;
// Phases // Phases
// Show controls to configure server details // Show controls to configure server details
const PHASE_SERVER_DETAILS = 0; 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) { onLoginClick: function(ev) {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
@ -534,8 +480,6 @@ module.exports = React.createClass({
defaultPhoneCountry={this.state.formVals.phoneCountry} defaultPhoneCountry={this.state.formVals.phoneCountry}
defaultPhoneNumber={this.state.formVals.phoneNumber} defaultPhoneNumber={this.state.formVals.phoneNumber}
defaultPassword={this.state.formVals.password} defaultPassword={this.state.formVals.password}
minPasswordLength={MIN_PASSWORD_LENGTH}
onValidationChange={this.onFormValidationChange}
onRegisterClick={this.onFormSubmit} onRegisterClick={this.onFormSubmit}
onEditServerDetailsClick={onEditServerDetailsClick} onEditServerDetailsClick={onEditServerDetailsClick}
flows={this.state.flows} flows={this.state.flows}

View file

@ -25,6 +25,7 @@ import Modal from '../../../Modal';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import { SAFE_LOCALPART_REGEX } from '../../../Registration'; import { SAFE_LOCALPART_REGEX } from '../../../Registration';
import withValidation from '../elements/Validation';
const FIELD_EMAIL = 'field_email'; const FIELD_EMAIL = 'field_email';
const FIELD_PHONE_NUMBER = 'field_phone_number'; const FIELD_PHONE_NUMBER = 'field_phone_number';
@ -32,6 +33,8 @@ const FIELD_USERNAME = 'field_username';
const FIELD_PASSWORD = 'field_password'; const FIELD_PASSWORD = 'field_password';
const FIELD_PASSWORD_CONFIRM = 'field_password_confirm'; 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. * A pure UI component which displays a registration form.
*/ */
@ -45,8 +48,6 @@ module.exports = React.createClass({
defaultPhoneNumber: PropTypes.string, defaultPhoneNumber: PropTypes.string,
defaultUsername: PropTypes.string, defaultUsername: PropTypes.string,
defaultPassword: PropTypes.string, defaultPassword: PropTypes.string,
minPasswordLength: PropTypes.number,
onValidationChange: PropTypes.func,
onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise
onEditServerDetailsClick: PropTypes.func, onEditServerDetailsClick: PropTypes.func,
flows: PropTypes.arrayOf(PropTypes.object).isRequired, flows: PropTypes.arrayOf(PropTypes.object).isRequired,
@ -59,7 +60,6 @@ module.exports = React.createClass({
getDefaultProps: function() { getDefaultProps: function() {
return { return {
minPasswordLength: 6,
onValidationChange: console.error, onValidationChange: console.error,
}; };
}, },
@ -67,7 +67,7 @@ module.exports = React.createClass({
getInitialState: function() { getInitialState: function() {
return { return {
// Field error codes by field ID // Field error codes by field ID
fieldErrors: {}, fieldValid: {},
// The ISO2 country code selected in the phone number entry // The ISO2 country code selected in the phone number entry
phoneCountry: this.props.defaultPhoneCountry, phoneCountry: this.props.defaultPhoneCountry,
username: "", username: "",
@ -75,25 +75,19 @@ module.exports = React.createClass({
phoneNumber: "", phoneNumber: "",
password: "", password: "",
passwordConfirm: "", passwordConfirm: "",
passwordComplexity: null,
}; };
}, },
onSubmit: function(ev) { onSubmit: async function(ev) {
ev.preventDefault(); ev.preventDefault();
// validate everything, in reverse order so const allFieldsValid = await this.verifyFieldsBeforeSubmit();
// the error that ends up being displayed if (!allFieldsValid) {
// is the one from the first invalid field. return;
// 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 self = this; const self = this;
if (this.allFieldsValid()) {
if (this.state.email == '') { if (this.state.email == '') {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, { Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, {
@ -113,7 +107,6 @@ module.exports = React.createClass({
} else { } else {
self._doSubmit(ev); self._doSubmit(ev);
} }
}
}, },
_doSubmit: function(ev) { _doSubmit: function(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. * @returns {boolean} true if all fields were valid last time they were validated.
*/ */
allFieldsValid: function() { allFieldsValid: function() {
const keys = Object.keys(this.state.fieldErrors); const keys = Object.keys(this.state.fieldValid);
for (let i = 0; i < keys.length; ++i) { for (let i = 0; i < keys.length; ++i) {
if (this.state.fieldErrors[keys[i]]) { if (!this.state.fieldValid[keys[i]]) {
return false; return false;
} }
} }
return true; return true;
}, },
validateField: function(fieldID, eventType) { findFirstInvalidField(fieldIDs) {
const pwd1 = this.state.password.trim(); for (const fieldID of fieldIDs) {
const pwd2 = this.state.passwordConfirm.trim(); if (!this.state.fieldValid[fieldID] && this[fieldID]) {
const allowEmpty = eventType === "blur"; return this[fieldID];
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;
} }
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) { markFieldValid: function(fieldID, valid) {
const { fieldErrors } = this.state; const { fieldValid } = this.state;
if (valid) { fieldValid[fieldID] = valid;
fieldErrors[fieldID] = null;
} else {
fieldErrors[fieldID] = errorCode;
}
this.setState({ 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) { onEmailChange(ev) {
@ -254,26 +210,113 @@ module.exports = React.createClass({
}); });
}, },
onPasswordBlur(ev) { async onEmailValidate(fieldState) {
this.validateField(FIELD_PASSWORD, ev.type); 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) { onPasswordChange(ev) {
this.setState({ this.setState({
password: ev.target.value, password: ev.target.value,
}); });
}, },
onPasswordConfirmBlur(ev) { async onPasswordValidate(fieldState) {
this.validateField(FIELD_PASSWORD_CONFIRM, ev.type); 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 <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);
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) { onPasswordConfirmChange(ev) {
this.setState({ this.setState({
passwordConfirm: ev.target.value, 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) { onPhoneCountryChange(newVal) {
this.setState({ this.setState({
phoneCountry: newVal.iso2, phoneCountry: newVal.iso2,
@ -281,26 +324,64 @@ module.exports = React.createClass({
}); });
}, },
onPhoneNumberBlur(ev) {
this.validateField(FIELD_PHONE_NUMBER, ev.type);
},
onPhoneNumberChange(ev) { onPhoneNumberChange(ev) {
this.setState({ this.setState({
phoneNumber: ev.target.value, phoneNumber: ev.target.value,
}); });
}, },
onUsernameBlur(ev) { async onPhoneNumberValidate(fieldState) {
this.validateField(FIELD_USERNAME, ev.type); 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) { onUsernameChange(ev) {
this.setState({ this.setState({
username: ev.target.value, 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. * 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 Field = sdk.getComponent('elements.Field');
const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ?
_t("Email") :
_t("Email (optional)");
return <Field
id="mx_RegistrationForm_email"
ref={field => 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 <Field
id="mx_RegistrationForm_password"
ref={field => 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 <Field
id="mx_RegistrationForm_passwordConfirm"
ref={field => 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 = <CountryDropdown
value={this.state.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChange}
/>;
return <Field
id="mx_RegistrationForm_phoneNumber"
ref={field => 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 <Field
id="mx_RegistrationForm_username"
ref={field => 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'); let yourMatrixAccountText = _t('Create your Matrix account');
if (this.props.hsName) { if (this.props.hsName) {
yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', { yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
@ -353,53 +524,6 @@ module.exports = React.createClass({
</a>; </a>;
} }
let emailSection;
if (this._authStepIsUsed('m.login.email.identity')) {
const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ?
_t("Email") :
_t("Email (optional)");
emailSection = (
<Field
className={this._classForField(FIELD_EMAIL)}
id="mx_RegistrationForm_email"
type="text"
label={emailPlaceholder}
defaultValue={this.props.defaultEmail}
value={this.state.email}
onBlur={this.onEmailBlur}
onChange={this.onEmailChange}
/>
);
}
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 = <CountryDropdown
value={this.state.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChange}
/>;
phoneSection = <Field
className={this._classForField(FIELD_PHONE_NUMBER)}
id="mx_RegistrationForm_phoneNumber"
type="text"
label={phoneLabel}
defaultValue={this.props.defaultPhoneNumber}
value={this.state.phoneNumber}
prefix={phoneCountry}
onBlur={this.onPhoneNumberBlur}
onChange={this.onPhoneNumberChange}
/>;
}
const registerButton = ( const registerButton = (
<input className="mx_Login_submit" type="submit" value={_t("Register")} /> <input className="mx_Login_submit" type="submit" value={_t("Register")} />
); );
@ -412,48 +536,18 @@ module.exports = React.createClass({
</h3> </h3>
<form onSubmit={this.onSubmit}> <form onSubmit={this.onSubmit}>
<div className="mx_AuthBody_fieldRow"> <div className="mx_AuthBody_fieldRow">
<Field {this.renderUsername()}
className={this._classForField(FIELD_USERNAME)}
id="mx_RegistrationForm_username"
type="text"
autoFocus={true}
label={_t("Username")}
defaultValue={this.props.defaultUsername}
value={this.state.username}
onBlur={this.onUsernameBlur}
onChange={this.onUsernameChange}
/>
</div> </div>
<div className="mx_AuthBody_fieldRow"> <div className="mx_AuthBody_fieldRow">
<Field {this.renderPassword()}
className={this._classForField(FIELD_PASSWORD)} {this.renderPasswordConfirm()}
id="mx_RegistrationForm_password"
type="password"
label={_t("Password")}
defaultValue={this.props.defaultPassword}
value={this.state.password}
onBlur={this.onPasswordBlur}
onChange={this.onPasswordChange}
/>
<Field
className={this._classForField(FIELD_PASSWORD_CONFIRM)}
id="mx_RegistrationForm_passwordConfirm"
type="password"
label={_t("Confirm")}
defaultValue={this.props.defaultPassword}
value={this.state.passwordConfirm}
onBlur={this.onPasswordConfirmBlur}
onChange={this.onPasswordConfirmChange}
/>
</div> </div>
<div className="mx_AuthBody_fieldRow"> <div className="mx_AuthBody_fieldRow">
{ emailSection } {this.renderEmail()}
{ phoneSection } {this.renderPhoneNumber()}
</div> </div>
{_t( {_t("Use an email address to recover your account.") + " "}
"Use an email address to recover your account. Other users " + {_t("Other users can invite you to rooms using your contact details.")}
"can invite you to rooms using your contact details.",
)}
{ registerButton } { registerButton }
</form> </form>
</div> </div>

View file

@ -18,6 +18,10 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import sdk from '../../../index'; 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 { export default class Field extends React.PureComponent {
static propTypes = { static propTypes = {
@ -53,20 +57,73 @@ export default class Field extends React.PureComponent {
}; };
} }
onChange = (ev) => { onFocus = (ev) => {
if (this.props.onValidate) { this.validate({
const result = this.props.onValidate(ev.target.value); focused: true,
this.setState({
valid: result.valid,
feedback: result.feedback,
}); });
// 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 // Parent component may have supplied its own `onChange` as well
if (this.props.onChange) { if (this.props.onChange) {
this.props.onChange(ev); 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() { render() {
const { element, prefix, onValidate, children, ...inputProps } = this.props; const { element, prefix, onValidate, children, ...inputProps } = this.props;
@ -74,10 +131,12 @@ export default class Field extends React.PureComponent {
// Set some defaults for the <input> element // Set some defaults for the <input> element
inputProps.type = inputProps.type || "text"; inputProps.type = inputProps.type || "text";
inputProps.ref = "fieldInput"; inputProps.ref = input => this.input = input;
inputProps.placeholder = inputProps.placeholder || inputProps.label; inputProps.placeholder = inputProps.placeholder || inputProps.label;
inputProps.onFocus = this.onFocus;
inputProps.onChange = this.onChange; inputProps.onChange = this.onChange;
inputProps.onBlur = this.onBlur;
const fieldInput = React.createElement(inputElement, inputProps, children); 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, mx_Field_invalid: onValidate && this.state.valid === false,
}); });
// handle displaying feedback on validity // Handle displaying feedback on validity
const Tooltip = sdk.getComponent("elements.Tooltip"); const Tooltip = sdk.getComponent("elements.Tooltip");
let feedback; let tooltip;
if (this.state.feedback) { if (this.state.feedback) {
feedback = <Tooltip tooltip = <Tooltip
tooltipClassName="mx_Field_tooltip" tooltipClassName="mx_Field_tooltip"
visible={this.state.feedbackVisible}
label={this.state.feedback} label={this.state.feedback}
/>; />;
} }
@ -109,7 +169,7 @@ export default class Field extends React.PureComponent {
{prefixContainer} {prefixContainer}
{fieldInput} {fieldInput}
<label htmlFor={this.props.id}>{this.props.label}</label> <label htmlFor={this.props.id}>{this.props.label}</label>
{feedback} {tooltip}
</div>; </div>;
} }
} }

View file

@ -31,10 +31,20 @@ module.exports = React.createClass({
className: React.PropTypes.string, className: React.PropTypes.string,
// Class applied to the tooltip itself // Class applied to the tooltip itself
tooltipClassName: React.PropTypes.string, 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 // the react element to put into the tooltip
label: React.PropTypes.node, label: React.PropTypes.node,
}, },
getDefaultProps() {
return {
visible: true,
};
},
// Create a wrapper for the tooltip outside the parent and attach it to the body element // Create a wrapper for the tooltip outside the parent and attach it to the body element
componentDidMount: function() { componentDidMount: function() {
this.tooltipContainer = document.createElement("div"); this.tooltipContainer = document.createElement("div");
@ -85,7 +95,10 @@ module.exports = React.createClass({
style = this._updatePosition(style); style = this._updatePosition(style);
style.display = "block"; 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 = ( const tooltip = (
<div className={tooltipClasses} style={style}> <div className={tooltipClasses} style={style}>

View file

@ -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 = <ul className="mx_Validation_details">
{results.map(result => {
const classes = classNames({
"mx_Validation_detail": true,
"mx_Validation_valid": result.valid,
"mx_Validation_invalid": !result.valid,
});
return <li key={result.key} className={classes}>
{result.text}
</li>;
})}
</ul>;
}
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 = <div className="mx_Validation_description">{content}</div>;
}
let feedback;
if (summary || details) {
feedback = <div className="mx_Validation">
{summary}
{details}
</div>;
}
return {
valid,
feedback,
};
};
}

View file

@ -1322,12 +1322,26 @@
"Change": "Change", "Change": "Change",
"Sign in with": "Sign in with", "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?", "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": "Create your Matrix account",
"Create your Matrix account on %(serverName)s": "Create your Matrix account on %(serverName)s", "Create your Matrix account on %(serverName)s": "Create your Matrix account on %(serverName)s",
"Email (optional)": "Email (optional)", "Use an email address to recover your account.": "Use an email address to recover your account.",
"Phone (optional)": "Phone (optional)", "Other users can invite you to rooms using your contact details.": "Other users can invite you to rooms using your contact details.",
"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.",
"Other servers": "Other servers", "Other servers": "Other servers",
"Enter custom server URLs <a>What does this mean?</a>": "Enter custom server URLs <a>What does this mean?</a>", "Enter custom server URLs <a>What does this mean?</a>": "Enter custom server URLs <a>What does this mean?</a>",
"Homeserver URL": "Homeserver URL", "Homeserver URL": "Homeserver URL",
@ -1515,15 +1529,6 @@
"Registration has been disabled on this homeserver.": "Registration has been disabled on this homeserver.", "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.", "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.", "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", "Create your account": "Create your account",
"Commands": "Commands", "Commands": "Commands",
"Results from DuckDuckGo": "Results from DuckDuckGo", "Results from DuckDuckGo": "Results from DuckDuckGo",
@ -1562,7 +1567,6 @@
"File to import": "File to import", "File to import": "File to import",
"Import": "Import", "Import": "Import",
"Great! This passphrase looks strong enough.": "Great! This passphrase looks strong enough.", "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.", "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.", "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...", "Enter a passphrase...": "Enter a passphrase...",

View file

@ -67,7 +67,9 @@ export function scorePassword(password) {
if (password.length === 0) return null; if (password.length === 0) return null;
const userInputs = ZXCVBN_USER_INPUTS.slice(); const userInputs = ZXCVBN_USER_INPUTS.slice();
if (MatrixClientPeg.get()) {
userInputs.push(MatrixClientPeg.get().getUserIdLocalpart()); userInputs.push(MatrixClientPeg.get().getUserIdLocalpart());
}
let zxcvbnResult = zxcvbn(password, userInputs); let zxcvbnResult = zxcvbn(password, userInputs);
// Work around https://github.com/dropbox/zxcvbn/issues/216 // Work around https://github.com/dropbox/zxcvbn/issues/216