Merge pull request #2933 from matrix-org/jryans/auth-validation
Input validation tooltips for registration
This commit is contained in:
commit
d5e1836e86
14 changed files with 667 additions and 310 deletions
|
@ -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";
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
69
res/css/views/elements/_Validation.scss
Normal file
69
res/css/views/elements/_Validation.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
3
res/img/feather-customised/check.svg
Normal file
3
res/img/feather-customised/check.svg
Normal 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 |
4
res/img/feather-customised/x.svg
Normal file
4
res/img/feather-customised/x.svg
Normal 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 |
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
131
src/components/views/elements/Validation.js
Normal file
131
src/components/views/elements/Validation.js
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
|
@ -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...",
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue