Files
ThreadNet-Web/src/components/views/auth/RegistrationForm.tsx
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

579 lines
20 KiB
TypeScript
Raw Normal View History

/*
2024-09-09 14:57:16 +01:00
Copyright 2024 New Vector Ltd.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
2024-09-09 14:57:16 +01:00
Copyright 2015, 2016 , 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C.
2024-09-09 14:57:16 +01:00
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { BaseSyntheticEvent, ReactNode } from "react";
import { MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
2021-12-09 09:10:23 +00:00
import { logger } from "matrix-js-sdk/src/logger";
2020-11-19 15:10:40 +00:00
2019-12-19 18:19:56 -07:00
import * as Email from "../../../email";
2023-02-13 11:39:16 +00:00
import { looksValid as phoneNumberLooksValid, PhoneNumberCountryDefinition } from "../../../phonenumber";
import Modal from "../../../Modal";
import { _t, _td } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import { SAFE_LOCALPART_REGEX } from "../../../Registration";
2023-02-13 11:39:16 +00:00
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
2022-07-14 15:03:34 +02:00
import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
import EmailField from "./EmailField";
import PassphraseField from "./PassphraseField";
import Field from "../elements/Field";
import RegistrationEmailPromptDialog from "../dialogs/RegistrationEmailPromptDialog";
2021-07-02 17:08:27 +02:00
import CountryDropdown from "./CountryDropdown";
import PassphraseConfirmField from "./PassphraseConfirmField";
import { PosthogAnalytics } from "../../../PosthogAnalytics";
2021-10-15 16:30:53 +02:00
2020-11-19 15:10:40 +00:00
enum RegistrationField {
Email = "field_email",
PhoneNumber = "field_phone_number",
Username = "field_username",
Password = "field_password",
PasswordConfirm = "field_password_confirm",
}
enum UsernameAvailableStatus {
Unknown,
Available,
Unavailable,
Error,
Invalid,
}
export const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario.
2020-11-19 15:10:40 +00: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;
matrixClient: MatrixClient;
2020-11-19 15:10:40 +00:00
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;
2020-11-19 15:10:40 +00:00
username: string;
email: string;
phoneNumber: string;
password: string;
passwordConfirm: string;
passwordComplexity?: number;
}
2020-08-29 12:57:11 +01:00
/*
* A pure UI component which displays a registration form.
*/
2020-11-19 15:10:40 +00:00
export default class RegistrationForm extends React.PureComponent<IProps, IState> {
private [RegistrationField.Email]: Field | null = null;
private [RegistrationField.Password]: Field | null = null;
private [RegistrationField.PasswordConfirm]: Field | null = null;
private [RegistrationField.Username]: Field | null = null;
private [RegistrationField.PhoneNumber]: Field | null = null;
2023-02-13 11:39:16 +00:00
public static defaultProps = {
2021-10-15 16:30:53 +02:00
onValidationChange: logger.error,
2020-08-29 12:14:16 +01:00
canSubmit: true,
};
2023-02-13 11:39:16 +00:00
public constructor(props: IProps) {
2020-08-29 12:14:16 +01:00
super(props);
this.state = {
fieldValid: {},
2017-03-14 11:50:13 +00:00
phoneCountry: this.props.defaultPhoneCountry,
username: this.props.defaultUsername || "",
email: this.props.defaultEmail || "",
phoneNumber: this.props.defaultPhoneNumber || "",
password: this.props.defaultPassword || "",
passwordConfirm: this.props.defaultPassword || "",
};
2020-08-29 12:14:16 +01:00
}
2023-02-13 11:39:16 +00:00
private onSubmit = async (
ev: BaseSyntheticEvent<Event, EventTarget & HTMLFormElement, EventTarget & HTMLFormElement>,
): Promise<void> => {
ev.preventDefault();
ev.persist();
if (!this.props.canSubmit) return;
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
2019-04-17 14:23:59 +01:00
if (!allFieldsValid) {
return;
}
if (this.state.email === "") {
if (this.showEmail()) {
2022-06-14 17:51:51 +01:00
Modal.createDialog(RegistrationEmailPromptDialog, {
onFinished: async (confirmed: boolean, email?: string): Promise<void> => {
if (confirmed && email !== undefined) {
this.setState(
{
email,
},
() => {
this.doSubmit(ev);
},
);
}
},
});
} else {
// user can't set an e-mail so don't prompt them to
2020-11-19 15:10:40 +00:00
this.doSubmit(ev);
return;
2019-08-07 11:15:56 +01:00
}
2019-04-17 14:23:59 +01:00
} else {
2020-11-19 15:10:40 +00:00
this.doSubmit(ev);
}
2020-08-29 12:14:16 +01:00
};
2023-02-13 11:39:16 +00:00
private doSubmit(
ev: BaseSyntheticEvent<Event, EventTarget & HTMLFormElement, EventTarget & HTMLFormElement>,
): void {
PosthogAnalytics.instance.setAuthenticationType("Password");
2019-03-14 14:29:04 +00:00
const email = this.state.email.trim();
2020-10-29 15:53:14 +00:00
2017-10-11 17:56:17 +01:00
const promise = this.props.onRegisterClick({
2019-03-14 14:29:04 +00:00
username: this.state.username.trim(),
password: this.state.password.trim(),
2017-01-19 10:51:40 +01:00
email: email,
2017-03-14 11:50:13 +00:00
phoneCountry: this.state.phoneCountry,
2019-03-14 14:29:04 +00:00
phoneNumber: this.state.phoneNumber,
2016-03-21 01:15:11 +00:00
});
if (promise) {
ev.target.disabled = true;
promise.finally(function () {
ev.target.disabled = false;
});
2016-08-03 15:59:17 +01:00
}
2020-08-29 12:14:16 +01:00
}
2016-03-21 01:15:11 +00:00
private async verifyFieldsBeforeSubmit(): Promise<boolean> {
2019-04-25 11:27:03 +01:00
// 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 15:10:40 +00:00
const activeElement = document.activeElement as HTMLElement;
2019-04-25 11:27:03 +01:00
if (activeElement) {
activeElement.blur();
}
const fieldIDsInDisplayOrder = [
2020-11-19 15:10:40 +00: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
2019-05-13 14:24:56 +01:00
// 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<void>((resolve) => this.setState({}, resolve));
if (this.allFieldsValid()) {
return true;
}
const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder);
2019-04-17 14:23:59 +01:00
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.
2019-04-17 14:23:59 +01:00
invalidField.focus();
invalidField.validate({ allowEmpty: false, focused: true });
2019-04-17 14:23:59 +01:00
return false;
2020-08-29 12:14:16 +01:00
}
2019-04-17 14:23:59 +01:00
/**
2019-01-23 18:32:36 -06:00
* @returns {boolean} true if all fields were valid last time they were validated.
*/
2022-11-07 13:45:34 +00:00
private allFieldsValid(): boolean {
return Object.values(this.state.fieldValid).every(Boolean);
2020-08-29 12:14:16 +01:00
}
private findFirstInvalidField(fieldIDs: RegistrationField[]): Field | null {
2019-04-17 14:23:59 +01:00
for (const fieldID of fieldIDs) {
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
return this[fieldID];
}
}
return null;
2020-08-29 12:14:16 +01:00
}
2019-04-17 14:23:59 +01:00
private markFieldValid(fieldID: RegistrationField, valid: boolean): void {
const { fieldValid } = this.state;
fieldValid[fieldID] = valid;
this.setState({
fieldValid,
});
2020-08-29 12:14:16 +01:00
}
2023-02-13 11:39:16 +00:00
private onEmailChange = (ev: React.ChangeEvent<HTMLInputElement>): void => {
2019-03-14 14:29:04 +00:00
this.setState({
2022-07-09 02:10:52 +05:30
email: ev.target.value.trim(),
2019-03-14 14:29:04 +00:00
});
2020-08-29 12:14:16 +01:00
};
2019-03-14 14:29:04 +00:00
private onEmailValidate = (result: IValidationResult): void => {
this.markFieldValid(RegistrationField.Email, !!result.valid);
2020-08-29 12:14:16 +01:00
};
2020-11-19 15:10:40 +00:00
private validateEmailRules = withValidation({
description: () => _t("auth|reset_password_email_field_description"),
hideDescriptionIfValid: true,
rules: [
{
key: "required",
2020-11-19 15:10:40 +00:00
test(this: RegistrationForm, { value, allowEmpty }) {
return allowEmpty || !this.authStepIsRequired("m.login.email.identity") || !!value;
},
invalid: () => _t("auth|reset_password_email_field_required_invalid"),
},
{
key: "email",
test: ({ value }) => !value || Email.looksValid(value),
invalid: () => _t("auth|email_field_label_invalid"),
},
],
2020-08-29 12:14:16 +01:00
});
2023-02-13 11:39:16 +00:00
private onPasswordChange = (ev: React.ChangeEvent<HTMLInputElement>): void => {
2019-03-14 14:29:04 +00:00
this.setState({
password: ev.target.value,
});
2020-08-29 12:14:16 +01:00
};
2019-03-14 14:29:04 +00:00
private onPasswordValidate = (result: IValidationResult): void => {
this.markFieldValid(RegistrationField.Password, !!result.valid);
2020-08-29 12:14:16 +01:00
};
2023-02-13 11:39:16 +00:00
private onPasswordConfirmChange = (ev: React.ChangeEvent<HTMLInputElement>): void => {
2019-03-14 14:29:04 +00:00
this.setState({
passwordConfirm: ev.target.value,
});
2020-08-29 12:14:16 +01:00
};
2019-03-14 14:29:04 +00:00
private onPasswordConfirmValidate = (result: IValidationResult): void => {
this.markFieldValid(RegistrationField.PasswordConfirm, !!result.valid);
2020-08-29 12:14:16 +01:00
};
2023-02-13 11:39:16 +00:00
private onPhoneCountryChange = (newVal: PhoneNumberCountryDefinition): void => {
2017-03-14 11:50:13 +00:00
this.setState({
phoneCountry: newVal.iso2,
2017-03-14 11:50:13 +00:00
});
2020-08-29 12:14:16 +01:00
};
2017-03-14 11:50:13 +00:00
2023-02-13 11:39:16 +00:00
private onPhoneNumberChange = (ev: React.ChangeEvent<HTMLInputElement>): void => {
2019-03-14 14:29:04 +00:00
this.setState({
phoneNumber: ev.target.value,
});
2020-08-29 12:14:16 +01:00
};
2019-03-14 14:29:04 +00:00
2023-02-13 11:39:16 +00:00
private onPhoneNumberValidate = async (fieldState: IFieldState): Promise<IValidationResult> => {
2020-08-29 18:28:15 +01:00
const result = await this.validatePhoneNumberRules(fieldState);
this.markFieldValid(RegistrationField.PhoneNumber, !!result.valid);
return result;
2020-08-29 12:14:16 +01:00
};
2020-11-19 15:10:40 +00:00
private validatePhoneNumberRules = withValidation({
description: () => _t("auth|msisdn_field_description"),
hideDescriptionIfValid: true,
rules: [
{
key: "required",
2020-11-19 15:10:40 +00:00
test(this: RegistrationForm, { value, allowEmpty }) {
return allowEmpty || !this.authStepIsRequired("m.login.msisdn") || !!value;
},
invalid: () => _t("auth|registration_msisdn_field_required_invalid"),
},
{
key: "email",
test: ({ value }) => !value || phoneNumberLooksValid(value),
invalid: () => _t("auth|msisdn_field_number_invalid"),
},
],
2020-08-29 12:14:16 +01:00
});
2023-02-13 11:39:16 +00:00
private onUsernameChange = (ev: React.ChangeEvent<HTMLInputElement>): void => {
2019-03-14 14:29:04 +00:00
this.setState({
username: ev.target.value,
});
2020-08-29 12:14:16 +01:00
};
2019-03-14 14:29:04 +00:00
2023-02-13 11:39:16 +00:00
private onUsernameValidate = async (fieldState: IFieldState): Promise<IValidationResult> => {
2020-08-29 18:28:15 +01:00
const result = await this.validateUsernameRules(fieldState);
this.markFieldValid(RegistrationField.Username, !!result.valid);
return result;
2020-08-29 12:14:16 +01:00
};
private validateUsernameRules = withValidation<this, UsernameAvailableStatus>({
description: (_, results) => {
// omit the description if the only failing result is the `available` one as it makes no sense for it.
if (results.every(({ key, valid }) => key === "available" || valid)) return null;
return _t("auth|registration_username_validation");
},
hideDescriptionIfValid: true,
async deriveData(this: RegistrationForm, { value }) {
if (!value) {
return UsernameAvailableStatus.Unknown;
}
try {
const available = await this.props.matrixClient.isUsernameAvailable(value);
return available ? UsernameAvailableStatus.Available : UsernameAvailableStatus.Unavailable;
} catch (err) {
if (err instanceof MatrixError && err.errcode === "M_INVALID_USERNAME") {
return UsernameAvailableStatus.Invalid;
}
return UsernameAvailableStatus.Error;
}
},
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("auth|username_field_required_invalid"),
},
{
key: "safeLocalpart",
test: ({ value }, usernameAvailable) =>
(!value || SAFE_LOCALPART_REGEX.test(value)) &&
usernameAvailable !== UsernameAvailableStatus.Invalid,
invalid: () => _t("room_settings|general|alias_field_safe_localpart_invalid"),
},
{
key: "available",
final: true,
test: async ({ value }, usernameAvailable): Promise<boolean> => {
if (!value) {
return true;
}
return usernameAvailable === UsernameAvailableStatus.Available;
},
invalid: (usernameAvailable) =>
usernameAvailable === UsernameAvailableStatus.Error
? _t("auth|registration_username_unable_check")
: _t("auth|registration_username_in_use"),
},
],
2020-08-29 12:14:16 +01:00
});
2019-01-31 16:37:37 -06: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
*/
private authStepIsRequired(step: string): boolean {
2019-01-31 16:37:37 -06:00
return this.props.flows.every((flow) => {
return flow.stages.includes(step);
2018-09-04 18:26:09 +01:00
});
2020-08-29 12:14:16 +01:00
}
2018-09-04 18:26:09 +01: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
*/
private authStepIsUsed(step: string): boolean {
return this.props.flows.some((flow) => {
return flow.stages.includes(step);
});
2020-08-29 12:14:16 +01:00
}
private showEmail(): boolean {
const threePidLogin = !SdkConfig.get().disable_3pid_login;
if (!threePidLogin || !this.authStepIsUsed("m.login.email.identity")) {
2019-08-07 11:15:56 +01:00
return false;
}
return true;
2020-08-29 12:14:16 +01:00
}
2019-08-07 11:15:56 +01:00
private showPhoneNumber(): boolean {
2019-08-28 10:34:50 -04:00
const threePidLogin = !SdkConfig.get().disable_3pid_login;
if (!threePidLogin || !this.authStepIsUsed("m.login.msisdn")) {
2019-08-28 10:34:50 -04:00
return false;
}
return true;
2020-08-29 12:14:16 +01:00
}
2019-08-28 10:34:50 -04:00
private renderEmail(): ReactNode {
2020-11-19 15:10:40 +00:00
if (!this.showEmail()) {
return null;
}
const emailLabel = this.authStepIsRequired("m.login.email.identity")
? _td("auth|email_field_label")
: _td("auth|registration|continue_without_email_field_label");
return (
<EmailField
fieldRef={(field) => (this[RegistrationField.Email] = field)}
label={emailLabel}
value={this.state.email}
validationRules={this.validateEmailRules.bind(this)}
onChange={this.onEmailChange}
onValidate={this.onEmailValidate}
/>
);
2020-08-29 12:14:16 +01:00
}
private renderPassword(): JSX.Element {
return (
<PassphraseField
id="mx_RegistrationForm_password"
2020-11-19 15:10:40 +00:00
fieldRef={(field) => (this[RegistrationField.Password] = field)}
minScore={PASSWORD_MIN_SCORE}
value={this.state.password}
onChange={this.onPasswordChange}
onValidate={this.onPasswordValidate}
userInputs={[this.state.username]}
/>
);
2020-08-29 12:14:16 +01:00
}
public renderPasswordConfirm(): JSX.Element {
return (
<PassphraseConfirmField
id="mx_RegistrationForm_passwordConfirm"
fieldRef={(field) => (this[RegistrationField.PasswordConfirm] = field)}
autoComplete="new-password"
value={this.state.passwordConfirm}
password={this.state.password}
onChange={this.onPasswordConfirmChange}
onValidate={this.onPasswordConfirmValidate}
/>
);
2020-08-29 12:14:16 +01:00
}
public renderPhoneNumber(): ReactNode {
2020-11-19 15:10:40 +00:00
if (!this.showPhoneNumber()) {
return null;
}
const phoneLabel = this.authStepIsRequired("m.login.msisdn")
? _t("auth|phone_label")
: _t("auth|phone_optional_label");
const phoneCountry = (
<CountryDropdown
value={this.state.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChange}
/>
);
return (
<Field
2020-11-19 15:10:40 +00:00
ref={(field) => (this[RegistrationField.PhoneNumber] = field)}
type="text"
label={phoneLabel}
value={this.state.phoneNumber}
2020-05-28 21:09:42 +01:00
prefixComponent={phoneCountry}
onChange={this.onPhoneNumberChange}
onValidate={this.onPhoneNumberValidate}
/>
);
2020-08-29 12:14:16 +01:00
}
public renderUsername(): ReactNode {
2019-04-16 16:52:31 +01:00
return (
<Field
id="mx_RegistrationForm_username"
2020-11-19 15:10:40 +00:00
ref={(field) => (this[RegistrationField.Username] = field)}
2019-04-16 16:52:31 +01:00
type="text"
autoFocus={true}
label={_t("common|username")}
placeholder={_t("common|username")}
2019-04-16 16:52:31 +01:00
value={this.state.username}
onChange={this.onUsernameChange}
onValidate={this.onUsernameValidate}
2019-04-16 16:52:31 +01:00
/>
);
2020-08-29 12:14:16 +01:00
}
2019-04-16 16:52:31 +01:00
public render(): ReactNode {
const registerButton = (
<input
className="mx_Login_submit"
type="submit"
value={_t("action|register")}
disabled={!this.props.canSubmit}
/>
);
let emailHelperText: JSX.Element | undefined;
2020-11-19 15:10:40 +00:00
if (this.showEmail()) {
if (this.showPhoneNumber()) {
2019-08-28 10:34:50 -04:00
emailHelperText = (
<div>
{_t("auth|email_help_text")} {_t("auth|email_phone_discovery_text")}
2019-08-28 10:34:50 -04:00
</div>
);
} else {
emailHelperText = (
<div>
{_t("auth|email_help_text")} {_t("auth|email_discovery_text")}
2019-08-28 10:34:50 -04:00
</div>
);
}
}
2019-08-07 11:15:56 +01:00
return (
<div>
<form onSubmit={this.onSubmit}>
2019-01-30 12:46:40 -06:00
<div className="mx_AuthBody_fieldRow">{this.renderUsername()}</div>
<div className="mx_AuthBody_fieldRow">
{this.renderPassword()}
{this.renderPasswordConfirm()}
2019-01-29 15:52:12 -06:00
</div>
2019-01-30 12:46:40 -06:00
<div className="mx_AuthBody_fieldRow">
{this.renderEmail()}
{this.renderPhoneNumber()}
2019-01-29 15:52:12 -06:00
</div>
2019-08-07 11:15:56 +01:00
{emailHelperText}
2017-10-11 17:56:17 +01:00
{registerButton}
</form>
</div>
);
2020-08-29 12:14:16 +01:00
}
}