/* Copyright 2024 New Vector Ltd. Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2015, 2016 , 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C. 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, ComponentProps, ReactNode } from "react"; import { MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import * as Email from "../../../email"; 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"; import withValidation, { IFieldState, IValidationResult } from "../elements/Validation"; import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig"; import EmailField from "./EmailField"; import PassphraseField from "./PassphraseField"; import Field from "../elements/Field"; import RegistrationEmailPromptDialog from "../dialogs/RegistrationEmailPromptDialog"; import CountryDropdown from "./CountryDropdown"; import PassphraseConfirmField from "./PassphraseConfirmField"; import { PosthogAnalytics } from "../../../PosthogAnalytics"; 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. 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; mobileRegister?: boolean; onRegisterClick(params: { username: string; password: string; email?: string; phoneCountry?: string; phoneNumber?: string; }): Promise; onEditServerDetailsClick?(): void; } interface IState { // Field error codes by field ID fieldValid: Partial>; // The ISO2 country code selected in the phone number entry phoneCountry?: string; username: string; email: string; phoneNumber: string; password: string; passwordConfirm: string; passwordComplexity?: number; } /* * A pure UI component which displays a registration form. */ export default class RegistrationForm extends React.PureComponent { 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; public static defaultProps = { onValidationChange: logger.error, canSubmit: true, }; public constructor(props: IProps) { super(props); 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 || "", }; } private onSubmit = async ( ev: BaseSyntheticEvent, ): Promise => { ev.preventDefault(); ev.persist(); if (!this.props.canSubmit) return; const allFieldsValid = await this.verifyFieldsBeforeSubmit(); if (!allFieldsValid) { return; } if (this.state.email === "") { if (this.showEmail()) { Modal.createDialog(RegistrationEmailPromptDialog, { onFinished: async (confirmed: boolean, email?: string): Promise => { if (confirmed && email !== undefined) { this.setState( { email, }, () => { this.doSubmit(ev); }, ); } }, }); } else { // user can't set an e-mail so don't prompt them to this.doSubmit(ev); return; } } else { this.doSubmit(ev); } }; private doSubmit( ev: BaseSyntheticEvent, ): void { PosthogAnalytics.instance.setAuthenticationType("Password"); const email = this.state.email.trim(); 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, }); if (promise) { ev.target.disabled = true; promise.finally(function () { ev.target.disabled = false; }); } } private async verifyFieldsBeforeSubmit(): Promise { // 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 as HTMLElement; if (activeElement) { activeElement.blur(); } const fieldIDsInDisplayOrder = [ 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; } /** * @returns {boolean} true if all fields were valid last time they were validated. */ private allFieldsValid(): boolean { return Object.values(this.state.fieldValid).every(Boolean); } private findFirstInvalidField(fieldIDs: RegistrationField[]): Field | null { for (const fieldID of fieldIDs) { if (!this.state.fieldValid[fieldID] && this[fieldID]) { return this[fieldID]; } } return null; } private markFieldValid(fieldID: RegistrationField, valid: boolean): void { const { fieldValid } = this.state; fieldValid[fieldID] = valid; this.setState({ fieldValid, }); } private onEmailChange = (ev: React.ChangeEvent): void => { this.setState({ email: ev.target.value.trim(), }); }; private onEmailValidate = (result: IValidationResult): void => { this.markFieldValid(RegistrationField.Email, !!result.valid); }; private validateEmailRules = withValidation({ description: () => _t("auth|reset_password_email_field_description"), hideDescriptionIfValid: true, rules: [ { key: "required", 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"), }, ], }); private onPasswordChange = (ev: React.ChangeEvent): void => { this.setState({ password: ev.target.value, }); }; private onPasswordValidate = (result: IValidationResult): void => { this.markFieldValid(RegistrationField.Password, !!result.valid); }; private onPasswordConfirmChange = (ev: React.ChangeEvent): void => { this.setState({ passwordConfirm: ev.target.value, }); }; private onPasswordConfirmValidate = (result: IValidationResult): void => { this.markFieldValid(RegistrationField.PasswordConfirm, !!result.valid); }; private onPhoneCountryChange = (newVal: PhoneNumberCountryDefinition): void => { this.setState({ phoneCountry: newVal.iso2, }); }; private onPhoneNumberChange = (ev: React.ChangeEvent): void => { this.setState({ phoneNumber: ev.target.value, }); }; private onPhoneNumberValidate = async (fieldState: IFieldState): Promise => { const result = await this.validatePhoneNumberRules(fieldState); this.markFieldValid(RegistrationField.PhoneNumber, !!result.valid); return result; }; private validatePhoneNumberRules = withValidation({ description: () => _t("auth|msisdn_field_description"), hideDescriptionIfValid: true, rules: [ { key: "required", 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"), }, ], }); private onUsernameChange = (ev: React.ChangeEvent): void => { this.setState({ username: ev.target.value, }); }; private onUsernameValidate = async (fieldState: IFieldState): Promise => { const result = await this.validateUsernameRules(fieldState); this.markFieldValid(RegistrationField.Username, !!result.valid); return result; }; private validateUsernameRules = withValidation({ 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 => { 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"), }, ], }); /** * 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 { return this.props.flows.every((flow) => { return flow.stages.includes(step); }); } /** * 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); }); } private showEmail(): boolean { const threePidLogin = !SdkConfig.get().disable_3pid_login; if (!threePidLogin || !this.authStepIsUsed("m.login.email.identity")) { return false; } return true; } private showPhoneNumber(): boolean { const threePidLogin = !SdkConfig.get().disable_3pid_login; if (!threePidLogin || !this.authStepIsUsed("m.login.msisdn")) { return false; } return true; } private tooltipAlignment(): ComponentProps["tooltipAlignment"] | undefined { if (this.props.mobileRegister) { return "bottom"; } return undefined; } private renderEmail(): ReactNode { 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 ( (this[RegistrationField.Email] = field)} label={emailLabel} value={this.state.email} validationRules={this.validateEmailRules.bind(this)} onChange={this.onEmailChange} onValidate={this.onEmailValidate} tooltipAlignment={this.tooltipAlignment()} /> ); } private renderPassword(): JSX.Element { return ( (this[RegistrationField.Password] = field)} minScore={PASSWORD_MIN_SCORE} value={this.state.password} onChange={this.onPasswordChange} onValidate={this.onPasswordValidate} userInputs={[this.state.username]} tooltipAlignment={this.tooltipAlignment()} /> ); } public renderPasswordConfirm(): JSX.Element { return ( (this[RegistrationField.PasswordConfirm] = field)} autoComplete="new-password" value={this.state.passwordConfirm} password={this.state.password} onChange={this.onPasswordConfirmChange} onValidate={this.onPasswordConfirmValidate} tooltipAlignment={this.tooltipAlignment()} /> ); } public renderPhoneNumber(): ReactNode { if (!this.showPhoneNumber()) { return null; } const phoneLabel = this.authStepIsRequired("m.login.msisdn") ? _t("auth|phone_label") : _t("auth|phone_optional_label"); const phoneCountry = ( ); return ( (this[RegistrationField.PhoneNumber] = field)} type="text" label={phoneLabel} value={this.state.phoneNumber} prefixComponent={phoneCountry} onChange={this.onPhoneNumberChange} onValidate={this.onPhoneNumberValidate} /> ); } public renderUsername(): ReactNode { return ( (this[RegistrationField.Username] = field)} type="text" autoFocus={true} label={_t("common|username")} placeholder={_t("common|username")} value={this.state.username} onChange={this.onUsernameChange} onValidate={this.onUsernameValidate} tooltipAlignment={this.tooltipAlignment()} autoCorrect="off" autoCapitalize="none" /> ); } public render(): ReactNode { const registerButton = ( ); let emailHelperText: JSX.Element | undefined; if (this.showEmail()) { if (this.showPhoneNumber()) { emailHelperText = (
{_t("auth|email_help_text")} {_t("auth|email_phone_discovery_text")}
); } else { emailHelperText = (
{_t("auth|email_help_text")} {_t("auth|email_discovery_text")}
); } } let passwordFields: JSX.Element | undefined; if (this.props.mobileRegister) { passwordFields = ( <>
{this.renderPassword()}
{this.renderPasswordConfirm()}
); } else { passwordFields = (
{this.renderPassword()} {this.renderPasswordConfirm()}
); } return (
{this.renderUsername()}
{passwordFields}
{this.renderEmail()} {this.renderPhoneNumber()}
{emailHelperText} {registerButton}
); } }