element-web/src/components/views/auth/RegistrationForm.tsx

590 lines
20 KiB
TypeScript
Raw Normal View History

/*
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
2020-11-19 18:10:40 +03:00
Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
2020-11-20 16:34:44 +03:00
import React from 'react';
2020-11-19 18:10:40 +03:00
import * as sdk from '../../../index';
import * as Email from '../../../email';
import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
import Modal from '../../../Modal';
More i18n strings (#963) * Add i18n for E2E import and Export Dialogs Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add various previous missing i18n strings Signed-off-by: MTRNord <mtrnord1@gmail.com> * Translate CreateRoomButton Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add ChatInviteDialog and fix missing to. Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add ConfitmRedactDialog translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add DeactivateAccountDialog translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add DeviceVerifyDialog translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add SessionRestoreErrorDialog translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add SetDisplayNameDialog translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add UnknownDeviceDialog translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add AddressTile translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add DeviceVerifyButtons translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add Dropdown translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add UserSelector translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add CaptchaForm translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add CasLogin translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add CustomServerDialog translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add InteractiveAuthEntryComponents translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add LoginFooter translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add RegistrationForm translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add ServerConfig translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add MAudioBody translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add MImageBody translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add MVideoBody translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add TextualBody translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add UnknownBody translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add UrlPreviewSettings translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add AuxPanel translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * Add PresenceLabel translations Signed-off-by: MTRNord <mtrnord1@gmail.com> * fix syntax error * weird space :P * missing ',' * fix missing value * fix json fail * remove acidential added file * fix another json fail
2017-05-30 17:09:57 +03:00
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import { SAFE_LOCALPART_REGEX } from '../../../Registration';
import withValidation from '../elements/Validation';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import PassphraseField from "./PassphraseField";
2020-10-29 18:53:14 +03:00
import CountlyAnalytics from "../../../CountlyAnalytics";
import Field from '../elements/Field';
import RegistrationEmailPromptDialog from '../dialogs/RegistrationEmailPromptDialog';
import QuestionDialog from '../dialogs/QuestionDialog';
2020-11-19 18:10:40 +03:00
enum RegistrationField {
Email = "field_email",
PhoneNumber = "field_phone_number",
Username = "field_username",
Password = "field_password",
PasswordConfirm = "field_password_confirm",
}
const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario.
2020-11-19 18:10:40 +03:00
interface IProps {
// Values pre-filled in the input boxes when the component loads
defaultEmail?: string;
defaultPhoneCountry?: string;
defaultPhoneNumber?: string;
defaultUsername?: string;
defaultPassword?: string;
flows: {
stages: string[];
}[];
serverConfig: ValidatedServerConfig;
canSubmit?: boolean;
serverRequiresIdServer?: boolean;
onRegisterClick(params: {
username: string;
password: string;
email?: string;
phoneCountry?: string;
phoneNumber?: string;
}): Promise<void>;
onEditServerDetailsClick?(): void;
}
interface IState {
// Field error codes by field ID
fieldValid: Partial<Record<RegistrationField, boolean>>;
// The ISO2 country code selected in the phone number entry
phoneCountry: string;
username: string;
email: string;
phoneNumber: string;
password: string;
passwordConfirm: string;
passwordComplexity?: number;
}
2020-08-29 14:57:11 +03:00
/*
* A pure UI component which displays a registration form.
*/
2020-11-19 18:10:40 +03:00
export default class RegistrationForm extends React.PureComponent<IProps, IState> {
2020-08-29 14:14:16 +03:00
static defaultProps = {
onValidationChange: console.error,
canSubmit: true,
};
constructor(props) {
super(props);
2020-08-29 14:14:16 +03:00
this.state = {
fieldValid: {},
phoneCountry: this.props.defaultPhoneCountry,
username: this.props.defaultUsername || "",
email: this.props.defaultEmail || "",
phoneNumber: this.props.defaultPhoneNumber || "",
password: this.props.defaultPassword || "",
passwordConfirm: this.props.defaultPassword || "",
passwordComplexity: null,
};
2020-10-29 18:53:14 +03:00
CountlyAnalytics.instance.track("onboarding_registration_begin");
2020-08-29 14:14:16 +03:00
}
2020-11-19 18:10:40 +03:00
private onSubmit = async ev => {
ev.preventDefault();
ev.persist();
if (!this.props.canSubmit) return;
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
if (!allFieldsValid) {
2020-10-29 18:53:14 +03:00
CountlyAnalytics.instance.track("onboarding_registration_submit_failed");
return;
}
if (this.state.email === '') {
2019-08-07 13:15:56 +03:00
const haveIs = Boolean(this.props.serverConfig.isUrl);
if (this.props.serverRequiresIdServer && !haveIs) {
Modal.createTrackedDialog("No identity server no email", '', QuestionDialog, {
title: _t("Warning!"),
description: _t(
"No identity server is configured so you cannot add an email address in order to " +
"reset your password in the future.",
),
button: _t("Continue"),
onFinished: async (confirmed) => {
if (confirmed) this.doSubmit(ev);
},
});
2020-11-19 18:10:40 +03:00
} else if (this.showEmail()) {
CountlyAnalytics.instance.track("onboarding_registration_submit_warn");
Modal.createTrackedDialog("Email prompt dialog", '', RegistrationEmailPromptDialog, {
onFinished: async (confirmed: boolean, email?: string) => {
if (confirmed) {
this.setState({
email,
}, () => {
this.doSubmit(ev);
});
}
},
});
} else {
// user can't set an e-mail so don't prompt them to
2020-11-19 18:10:40 +03:00
this.doSubmit(ev);
return;
2019-08-07 13:15:56 +03:00
}
} else {
2020-11-19 18:10:40 +03:00
this.doSubmit(ev);
}
2020-08-29 14:14:16 +03:00
};
2020-11-19 18:10:40 +03:00
private doSubmit(ev) {
const email = this.state.email.trim();
2020-10-29 18:53:14 +03:00
CountlyAnalytics.instance.track("onboarding_registration_submit_ok", {
email: !!email,
});
const promise = this.props.onRegisterClick({
username: this.state.username.trim(),
password: this.state.password.trim(),
email: email,
phoneCountry: this.state.phoneCountry,
phoneNumber: this.state.phoneNumber,
2016-03-21 04:15:11 +03:00
});
if (promise) {
ev.target.disabled = true;
promise.finally(function() {
ev.target.disabled = false;
});
}
2020-08-29 14:14:16 +03:00
}
2016-03-21 04:15:11 +03:00
2020-11-19 18:10:40 +03:00
private 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.
2020-11-19 18:10:40 +03:00
const activeElement = document.activeElement as HTMLElement;
if (activeElement) {
activeElement.blur();
}
const fieldIDsInDisplayOrder = [
2020-11-19 18:10:40 +03:00
RegistrationField.Username,
RegistrationField.Password,
RegistrationField.PasswordConfirm,
RegistrationField.Email,
RegistrationField.PhoneNumber,
];
// Run all fields with stricter validation that no longer allows empty
// values for required fields.
for (const fieldID of fieldIDsInDisplayOrder) {
const field = this[fieldID];
if (!field) {
continue;
}
// We must wait for these validations to finish before queueing
// up the setState below so our setState goes in the queue after
// all the setStates from these validate calls (that's how we
// know they've finished).
await field.validate({ allowEmpty: false });
}
// Validation and state updates are async, so we need to wait for them to complete
// first. Queue a `setState` callback and wait for it to resolve.
await new Promise(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;
2020-08-29 14:14:16 +03:00
}
/**
* @returns {boolean} true if all fields were valid last time they were validated.
*/
2020-11-19 18:10:40 +03:00
private allFieldsValid() {
const keys = Object.keys(this.state.fieldValid);
for (let i = 0; i < keys.length; ++i) {
if (!this.state.fieldValid[keys[i]]) {
return false;
}
}
return true;
2020-08-29 14:14:16 +03:00
}
2020-11-19 18:10:40 +03:00
private findFirstInvalidField(fieldIDs: RegistrationField[]) {
for (const fieldID of fieldIDs) {
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
return this[fieldID];
}
}
return null;
2020-08-29 14:14:16 +03:00
}
2020-11-19 18:10:40 +03:00
private markFieldValid(fieldID: RegistrationField, valid: boolean) {
const { fieldValid } = this.state;
fieldValid[fieldID] = valid;
this.setState({
fieldValid,
});
2020-08-29 14:14:16 +03:00
}
2020-11-19 18:10:40 +03:00
private onEmailChange = ev => {
this.setState({
email: ev.target.value,
});
2020-08-29 14:14:16 +03:00
};
2020-11-19 18:10:40 +03:00
private onEmailValidate = async fieldState => {
2020-08-29 20:28:15 +03:00
const result = await this.validateEmailRules(fieldState);
2020-11-19 18:10:40 +03:00
this.markFieldValid(RegistrationField.Email, result.valid);
return result;
2020-08-29 14:14:16 +03:00
};
2020-11-19 18:10:40 +03:00
private validateEmailRules = withValidation({
description: () => _t("Use an email address to recover your account"),
hideDescriptionIfValid: true,
rules: [
{
key: "required",
2020-11-19 18:10:40 +03:00
test(this: RegistrationForm, { 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"),
},
],
2020-08-29 14:14:16 +03:00
});
2020-11-19 18:10:40 +03:00
private onPasswordChange = ev => {
this.setState({
password: ev.target.value,
});
2020-08-29 14:14:16 +03:00
};
2020-11-19 18:10:40 +03:00
private onPasswordValidate = result => {
this.markFieldValid(RegistrationField.Password, result.valid);
2020-08-29 14:14:16 +03:00
};
2020-11-19 18:10:40 +03:00
private onPasswordConfirmChange = ev => {
this.setState({
passwordConfirm: ev.target.value,
});
2020-08-29 14:14:16 +03:00
};
2020-11-19 18:10:40 +03:00
private onPasswordConfirmValidate = async fieldState => {
2020-08-29 20:28:15 +03:00
const result = await this.validatePasswordConfirmRules(fieldState);
2020-11-19 18:10:40 +03:00
this.markFieldValid(RegistrationField.PasswordConfirm, result.valid);
return result;
2020-08-29 14:14:16 +03:00
};
2020-11-19 18:10:40 +03:00
private validatePasswordConfirmRules = withValidation({
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("Confirm password"),
},
{
key: "match",
2020-11-19 18:10:40 +03:00
test(this: RegistrationForm, { value }) {
return !value || value === this.state.password;
},
invalid: () => _t("Passwords don't match"),
},
2020-11-19 18:10:40 +03:00
],
2020-08-29 14:14:16 +03:00
});
2020-11-19 18:10:40 +03:00
private onPhoneCountryChange = newVal => {
this.setState({
phoneCountry: newVal.iso2,
});
2020-08-29 14:14:16 +03:00
};
2020-11-19 18:10:40 +03:00
private onPhoneNumberChange = ev => {
this.setState({
phoneNumber: ev.target.value,
});
2020-08-29 14:14:16 +03:00
};
2020-11-19 18:10:40 +03:00
private onPhoneNumberValidate = async fieldState => {
2020-08-29 20:28:15 +03:00
const result = await this.validatePhoneNumberRules(fieldState);
2020-11-19 18:10:40 +03:00
this.markFieldValid(RegistrationField.PhoneNumber, result.valid);
return result;
2020-08-29 14:14:16 +03:00
};
2020-11-19 18:10:40 +03:00
private validatePhoneNumberRules = withValidation({
description: () => _t("Other users can invite you to rooms using your contact details"),
hideDescriptionIfValid: true,
rules: [
{
key: "required",
2020-11-19 18:10:40 +03:00
test(this: RegistrationForm, { 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"),
},
],
2020-08-29 14:14:16 +03:00
});
2020-11-19 18:10:40 +03:00
private onUsernameChange = ev => {
this.setState({
username: ev.target.value,
});
2020-08-29 14:14:16 +03:00
};
2020-11-19 18:10:40 +03:00
private onUsernameValidate = async fieldState => {
2020-08-29 20:28:15 +03:00
const result = await this.validateUsernameRules(fieldState);
2020-11-19 18:10:40 +03:00
this.markFieldValid(RegistrationField.Username, result.valid);
return result;
2020-08-29 14:14:16 +03:00
};
2020-11-19 18:10:40 +03:00
private validateUsernameRules = withValidation({
description: () => _t("Use lowercase letters, numbers, dashes and underscores only"),
hideDescriptionIfValid: true,
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"),
},
],
2020-08-29 14:14:16 +03:00
});
2019-02-01 01:37:37 +03:00
/**
* A step is required if all flows include that step.
*
* @param {string} step A stage name to check
* @returns {boolean} Whether it is required
*/
2020-11-19 18:10:40 +03:00
private authStepIsRequired(step: string) {
2019-02-01 01:37:37 +03:00
return this.props.flows.every((flow) => {
return flow.stages.includes(step);
});
2020-08-29 14:14:16 +03:00
}
/**
* A step is used if any flows include that step.
*
* @param {string} step A stage name to check
* @returns {boolean} Whether it is used
*/
2020-11-19 18:10:40 +03:00
private authStepIsUsed(step: string) {
return this.props.flows.some((flow) => {
return flow.stages.includes(step);
});
2020-08-29 14:14:16 +03:00
}
2020-11-19 18:10:40 +03:00
private showEmail() {
2019-08-07 13:15:56 +03:00
const haveIs = Boolean(this.props.serverConfig.isUrl);
if (
(this.props.serverRequiresIdServer && !haveIs) ||
2020-11-19 18:10:40 +03:00
!this.authStepIsUsed('m.login.email.identity')
) {
2019-08-07 13:15:56 +03:00
return false;
}
return true;
2020-08-29 14:14:16 +03:00
}
2019-08-07 13:15:56 +03:00
2020-11-19 18:10:40 +03:00
private showPhoneNumber() {
const threePidLogin = !SdkConfig.get().disable_3pid_login;
const haveIs = Boolean(this.props.serverConfig.isUrl);
if (
!threePidLogin ||
(this.props.serverRequiresIdServer && !haveIs) ||
2020-11-19 18:10:40 +03:00
!this.authStepIsUsed('m.login.msisdn')
) {
return false;
}
return true;
2020-08-29 14:14:16 +03:00
}
2020-11-19 18:10:40 +03:00
private renderEmail() {
if (!this.showEmail()) {
return null;
}
2020-11-19 18:10:40 +03:00
const emailPlaceholder = this.authStepIsRequired('m.login.email.identity') ?
_t("Email") :
_t("Email (optional)");
return <Field
2020-11-19 18:10:40 +03:00
ref={field => this[RegistrationField.Email] = field}
type="text"
label={emailPlaceholder}
value={this.state.email}
onChange={this.onEmailChange}
onValidate={this.onEmailValidate}
2020-10-29 18:53:14 +03:00
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_email_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_email_blur")}
/>;
2020-08-29 14:14:16 +03:00
}
2020-11-19 18:10:40 +03:00
private renderPassword() {
return <PassphraseField
id="mx_RegistrationForm_password"
2020-11-19 18:10:40 +03:00
fieldRef={field => this[RegistrationField.Password] = field}
minScore={PASSWORD_MIN_SCORE}
value={this.state.password}
onChange={this.onPasswordChange}
onValidate={this.onPasswordValidate}
2020-10-29 18:53:14 +03:00
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_password_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_password_blur")}
/>;
2020-08-29 14:14:16 +03:00
}
renderPasswordConfirm() {
return <Field
id="mx_RegistrationForm_passwordConfirm"
2020-11-19 18:10:40 +03:00
ref={field => this[RegistrationField.PasswordConfirm] = field}
type="password"
autoComplete="new-password"
2020-11-11 16:45:50 +03:00
label={_t("Confirm password")}
value={this.state.passwordConfirm}
onChange={this.onPasswordConfirmChange}
onValidate={this.onPasswordConfirmValidate}
2020-10-29 18:53:14 +03:00
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_passwordConfirm_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_passwordConfirm_blur")}
/>;
2020-08-29 14:14:16 +03:00
}
renderPhoneNumber() {
2020-11-19 18:10:40 +03:00
if (!this.showPhoneNumber()) {
return null;
}
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
2020-11-19 18:10:40 +03:00
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
2020-11-19 18:10:40 +03:00
ref={field => this[RegistrationField.PhoneNumber] = field}
type="text"
label={phoneLabel}
value={this.state.phoneNumber}
2020-05-28 23:09:42 +03:00
prefixComponent={phoneCountry}
onChange={this.onPhoneNumberChange}
onValidate={this.onPhoneNumberValidate}
/>;
2020-08-29 14:14:16 +03:00
}
renderUsername() {
return <Field
id="mx_RegistrationForm_username"
2020-11-19 18:10:40 +03:00
ref={field => this[RegistrationField.Username] = field}
type="text"
autoFocus={true}
label={_t("Username")}
placeholder={_t("Username").toLocaleLowerCase()}
value={this.state.username}
onChange={this.onUsernameChange}
onValidate={this.onUsernameValidate}
2020-10-29 18:53:14 +03:00
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_username_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_username_blur")}
/>;
2020-08-29 14:14:16 +03:00
}
2020-08-29 14:14:16 +03:00
render() {
const registerButton = (
<input className="mx_Login_submit" type="submit" value={_t("Register")} disabled={!this.props.canSubmit} />
);
let emailHelperText = null;
2020-11-19 18:10:40 +03:00
if (this.showEmail()) {
if (this.showPhoneNumber()) {
emailHelperText = <div>
{_t(
"Set an email for account recovery. " +
"Use email or phone to optionally be discoverable by existing contacts.",
)}
</div>;
} else {
emailHelperText = <div>
{_t(
"Set an email for account recovery. " +
"Use email to optionally be discoverable by existing contacts.",
)}
</div>;
}
}
2019-08-07 13:15:56 +03:00
const haveIs = Boolean(this.props.serverConfig.isUrl);
let noIsText = null;
if (this.props.serverRequiresIdServer && !haveIs) {
noIsText = <div>
{_t(
2019-10-28 17:23:11 +03:00
"No identity server is configured so you cannot add an email address in order to " +
"reset your password in the future.",
)}
</div>;
}
2019-08-07 13:15:56 +03:00
return (
<div>
<form onSubmit={this.onSubmit}>
2019-01-30 21:46:40 +03:00
<div className="mx_AuthBody_fieldRow">
{this.renderUsername()}
2019-01-30 00:52:12 +03:00
</div>
2019-01-30 21:46:40 +03:00
<div className="mx_AuthBody_fieldRow">
{this.renderPassword()}
{this.renderPasswordConfirm()}
2019-01-30 00:52:12 +03:00
</div>
2019-01-30 21:46:40 +03:00
<div className="mx_AuthBody_fieldRow">
{this.renderEmail()}
{this.renderPhoneNumber()}
2019-01-30 00:52:12 +03:00
</div>
2019-08-07 13:15:56 +03:00
{ emailHelperText }
{ noIsText }
{ registerButton }
</form>
</div>
);
2020-08-29 14:14:16 +03:00
}
}