diff --git a/src/components/structures/auth/Login.js b/src/components/structures/auth/Login.tsx similarity index 71% rename from src/components/structures/auth/Login.js rename to src/components/structures/auth/Login.tsx index c3cbac0442..4cd8981a65 100644 --- a/src/components/structures/auth/Login.js +++ b/src/components/structures/auth/Login.tsx @@ -1,7 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018, 2019 New Vector Ltd +Copyright 2015, 2016, 2017, 2018, 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. @@ -16,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, {ComponentProps, ReactNode} from 'react'; + import {_t, _td} from '../../../languageHandler'; import * as sdk from '../../../index'; import Login from '../../../Login'; @@ -31,15 +29,12 @@ import PlatformPeg from '../../../PlatformPeg'; import SettingsStore from "../../../settings/SettingsStore"; import {UIFeature} from "../../../settings/UIFeature"; import CountlyAnalytics from "../../../CountlyAnalytics"; - -// For validating phone numbers without country codes -const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; - -// Phases -// Show controls to configure server details -const PHASE_SERVER_DETAILS = 0; -// Show the appropriate login flow(s) for the server -const PHASE_LOGIN = 1; +import {IMatrixClientCreds} from "../../../MatrixClientPeg"; +import ServerConfig from "../../views/auth/ServerConfig"; +import PasswordLogin from "../../views/auth/PasswordLogin"; +import SignInToText from "../../views/auth/SignInToText"; +import InlineSpinner from "../../views/elements/InlineSpinner"; +import Spinner from "../../views/elements/Spinner"; // Enable phases for login const PHASES_ENABLED = true; @@ -55,64 +50,88 @@ _td("Invalid base_url for m.identity_server"); _td("Identity server URL does not appear to be a valid identity server"); _td("General failure"); +interface IProps { + serverConfig: ValidatedServerConfig; + // If true, the component will consider itself busy. + busy?: boolean; + isSyncing?: boolean; + // Secondary HS which we try to log into if the user is using + // the default HS but login fails. Useful for migrating to a + // different homeserver without confusing users. + fallbackHsUrl?: string; + defaultDeviceDisplayName?: string; + fragmentAfterLogin?: string; + + // Called when the user has logged in. Params: + // - The object returned by the login API + // - The user's password, if applicable, (may be cached in memory for a + // short time so the user is not required to re-enter their password + // for operations like uploading cross-signing keys). + onLoggedIn(data: IMatrixClientCreds, password: string): void; + + // login shouldn't know or care how registration, password recovery, etc is done. + onRegisterClick(): void; + onForgotPasswordClick?(): void; + onServerConfigChange(config: ValidatedServerConfig): void; +} + +enum Phase { + // Show controls to configure server details + ServerDetails, + // Show the appropriate login flow(s) for the server + Login, +} + +interface IState { + busy: boolean; + busyLoggingIn?: boolean; + errorText?: ReactNode; + loginIncorrect: boolean; + // can we attempt to log in or are there validation errors? + canTryLogin: boolean; + + // used for preserving form values when changing homeserver + username: string; + phoneCountry?: string; + phoneNumber: string; + + // Phase of the overall login dialog. + phase: Phase; + // The current login flow, such as password, SSO, etc. + // we need to load the flows from the server + currentFlow?: string; + + // We perform liveliness checks later, but for now suppress the errors. + // We also track the server dead errors independently of the regular errors so + // that we can render it differently, and override any other error the user may + // be seeing. + serverIsAlive: boolean; + serverErrorIsFatal: boolean; + serverDeadError: string; +} + /* * A wire component which glues together login UI components and Login logic */ -export default class LoginComponent extends React.Component { - static propTypes = { - // Called when the user has logged in. Params: - // - The object returned by the login API - // - The user's password, if applicable, (may be cached in memory for a - // short time so the user is not required to re-enter their password - // for operations like uploading cross-signing keys). - onLoggedIn: PropTypes.func.isRequired, - - // If true, the component will consider itself busy. - busy: PropTypes.bool, - - // Secondary HS which we try to log into if the user is using - // the default HS but login fails. Useful for migrating to a - // different homeserver without confusing users. - fallbackHsUrl: PropTypes.string, - - defaultDeviceDisplayName: PropTypes.string, - - // login shouldn't know or care how registration, password recovery, - // etc is done. - onRegisterClick: PropTypes.func.isRequired, - onForgotPasswordClick: PropTypes.func, - onServerConfigChange: PropTypes.func.isRequired, - - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - isSyncing: PropTypes.bool, - }; +export default class LoginComponent extends React.Component { + private unmounted = false; + private loginLogic: Login; + private readonly stepRendererMap: Record ReactNode>; constructor(props) { super(props); - this._unmounted = false; - this.state = { busy: false, busyLoggingIn: null, errorText: null, loginIncorrect: false, - canTryLogin: true, // can we attempt to log in or are there validation errors? - - // used for preserving form values when changing homeserver + canTryLogin: true, username: "", phoneCountry: null, phoneNumber: "", - - // Phase of the overall login dialog. - phase: PHASE_LOGIN, - // The current login flow, such as password, SSO, etc. - currentFlow: null, // we need to load the flows from the server - - // We perform liveliness checks later, but for now suppress the errors. - // We also track the server dead errors independently of the regular errors so - // that we can render it differently, and override any other error the user may - // be seeing. + phase: Phase.Login, + currentFlow: null, serverIsAlive: true, serverErrorIsFatal: false, serverDeadError: "", @@ -120,12 +139,12 @@ export default class LoginComponent extends React.Component { // map from login step type to a function which will render a control // letting you do that login type - this._stepRendererMap = { - 'm.login.password': this._renderPasswordStep, + this.stepRendererMap = { + 'm.login.password': this.renderPasswordStep, // CAS and SSO are the same thing, modulo the url we link to - 'm.login.cas': () => this._renderSsoStep("cas"), - 'm.login.sso': () => this._renderSsoStep("sso"), + 'm.login.cas': () => this.renderSsoStep("cas"), + 'm.login.sso': () => this.renderSsoStep("sso"), }; CountlyAnalytics.instance.track("onboarding_login_begin"); @@ -134,11 +153,11 @@ export default class LoginComponent extends React.Component { // TODO: [REACT-WARNING] Replace with appropriate lifecycle event // eslint-disable-next-line camelcase UNSAFE_componentWillMount() { - this._initLoginLogic(); + this.initLoginLogic(this.props.serverConfig); } componentWillUnmount() { - this._unmounted = true; + this.unmounted = true; } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event @@ -148,16 +167,9 @@ export default class LoginComponent extends React.Component { newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; // Ensure that we end up actually logging in to the right place - this._initLoginLogic(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl); + this.initLoginLogic(newProps.serverConfig); } - onPasswordLoginError = errorText => { - this.setState({ - errorText, - loginIncorrect: Boolean(errorText), - }); - }; - isBusy = () => this.state.busy || this.props.busy; onPasswordLogin = async (username, phoneCountry, phoneNumber, password) => { @@ -194,13 +206,13 @@ export default class LoginComponent extends React.Component { loginIncorrect: false, }); - this._loginLogic.loginViaPassword( + this.loginLogic.loginViaPassword( username, phoneCountry, phoneNumber, password, ).then((data) => { this.setState({serverIsAlive: true}); // it must be, we logged in. this.props.onLoggedIn(data, password); }, (error) => { - if (this._unmounted) { + if (this.unmounted) { return; } let errorText; @@ -212,21 +224,23 @@ export default class LoginComponent extends React.Component { } else if (error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { const errorTop = messageForResourceLimitError( error.data.limit_type, - error.data.admin_contact, { - 'monthly_active_user': _td( - "This homeserver has hit its Monthly Active User limit.", - ), - '': _td( - "This homeserver has exceeded one of its resource limits.", - ), - }); + error.data.admin_contact, + { + 'monthly_active_user': _td( + "This homeserver has hit its Monthly Active User limit.", + ), + '': _td( + "This homeserver has exceeded one of its resource limits.", + ), + }, + ); const errorDetail = messageForResourceLimitError( error.data.limit_type, - error.data.admin_contact, { - '': _td( - "Please contact your service administrator to continue using this service.", - ), - }); + error.data.admin_contact, + { + '': _td("Please contact your service administrator to continue using this service."), + }, + ); errorText = (
{errorTop}
@@ -253,7 +267,7 @@ export default class LoginComponent extends React.Component { } } else { // other errors, not specific to doing a password login - errorText = this._errorTextFromError(error); + errorText = this.errorTextFromError(error); } this.setState({ @@ -291,7 +305,7 @@ export default class LoginComponent extends React.Component { // the busy state. In the case of a full MXID that resolves to the same // HS as Element's default HS though, there may not be any server change. // To avoid this trap, we clear busy here. For cases where the server - // actually has changed, `_initLoginLogic` will be called and manages + // actually has changed, `initLoginLogic` will be called and manages // busy state for its own liveness check. this.setState({ busy: false, @@ -304,7 +318,7 @@ export default class LoginComponent extends React.Component { message = e.translatedMessage; } - let errorText = message; + let errorText: ReactNode = message; let discoveryState = {}; if (AutoDiscoveryUtils.isLivelinessError(e)) { errorText = this.state.errorText; @@ -330,21 +344,6 @@ export default class LoginComponent extends React.Component { }); }; - onPhoneNumberBlur = phoneNumber => { - // Validate the phone number entered - if (!PHONE_NUMBER_REGEX.test(phoneNumber)) { - this.setState({ - errorText: _t('The phone number entered looks invalid'), - canTryLogin: false, - }); - } else { - this.setState({ - errorText: null, - canTryLogin: true, - }); - } - }; - onRegisterClick = ev => { ev.preventDefault(); ev.stopPropagation(); @@ -352,14 +351,14 @@ export default class LoginComponent extends React.Component { }; onTryRegisterClick = ev => { - const step = this._getCurrentFlowStep(); + const step = this.getCurrentFlowStep(); if (step === 'm.login.sso' || step === 'm.login.cas') { // If we're showing SSO it means that registration is also probably disabled, // so intercept the click and instead pretend the user clicked 'Sign in with SSO'. ev.preventDefault(); ev.stopPropagation(); const ssoKind = step === 'm.login.sso' ? 'sso' : 'cas'; - PlatformPeg.get().startSingleSignOn(this._loginLogic.createTemporaryClient(), ssoKind, + PlatformPeg.get().startSingleSignOn(this.loginLogic.createTemporaryClient(), ssoKind, this.props.fragmentAfterLogin); } else { // Don't intercept - just go through to the register page @@ -367,24 +366,21 @@ export default class LoginComponent extends React.Component { } }; - onServerDetailsNextPhaseClick = () => { + private onServerDetailsNextPhaseClick = () => { this.setState({ - phase: PHASE_LOGIN, + phase: Phase.Login, }); }; - onEditServerDetailsClick = ev => { + private onEditServerDetailsClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.setState({ - phase: PHASE_SERVER_DETAILS, + phase: Phase.ServerDetails, }); }; - async _initLoginLogic(hsUrl, isUrl) { - hsUrl = hsUrl || this.props.serverConfig.hsUrl; - isUrl = isUrl || this.props.serverConfig.isUrl; - + private async initLoginLogic({hsUrl, isUrl}: ValidatedServerConfig) { let isDefaultServer = false; if (this.props.serverConfig.isDefault && hsUrl === this.props.serverConfig.hsUrl @@ -397,7 +393,7 @@ export default class LoginComponent extends React.Component { const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, { defaultDeviceDisplayName: this.props.defaultDeviceDisplayName, }); - this._loginLogic = loginLogic; + this.loginLogic = loginLogic; this.setState({ busy: true, @@ -428,7 +424,7 @@ export default class LoginComponent extends React.Component { if (this.state.serverErrorIsFatal) { // Server is dead: show server details prompt instead this.setState({ - phase: PHASE_SERVER_DETAILS, + phase: Phase.ServerDetails, }); return; } @@ -437,7 +433,7 @@ export default class LoginComponent extends React.Component { loginLogic.getFlows().then((flows) => { // look for a flow where we understand all of the steps. for (let i = 0; i < flows.length; i++ ) { - if (!this._isSupportedFlow(flows[i])) { + if (!this.isSupportedFlow(flows[i])) { continue; } @@ -446,7 +442,7 @@ export default class LoginComponent extends React.Component { // that for now). loginLogic.chooseFlow(i); this.setState({ - currentFlow: this._getCurrentFlowStep(), + currentFlow: this.getCurrentFlowStep(), }); return; } @@ -460,7 +456,7 @@ export default class LoginComponent extends React.Component { }); }, (err) => { this.setState({ - errorText: this._errorTextFromError(err), + errorText: this.errorTextFromError(err), loginIncorrect: false, canTryLogin: false, }); @@ -471,28 +467,28 @@ export default class LoginComponent extends React.Component { }); } - _isSupportedFlow(flow) { + private isSupportedFlow(flow) { // technically the flow can have multiple steps, but no one does this // for login and loginLogic doesn't support it so we can ignore it. - if (!this._stepRendererMap[flow.type]) { + if (!this.stepRendererMap[flow.type]) { console.log("Skipping flow", flow, "due to unsupported login type", flow.type); return false; } return true; } - _getCurrentFlowStep() { - return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null; + private getCurrentFlowStep() { + return this.loginLogic ? this.loginLogic.getCurrentFlowStep() : null; } - _errorTextFromError(err) { + private errorTextFromError(err) { let errCode = err.errcode; if (!errCode && err.httpStatus) { errCode = "HTTP " + err.httpStatus; } - let errorText = _t("Error: Problem communicating with the given homeserver.") + - (errCode ? " (" + errCode + ")" : ""); + let errorText: ReactNode = _t("Error: Problem communicating with the given homeserver.") + + (errCode ? " (" + errCode + ")" : ""); if (err.cors === 'rejected') { if (window.location.protocol === 'https:' && @@ -502,29 +498,27 @@ export default class LoginComponent extends React.Component { errorText = { _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " + "Either use HTTPS or enable unsafe scripts.", {}, - { - 'a': (sub) => { - return - { sub } - ; - }, + { + 'a': (sub) => { + return + { sub } + ; }, - ) } + }) } ; } else { errorText = { _t("Can't connect to homeserver - please check your connectivity, ensure your " + "homeserver's SSL certificate is trusted, and that a browser extension " + "is not blocking requests.", {}, - { - 'a': (sub) => - - { sub } - , - }, - ) } + { + 'a': (sub) => + + { sub } + , + }) } ; } } @@ -532,18 +526,16 @@ export default class LoginComponent extends React.Component { return errorText; } - renderServerComponent() { - const ServerConfig = sdk.getComponent("auth.ServerConfig"); - + private renderServerComponent() { if (SdkConfig.get()['disable_custom_urls']) { return null; } - if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) { + if (PHASES_ENABLED && this.state.phase !== Phase.ServerDetails) { return null; } - const serverDetailsProps = {}; + const serverDetailsProps: ComponentProps = {}; if (PHASES_ENABLED) { serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick; serverDetailsProps.submitText = _t("Next"); @@ -558,8 +550,8 @@ export default class LoginComponent extends React.Component { />; } - renderLoginComponentForStep() { - if (PHASES_ENABLED && this.state.phase !== PHASE_LOGIN) { + private renderLoginComponentForStep() { + if (PHASES_ENABLED && this.state.phase !== Phase.Login) { return null; } @@ -569,7 +561,7 @@ export default class LoginComponent extends React.Component { return null; } - const stepRenderer = this._stepRendererMap[step]; + const stepRenderer = this.stepRendererMap[step]; if (stepRenderer) { return stepRenderer(); @@ -578,9 +570,7 @@ export default class LoginComponent extends React.Component { return null; } - _renderPasswordStep = () => { - const PasswordLogin = sdk.getComponent('auth.PasswordLogin'); - + private renderPasswordStep = () => { let onEditServerDetailsClick = null; // If custom URLs are allowed, wire up the server details edit link. if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) { @@ -589,29 +579,25 @@ export default class LoginComponent extends React.Component { return ( ); }; - _renderSsoStep = loginType => { - const SignInToText = sdk.getComponent('views.auth.SignInToText'); - + private renderSsoStep = loginType => { let onEditServerDetailsClick = null; // If custom URLs are allowed, wire up the server details edit link. if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) { @@ -632,7 +618,7 @@ export default class LoginComponent extends React.Component { @@ -641,12 +627,10 @@ export default class LoginComponent extends React.Component { }; render() { - const Loader = sdk.getComponent("elements.Spinner"); - const InlineSpinner = sdk.getComponent("elements.InlineSpinner"); const AuthHeader = sdk.getComponent("auth.AuthHeader"); const AuthBody = sdk.getComponent("auth.AuthBody"); const loader = this.isBusy() && !this.state.busyLoggingIn ? -
: null; +
: null; const errorText = this.state.errorText; diff --git a/src/components/structures/auth/Registration.js b/src/components/structures/auth/Registration.tsx similarity index 78% rename from src/components/structures/auth/Registration.js rename to src/components/structures/auth/Registration.tsx index 80bf3b72cd..f97f20cf59 100644 --- a/src/components/structures/auth/Registration.js +++ b/src/components/structures/auth/Registration.tsx @@ -1,8 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018, 2019 New Vector Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +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. @@ -18,8 +15,9 @@ limitations under the License. */ import Matrix from 'matrix-js-sdk'; -import React from 'react'; -import PropTypes from 'prop-types'; +import React, {ComponentProps, ReactNode} from 'react'; +import {MatrixClient} from "matrix-js-sdk/src/client"; + import * as sdk from '../../../index'; import { _t, _td } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; @@ -34,36 +32,96 @@ import Login from "../../../Login"; import dis from "../../../dispatcher/dispatcher"; // Phases -// Show controls to configure server details -const PHASE_SERVER_DETAILS = 0; -// Show the appropriate registration flow(s) for the server -const PHASE_REGISTRATION = 1; +enum Phase { + // Show controls to configure server details + ServerDetails = 0, + // Show the appropriate registration flow(s) for the server + Registration = 1, +} + +interface IProps { + serverConfig: ValidatedServerConfig; + defaultDeviceDisplayName: string; + email?: string; + brand?: string; + clientSecret?: string; + sessionId?: string; + idSid?: string; + + // Called when the user has logged in. Params: + // - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken + // - The user's password, if available and applicable (may be cached in memory + // for a short time so the user is not required to re-enter their password + // for operations like uploading cross-signing keys). + onLoggedIn(params: { + userId: string; + deviceId: string + homeserverUrl: string; + identityServerUrl?: string; + accessToken: string; + }, password: string): void; + makeRegistrationUrl(params: { + /* eslint-disable camelcase */ + client_secret: string; + hs_url: string; + is_url?: string; + session_id: string; + /* eslint-enable camelcase */ + }): void; + // registration shouldn't know or care how login is done. + onLoginClick(): void; + onServerConfigChange(config: ValidatedServerConfig): void; +} + +interface IState { + busy: boolean; + errorText?: ReactNode; + // true if we're waiting for the user to complete + // We remember the values entered by the user because + // the registration form will be unmounted during the + // course of registration, but if there's an error we + // want to bring back the registration form with the + // values the user entered still in it. We can keep + // them in this component's state since this component + // persist for the duration of the registration process. + formVals: Record; + // user-interactive auth + // If we've been given a session ID, we're resuming + // straight back into UI auth + doingUIAuth: boolean; + // If set, we've registered but are not going to log + // the user in to their new account automatically. + completedNoSignin: boolean; + serverType: ServerType.FREE | ServerType.PREMIUM | ServerType.ADVANCED; + // Phase of the overall registration dialog. + phase: Phase; + flows: { + stages: string[]; + }[]; + // We perform liveliness checks later, but for now suppress the errors. + // We also track the server dead errors independently of the regular errors so + // that we can render it differently, and override any other error the user may + // be seeing. + serverIsAlive: boolean; + serverErrorIsFatal: boolean; + serverDeadError: string; + + // Our matrix client - part of state because we can't render the UI auth + // component without it. + matrixClient?: MatrixClient; + // whether the HS requires an ID server to register with a threepid + serverRequiresIdServer?: boolean; + // The user ID we've just registered + registeredUsername?: string; + // if a different user ID to the one we just registered is logged in, + // this is the user ID that's logged in. + differentLoggedInUserId?: string; +} // Enable phases for registration const PHASES_ENABLED = true; -export default class Registration extends React.Component { - static propTypes = { - // Called when the user has logged in. Params: - // - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken - // - The user's password, if available and applicable (may be cached in memory - // for a short time so the user is not required to re-enter their password - // for operations like uploading cross-signing keys). - onLoggedIn: PropTypes.func.isRequired, - - clientSecret: PropTypes.string, - sessionId: PropTypes.string, - makeRegistrationUrl: PropTypes.func.isRequired, - idSid: PropTypes.string, - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - brand: PropTypes.string, - email: PropTypes.string, - // registration shouldn't know or care how login is done. - onLoginClick: PropTypes.func.isRequired, - onServerConfigChange: PropTypes.func.isRequired, - defaultDeviceDisplayName: PropTypes.string, - }; - +export default class Registration extends React.Component { constructor(props) { super(props); @@ -71,56 +129,22 @@ export default class Registration extends React.Component { this.state = { busy: false, errorText: null, - // We remember the values entered by the user because - // the registration form will be unmounted during the - // course of registration, but if there's an error we - // want to bring back the registration form with the - // values the user entered still in it. We can keep - // them in this component's state since this component - // persist for the duration of the registration process. formVals: { email: this.props.email, }, - // true if we're waiting for the user to complete - // user-interactive auth - // If we've been given a session ID, we're resuming - // straight back into UI auth doingUIAuth: Boolean(this.props.sessionId), serverType, - // Phase of the overall registration dialog. - phase: PHASE_REGISTRATION, + phase: Phase.Registration, flows: null, - // If set, we've registered but are not going to log - // the user in to their new account automatically. completedNoSignin: false, - - // We perform liveliness checks later, but for now suppress the errors. - // We also track the server dead errors independently of the regular errors so - // that we can render it differently, and override any other error the user may - // be seeing. serverIsAlive: true, serverErrorIsFatal: false, serverDeadError: "", - - // Our matrix client - part of state because we can't render the UI auth - // component without it. - matrixClient: null, - - // whether the HS requires an ID server to register with a threepid - serverRequiresIdServer: null, - - // The user ID we've just registered - registeredUsername: null, - - // if a different user ID to the one we just registered is logged in, - // this is the user ID that's logged in. - differentLoggedInUserId: null, }; } componentDidMount() { - this._unmounted = false; - this._replaceClient(); + this.replaceClient(this.props.serverConfig); } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event @@ -129,7 +153,7 @@ export default class Registration extends React.Component { if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; - this._replaceClient(newProps.serverConfig); + this.replaceClient(newProps.serverConfig); // Handle cases where the user enters "https://matrix.org" for their server // from the advanced option - we should default to FREE at that point. @@ -138,25 +162,25 @@ export default class Registration extends React.Component { // Reset the phase to default phase for the server type. this.setState({ serverType, - phase: this.getDefaultPhaseForServerType(serverType), + phase: Registration.getDefaultPhaseForServerType(serverType), }); } } - getDefaultPhaseForServerType(type) { + private static getDefaultPhaseForServerType(type: IState["serverType"]) { switch (type) { case ServerType.FREE: { // Move directly to the registration phase since the server // details are fixed. - return PHASE_REGISTRATION; + return Phase.Registration; } case ServerType.PREMIUM: case ServerType.ADVANCED: - return PHASE_SERVER_DETAILS; + return Phase.ServerDetails; } } - onServerTypeChange = type => { + private onServerTypeChange = (type: IState["serverType"]) => { this.setState({ serverType: type, }); @@ -181,11 +205,11 @@ export default class Registration extends React.Component { // Reset the phase to default phase for the server type. this.setState({ - phase: this.getDefaultPhaseForServerType(type), + phase: Registration.getDefaultPhaseForServerType(type), }); }; - async _replaceClient(serverConfig) { + private async replaceClient(serverConfig: ValidatedServerConfig) { this.setState({ errorText: null, serverDeadError: null, @@ -194,7 +218,6 @@ export default class Registration extends React.Component { // the UI auth component while we don't have a matrix client) busy: true, }); - if (!serverConfig) serverConfig = this.props.serverConfig; // Do a liveliness check on the URLs try { @@ -246,7 +269,7 @@ export default class Registration extends React.Component { // do SSO instead. If we've already started the UI Auth process though, we don't // need to. if (!this.state.doingUIAuth) { - await this._makeRegisterRequest(null); + await this.makeRegisterRequest(null); // This should never succeed since we specified no auth object. console.log("Expecting 401 from register request but got success!"); } @@ -287,7 +310,7 @@ export default class Registration extends React.Component { } } - onFormSubmit = formVals => { + private onFormSubmit = formVals => { this.setState({ errorText: "", busy: true, @@ -296,7 +319,7 @@ export default class Registration extends React.Component { }); }; - _requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => { + private requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => { return this.state.matrixClient.requestRegisterEmailToken( emailAddress, clientSecret, @@ -310,28 +333,26 @@ export default class Registration extends React.Component { ); } - _onUIAuthFinished = async (success, response, extra) => { + private onUIAuthFinished = async (success, response, extra) => { if (!success) { let msg = response.message || response.toString(); // can we give a better error message? if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { const errorTop = messageForResourceLimitError( response.data.limit_type, - response.data.admin_contact, { - 'monthly_active_user': _td( - "This homeserver has hit its Monthly Active User limit.", - ), - '': _td( - "This homeserver has exceeded one of its resource limits.", - ), - }); + response.data.admin_contact, + { + 'monthly_active_user': _td("This homeserver has hit its Monthly Active User limit."), + '': _td("This homeserver has exceeded one of its resource limits."), + }, + ); const errorDetail = messageForResourceLimitError( response.data.limit_type, - response.data.admin_contact, { - '': _td( - "Please contact your service administrator to continue using this service.", - ), - }); + response.data.admin_contact, + { + '': _td("Please contact your service administrator to continue using this service."), + }, + ); msg =

{errorTop}

{errorDetail}

@@ -339,7 +360,7 @@ export default class Registration extends React.Component { } else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) { let msisdnAvailable = false; for (const flow of response.available_flows) { - msisdnAvailable |= flow.stages.indexOf('m.login.msisdn') > -1; + msisdnAvailable = msisdnAvailable || flow.stages.includes('m.login.msisdn'); } if (!msisdnAvailable) { msg = _t('This server does not support authentication with a phone number.'); @@ -358,6 +379,10 @@ export default class Registration extends React.Component { const newState = { doingUIAuth: false, registeredUsername: response.user_id, + differentLoggedInUserId: null, + completedNoSignin: false, + // we're still busy until we get unmounted: don't show the registration form again + busy: true, }; // The user came in through an email validation link. To avoid overwriting @@ -372,8 +397,6 @@ export default class Registration extends React.Component { `Found a session for ${sessionOwner} but ${response.userId} has just registered.`, ); newState.differentLoggedInUserId = sessionOwner; - } else { - newState.differentLoggedInUserId = null; } if (response.access_token) { @@ -385,9 +408,7 @@ export default class Registration extends React.Component { accessToken: response.access_token, }, this.state.formVals.password); - this._setupPushers(); - // we're still busy until we get unmounted: don't show the registration form again - newState.busy = true; + this.setupPushers(); } else { newState.busy = false; newState.completedNoSignin = true; @@ -396,7 +417,7 @@ export default class Registration extends React.Component { this.setState(newState); }; - _setupPushers() { + private setupPushers() { if (!this.props.brand) { return Promise.resolve(); } @@ -419,38 +440,38 @@ export default class Registration extends React.Component { }); } - onLoginClick = ev => { + private onLoginClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.props.onLoginClick(); }; - onGoToFormClicked = ev => { + private onGoToFormClicked = ev => { ev.preventDefault(); ev.stopPropagation(); - this._replaceClient(); + this.replaceClient(this.props.serverConfig); this.setState({ busy: false, doingUIAuth: false, - phase: PHASE_REGISTRATION, + phase: Phase.Registration, }); }; - onServerDetailsNextPhaseClick = async () => { + private onServerDetailsNextPhaseClick = async () => { this.setState({ - phase: PHASE_REGISTRATION, + phase: Phase.Registration, }); }; - onEditServerDetailsClick = ev => { + private onEditServerDetailsClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.setState({ - phase: PHASE_SERVER_DETAILS, + phase: Phase.ServerDetails, }); }; - _makeRegisterRequest = auth => { + private makeRegisterRequest = auth => { // We inhibit login if we're trying to register with an email address: this // avoids a lot of complex race conditions that can occur if we try to log // the user in one one or both of the tabs they might end up with after @@ -466,13 +487,15 @@ export default class Registration extends React.Component { username: this.state.formVals.username, password: this.state.formVals.password, initial_device_display_name: this.props.defaultDeviceDisplayName, + auth: undefined, + inhibit_login: undefined, }; if (auth) registerParams.auth = auth; if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin; return this.state.matrixClient.registerRequest(registerParams); }; - _getUIAuthInputs() { + private getUIAuthInputs() { return { emailAddress: this.state.formVals.email, phoneCountry: this.state.formVals.phoneCountry, @@ -483,7 +506,7 @@ export default class Registration extends React.Component { // Links to the login page shown after registration is completed are routed through this // which checks the user hasn't already logged in somewhere else (perhaps we should do // this more generally?) - _onLoginClickWithCheck = async ev => { + private onLoginClickWithCheck = async ev => { ev.preventDefault(); const sessionLoaded = await Lifecycle.loadSession({ignoreGuest: true}); @@ -493,7 +516,7 @@ export default class Registration extends React.Component { } }; - renderServerComponent() { + private renderServerComponent() { const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector"); const ServerConfig = sdk.getComponent("auth.ServerConfig"); const ModularServerConfig = sdk.getComponent("auth.ModularServerConfig"); @@ -503,7 +526,7 @@ export default class Registration extends React.Component { } // Hide the server picker once the user is doing UI Auth unless encountered a fatal server error - if (this.state.phase !== PHASE_SERVER_DETAILS && this.state.doingUIAuth && !this.state.serverErrorIsFatal) { + if (this.state.phase !== Phase.ServerDetails && this.state.doingUIAuth && !this.state.serverErrorIsFatal) { return null; } @@ -511,7 +534,7 @@ export default class Registration extends React.Component { // which is always shown if we allow custom URLs at all. // (if there's a fatal server error, we need to show the full server // config as the user may need to change servers to resolve the error). - if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS && !this.state.serverErrorIsFatal) { + if (PHASES_ENABLED && this.state.phase !== Phase.ServerDetails && !this.state.serverErrorIsFatal) { return
; } - const serverDetailsProps = {}; + const serverDetailsProps: ComponentProps = {}; if (PHASES_ENABLED) { serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick; serverDetailsProps.submitText = _t("Next"); @@ -559,8 +582,8 @@ export default class Registration extends React.Component {
; } - renderRegisterComponent() { - if (PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) { + private renderRegisterComponent() { + if (PHASES_ENABLED && this.state.phase !== Phase.Registration) { return null; } @@ -571,10 +594,10 @@ export default class Registration extends React.Component { if (this.state.matrixClient && this.state.doingUIAuth) { return { _t('Go back') } ; @@ -651,7 +674,7 @@ export default class Registration extends React.Component { loggedInUserId: this.state.differentLoggedInUserId, }, )}

-

+

{_t("Continue with previous account")}

; @@ -660,7 +683,7 @@ export default class Registration extends React.Component { regDoneText =

{_t( "Log in to your new account.", {}, { - a: (sub) => {sub}, + a: (sub) => {sub}, }, )}

; } else { @@ -670,7 +693,7 @@ export default class Registration extends React.Component { regDoneText =

{_t( "You can now close this window or log in to your new account.", {}, { - a: (sub) => {sub}, + a: (sub) => {sub}, }, )}

; } @@ -679,7 +702,7 @@ export default class Registration extends React.Component { { regDoneText }
; } else { - let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', { + let yourMatrixAccountText: ReactNode = _t('Create your Matrix account on %(serverName)s', { serverName: this.props.serverConfig.hsName, }); if (this.props.serverConfig.hsNameIsDifferent) { @@ -717,7 +740,7 @@ export default class Registration extends React.Component { { errorText } { serverDeadSection } { this.renderServerComponent() } - { this.state.phase !== PHASE_SERVER_DETAILS &&

+ { this.state.phase !== Phase.ServerDetails &&

{yourMatrixAccountText} {editLink}

} diff --git a/src/components/views/auth/PassphraseField.tsx b/src/components/views/auth/PassphraseField.tsx index b420ed0872..e240ad61ca 100644 --- a/src/components/views/auth/PassphraseField.tsx +++ b/src/components/views/auth/PassphraseField.tsx @@ -21,9 +21,9 @@ import zxcvbn from "zxcvbn"; import SdkConfig from "../../../SdkConfig"; import withValidation, {IFieldState, IValidationResult} from "../elements/Validation"; import {_t, _td} from "../../../languageHandler"; -import Field from "../elements/Field"; +import Field, {IInputProps} from "../elements/Field"; -interface IProps { +interface IProps extends Omit { autoFocus?: boolean; id?: string; className?: string; diff --git a/src/components/views/auth/PasswordLogin.js b/src/components/views/auth/PasswordLogin.js deleted file mode 100644 index 405f9051b9..0000000000 --- a/src/components/views/auth/PasswordLogin.js +++ /dev/null @@ -1,377 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -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. -*/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import * as sdk from '../../../index'; -import { _t } from '../../../languageHandler'; -import SdkConfig from '../../../SdkConfig'; -import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; -import AccessibleButton from "../elements/AccessibleButton"; -import CountlyAnalytics from "../../../CountlyAnalytics"; - -/** - * A pure UI component which displays a username/password form. - */ -export default class PasswordLogin extends React.Component { - static propTypes = { - onSubmit: PropTypes.func.isRequired, // fn(username, password) - onError: PropTypes.func, - onEditServerDetailsClick: PropTypes.func, - onForgotPasswordClick: PropTypes.func, // fn() - initialUsername: PropTypes.string, - initialPhoneCountry: PropTypes.string, - initialPhoneNumber: PropTypes.string, - initialPassword: PropTypes.string, - onUsernameChanged: PropTypes.func, - onPhoneCountryChanged: PropTypes.func, - onPhoneNumberChanged: PropTypes.func, - onPasswordChanged: PropTypes.func, - loginIncorrect: PropTypes.bool, - disableSubmit: PropTypes.bool, - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - busy: PropTypes.bool, - }; - - static defaultProps = { - onError: function() {}, - onEditServerDetailsClick: null, - onUsernameChanged: function() {}, - onUsernameBlur: function() {}, - onPasswordChanged: function() {}, - onPhoneCountryChanged: function() {}, - onPhoneNumberChanged: function() {}, - onPhoneNumberBlur: function() {}, - initialUsername: "", - initialPhoneCountry: "", - initialPhoneNumber: "", - initialPassword: "", - loginIncorrect: false, - disableSubmit: false, - }; - - static LOGIN_FIELD_EMAIL = "login_field_email"; - static LOGIN_FIELD_MXID = "login_field_mxid"; - static LOGIN_FIELD_PHONE = "login_field_phone"; - - constructor(props) { - super(props); - this.state = { - username: this.props.initialUsername, - password: this.props.initialPassword, - phoneCountry: this.props.initialPhoneCountry, - phoneNumber: this.props.initialPhoneNumber, - loginType: PasswordLogin.LOGIN_FIELD_MXID, - }; - - this.onForgotPasswordClick = this.onForgotPasswordClick.bind(this); - this.onSubmitForm = this.onSubmitForm.bind(this); - this.onUsernameChanged = this.onUsernameChanged.bind(this); - this.onUsernameBlur = this.onUsernameBlur.bind(this); - this.onLoginTypeChange = this.onLoginTypeChange.bind(this); - this.onPhoneCountryChanged = this.onPhoneCountryChanged.bind(this); - this.onPhoneNumberChanged = this.onPhoneNumberChanged.bind(this); - this.onPhoneNumberBlur = this.onPhoneNumberBlur.bind(this); - this.onPasswordChanged = this.onPasswordChanged.bind(this); - this.isLoginEmpty = this.isLoginEmpty.bind(this); - } - - onForgotPasswordClick(ev) { - ev.preventDefault(); - ev.stopPropagation(); - this.props.onForgotPasswordClick(); - } - - onSubmitForm(ev) { - ev.preventDefault(); - - let username = ''; // XXX: Synapse breaks if you send null here: - let phoneCountry = null; - let phoneNumber = null; - let error; - - switch (this.state.loginType) { - case PasswordLogin.LOGIN_FIELD_EMAIL: - username = this.state.username; - if (!username) { - error = _t('The email field must not be blank.'); - } - break; - case PasswordLogin.LOGIN_FIELD_MXID: - username = this.state.username; - if (!username) { - error = _t('The username field must not be blank.'); - } - break; - case PasswordLogin.LOGIN_FIELD_PHONE: - phoneCountry = this.state.phoneCountry; - phoneNumber = this.state.phoneNumber; - if (!phoneNumber) { - error = _t('The phone number field must not be blank.'); - } - break; - } - - if (error) { - this.props.onError(error); - return; - } - - if (!this.state.password) { - this.props.onError(_t('The password field must not be blank.')); - return; - } - - this.props.onSubmit( - username, - phoneCountry, - phoneNumber, - this.state.password, - ); - } - - onUsernameChanged(ev) { - this.setState({username: ev.target.value}); - this.props.onUsernameChanged(ev.target.value); - } - - onUsernameFocus() { - if (this.state.loginType === PasswordLogin.LOGIN_FIELD_MXID) { - CountlyAnalytics.instance.track("onboarding_login_mxid_focus"); - } else { - CountlyAnalytics.instance.track("onboarding_login_email_focus"); - } - } - - onUsernameBlur(ev) { - if (this.state.loginType === PasswordLogin.LOGIN_FIELD_MXID) { - CountlyAnalytics.instance.track("onboarding_login_mxid_blur"); - } else { - CountlyAnalytics.instance.track("onboarding_login_email_blur"); - } - this.props.onUsernameBlur(ev.target.value); - } - - onLoginTypeChange(ev) { - const loginType = ev.target.value; - this.props.onError(null); // send a null error to clear any error messages - this.setState({ - loginType: loginType, - username: "", // Reset because email and username use the same state - }); - CountlyAnalytics.instance.track("onboarding_login_type_changed", { loginType }); - } - - onPhoneCountryChanged(country) { - this.setState({ - phoneCountry: country.iso2, - phonePrefix: country.prefix, - }); - this.props.onPhoneCountryChanged(country.iso2); - } - - onPhoneNumberChanged(ev) { - this.setState({phoneNumber: ev.target.value}); - this.props.onPhoneNumberChanged(ev.target.value); - } - - onPhoneNumberFocus() { - CountlyAnalytics.instance.track("onboarding_login_phone_number_focus"); - } - - onPhoneNumberBlur(ev) { - this.props.onPhoneNumberBlur(ev.target.value); - CountlyAnalytics.instance.track("onboarding_login_phone_number_blur"); - } - - onPasswordChanged(ev) { - this.setState({password: ev.target.value}); - this.props.onPasswordChanged(ev.target.value); - } - - renderLoginField(loginType, autoFocus) { - const Field = sdk.getComponent('elements.Field'); - - const classes = {}; - - switch (loginType) { - case PasswordLogin.LOGIN_FIELD_EMAIL: - classes.error = this.props.loginIncorrect && !this.state.username; - return ; - case PasswordLogin.LOGIN_FIELD_MXID: - classes.error = this.props.loginIncorrect && !this.state.username; - return ; - case PasswordLogin.LOGIN_FIELD_PHONE: { - const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown'); - classes.error = this.props.loginIncorrect && !this.state.phoneNumber; - - const phoneCountry = ; - - return ; - } - } - } - - isLoginEmpty() { - switch (this.state.loginType) { - case PasswordLogin.LOGIN_FIELD_EMAIL: - case PasswordLogin.LOGIN_FIELD_MXID: - return !this.state.username; - case PasswordLogin.LOGIN_FIELD_PHONE: - return !this.state.phoneCountry || !this.state.phoneNumber; - } - } - - render() { - const Field = sdk.getComponent('elements.Field'); - const SignInToText = sdk.getComponent('views.auth.SignInToText'); - - let forgotPasswordJsx; - - if (this.props.onForgotPasswordClick) { - forgotPasswordJsx = - {_t('Not sure of your password? Set a new one', {}, { - a: sub => ( - - {sub} - - ), - })} - ; - } - - const pwFieldClass = classNames({ - error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field - }); - - // If login is empty, autoFocus login, otherwise autoFocus password. - // this is for when auto server discovery remounts us when the user tries to tab from username to password - const autoFocusPassword = !this.isLoginEmpty(); - const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword); - - let loginType; - if (!SdkConfig.get().disable_3pid_login) { - loginType = ( -
- - - - - - -
- ); - } - - return ( -
- -
- {loginType} - {loginField} - - {forgotPasswordJsx} - { !this.props.busy && } - -
- ); - } -} diff --git a/src/components/views/auth/PasswordLogin.tsx b/src/components/views/auth/PasswordLogin.tsx new file mode 100644 index 0000000000..fced2e08d0 --- /dev/null +++ b/src/components/views/auth/PasswordLogin.tsx @@ -0,0 +1,495 @@ +/* +Copyright 2015, 2016, 2017, 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. +*/ + +import React from 'react'; +import classNames from 'classnames'; + +import { _t } from '../../../languageHandler'; +import SdkConfig from '../../../SdkConfig'; +import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +import AccessibleButton from "../elements/AccessibleButton"; +import CountlyAnalytics from "../../../CountlyAnalytics"; +import withValidation from "../elements/Validation"; +import * as Email from "../../../email"; +import Field from "../elements/Field"; +import CountryDropdown from "./CountryDropdown"; +import SignInToText from "./SignInToText"; + +// For validating phone numbers without country codes +const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; + +interface IProps { + username: string; // also used for email address + phoneCountry: string; + phoneNumber: string; + + serverConfig: ValidatedServerConfig; + loginIncorrect?: boolean; + disableSubmit?: boolean; + busy?: boolean; + + onSubmit(username: string, phoneCountry: void, phoneNumber: void, password: string): void; + onSubmit(username: void, phoneCountry: string, phoneNumber: string, password: string): void; + onUsernameChanged?(username: string): void; + onUsernameBlur?(username: string): void; + onPhoneCountryChanged?(phoneCountry: string): void; + onPhoneNumberChanged?(phoneNumber: string): void; + onEditServerDetailsClick?(): void; + onForgotPasswordClick?(): void; +} + +interface IState { + fieldValid: Partial>; + loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone, + password: "", +} + +enum LoginField { + Email = "login_field_email", + MatrixId = "login_field_mxid", + Phone = "login_field_phone", + Password = "login_field_phone", +} + +/* + * A pure UI component which displays a username/password form. + * The email/username/phone fields are fully-controlled, the password field is not. + */ +export default class PasswordLogin extends React.PureComponent { + static defaultProps = { + onEditServerDetailsClick: null, + onUsernameChanged: function() {}, + onUsernameBlur: function() {}, + onPhoneCountryChanged: function() {}, + onPhoneNumberChanged: function() {}, + loginIncorrect: false, + disableSubmit: false, + }; + + constructor(props) { + super(props); + this.state = { + // Field error codes by field ID + fieldValid: {}, + loginType: LoginField.MatrixId, + password: "", + }; + } + + private onForgotPasswordClick = ev => { + ev.preventDefault(); + ev.stopPropagation(); + this.props.onForgotPasswordClick(); + }; + + private onSubmitForm = async ev => { + ev.preventDefault(); + + const allFieldsValid = await this.verifyFieldsBeforeSubmit(); + if (!allFieldsValid) { + CountlyAnalytics.instance.track("onboarding_registration_submit_failed"); + return; + } + + let username = ''; // XXX: Synapse breaks if you send null here: + let phoneCountry = null; + let phoneNumber = null; + + switch (this.state.loginType) { + case LoginField.Email: + case LoginField.MatrixId: + username = this.props.username; + break; + case LoginField.Phone: + phoneCountry = this.props.phoneCountry; + phoneNumber = this.props.phoneNumber; + break; + } + + this.props.onSubmit(username, phoneCountry, phoneNumber, this.state.password); + }; + + private onUsernameChanged = ev => { + this.props.onUsernameChanged(ev.target.value); + }; + + private onUsernameFocus = () => { + if (this.state.loginType === LoginField.MatrixId) { + CountlyAnalytics.instance.track("onboarding_login_mxid_focus"); + } else { + CountlyAnalytics.instance.track("onboarding_login_email_focus"); + } + }; + + private onUsernameBlur = ev => { + if (this.state.loginType === LoginField.MatrixId) { + CountlyAnalytics.instance.track("onboarding_login_mxid_blur"); + } else { + CountlyAnalytics.instance.track("onboarding_login_email_blur"); + } + this.props.onUsernameBlur(ev.target.value); + }; + + private onLoginTypeChange = ev => { + const loginType = ev.target.value; + this.setState({ loginType }); + this.props.onUsernameChanged(""); // Reset because email and username use the same state + CountlyAnalytics.instance.track("onboarding_login_type_changed", { loginType }); + }; + + private onPhoneCountryChanged = country => { + this.props.onPhoneCountryChanged(country.iso2); + }; + + private onPhoneNumberChanged = ev => { + this.props.onPhoneNumberChanged(ev.target.value); + }; + + private onPhoneNumberFocus = () => { + CountlyAnalytics.instance.track("onboarding_login_phone_number_focus"); + }; + + private onPhoneNumberBlur = ev => { + CountlyAnalytics.instance.track("onboarding_login_phone_number_blur"); + }; + + private onPasswordChanged = ev => { + this.setState({password: ev.target.value}); + }; + + 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. + const activeElement = document.activeElement as HTMLElement; + if (activeElement) { + activeElement.blur(); + } + + const fieldIDsInDisplayOrder = [ + this.state.loginType, + LoginField.Password, + ]; + + // 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; + } + + 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; + } + + private findFirstInvalidField(fieldIDs: LoginField[]) { + for (const fieldID of fieldIDs) { + if (!this.state.fieldValid[fieldID] && this[fieldID]) { + return this[fieldID]; + } + } + return null; + } + + private markFieldValid(fieldID: LoginField, valid: boolean) { + const { fieldValid } = this.state; + fieldValid[fieldID] = valid; + this.setState({ + fieldValid, + }); + } + + private validateUsernameRules = withValidation({ + rules: [ + { + key: "required", + test({ value, allowEmpty }) { + return allowEmpty || !!value; + }, + invalid: () => _t("Enter username"), + }, + ], + }); + + private onUsernameValidate = async (fieldState) => { + const result = await this.validateUsernameRules(fieldState); + this.markFieldValid(LoginField.MatrixId, result.valid); + return result; + }; + + private validateEmailRules = withValidation({ + rules: [ + { + key: "required", + test({ value, allowEmpty }) { + return allowEmpty || !!value; + }, + invalid: () => _t("Enter email address"), + }, { + key: "email", + test: ({ value }) => !value || Email.looksValid(value), + invalid: () => _t("Doesn't look like a valid email address"), + }, + ], + }); + + private onEmailValidate = async (fieldState) => { + const result = await this.validateEmailRules(fieldState); + this.markFieldValid(LoginField.Email, result.valid); + return result; + }; + + private validatePhoneNumberRules = withValidation({ + rules: [ + { + key: "required", + test({ value, allowEmpty }) { + return allowEmpty || !!value; + }, + invalid: () => _t("Enter phone number"), + }, { + key: "number", + test: ({ value }) => !value || PHONE_NUMBER_REGEX.test(value), + invalid: () => _t("Doesn't look like a valid phone number"), + }, + ], + }); + + private onPhoneNumberValidate = async (fieldState) => { + const result = await this.validatePhoneNumberRules(fieldState); + this.markFieldValid(LoginField.Password, result.valid); + return result; + }; + + private validatePasswordRules = withValidation({ + rules: [ + { + key: "required", + test({ value, allowEmpty }) { + return allowEmpty || !!value; + }, + invalid: () => _t("Enter password"), + }, + ], + }); + + private onPasswordValidate = async (fieldState) => { + const result = await this.validatePasswordRules(fieldState); + this.markFieldValid(LoginField.Password, result.valid); + return result; + } + + private renderLoginField(loginType: IState["loginType"], autoFocus: boolean) { + const classes = { + error: false, + }; + + switch (loginType) { + case LoginField.Email: + classes.error = this.props.loginIncorrect && !this.props.username; + return this[LoginField.Email] = field} + />; + case LoginField.MatrixId: + classes.error = this.props.loginIncorrect && !this.props.username; + return this[LoginField.MatrixId] = field} + />; + case LoginField.Phone: { + classes.error = this.props.loginIncorrect && !this.props.phoneNumber; + + const phoneCountry = ; + + return this[LoginField.Password] = field} + />; + } + } + } + + private isLoginEmpty() { + switch (this.state.loginType) { + case LoginField.Email: + case LoginField.MatrixId: + return !this.props.username; + case LoginField.Phone: + return !this.props.phoneCountry || !this.props.phoneNumber; + } + } + + render() { + let forgotPasswordJsx; + + if (this.props.onForgotPasswordClick) { + forgotPasswordJsx = + {_t('Not sure of your password? Set a new one', {}, { + a: sub => ( + + {sub} + + ), + })} + ; + } + + const pwFieldClass = classNames({ + error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field + }); + + // If login is empty, autoFocus login, otherwise autoFocus password. + // this is for when auto server discovery remounts us when the user tries to tab from username to password + const autoFocusPassword = !this.isLoginEmpty(); + const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword); + + let loginType; + if (!SdkConfig.get().disable_3pid_login) { + loginType = ( +
+ + + + + + +
+ ); + } + + return ( +
+ +
+ {loginType} + {loginField} + this[LoginField.Password] = field} + /> + {forgotPasswordJsx} + { !this.props.busy && } + +
+ ); + } +} diff --git a/src/components/views/auth/RegistrationForm.js b/src/components/views/auth/RegistrationForm.tsx similarity index 77% rename from src/components/views/auth/RegistrationForm.js rename to src/components/views/auth/RegistrationForm.tsx index 70c1017427..f6fb3bb3ea 100644 --- a/src/components/views/auth/RegistrationForm.js +++ b/src/components/views/auth/RegistrationForm.tsx @@ -1,8 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018, 2019 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +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. @@ -18,7 +16,7 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; + import * as sdk from '../../../index'; import * as Email from '../../../email'; import { looksValid as phoneNumberLooksValid } from '../../../phonenumber'; @@ -31,32 +29,57 @@ import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; import PassphraseField from "./PassphraseField"; import CountlyAnalytics from "../../../CountlyAnalytics"; -const FIELD_EMAIL = 'field_email'; -const FIELD_PHONE_NUMBER = 'field_phone_number'; -const FIELD_USERNAME = 'field_username'; -const FIELD_PASSWORD = 'field_password'; -const FIELD_PASSWORD_CONFIRM = 'field_password_confirm'; +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. +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; + 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.Component { - static propTypes = { - // Values pre-filled in the input boxes when the component loads - defaultEmail: PropTypes.string, - defaultPhoneCountry: PropTypes.string, - defaultPhoneNumber: PropTypes.string, - defaultUsername: PropTypes.string, - defaultPassword: PropTypes.string, - onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise - flows: PropTypes.arrayOf(PropTypes.object).isRequired, - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - canSubmit: PropTypes.bool, - serverRequiresIdServer: PropTypes.bool, - }; - +export default class RegistrationForm extends React.PureComponent { static defaultProps = { onValidationChange: console.error, canSubmit: true, @@ -66,9 +89,7 @@ export default class RegistrationForm extends React.Component { super(props); this.state = { - // Field error codes by field ID fieldValid: {}, - // The ISO2 country code selected in the phone number entry phoneCountry: this.props.defaultPhoneCountry, username: this.props.defaultUsername || "", email: this.props.defaultEmail || "", @@ -81,7 +102,7 @@ export default class RegistrationForm extends React.Component { CountlyAnalytics.instance.track("onboarding_registration_begin"); } - onSubmit = async ev => { + private onSubmit = async ev => { ev.preventDefault(); if (!this.props.canSubmit) return; @@ -92,7 +113,6 @@ export default class RegistrationForm extends React.Component { return; } - const self = this; if (this.state.email === '') { const haveIs = Boolean(this.props.serverConfig.isUrl); @@ -102,14 +122,14 @@ export default class RegistrationForm extends React.Component { "No identity server is configured so you cannot add an email address in order to " + "reset your password in the future.", ); - } else if (this._showEmail()) { + } else if (this.showEmail()) { desc = _t( "If you don't specify an email address, you won't be able to reset your password. " + "Are you sure?", ); } else { // user can't set an e-mail so don't prompt them to - self._doSubmit(ev); + this.doSubmit(ev); return; } @@ -120,18 +140,18 @@ export default class RegistrationForm extends React.Component { title: _t("Warning!"), description: desc, button: _t("Continue"), - onFinished(confirmed) { + onFinished: (confirmed) => { if (confirmed) { - self._doSubmit(ev); + this.doSubmit(ev); } }, }); } else { - self._doSubmit(ev); + this.doSubmit(ev); } }; - _doSubmit(ev) { + private doSubmit(ev) { const email = this.state.email.trim(); CountlyAnalytics.instance.track("onboarding_registration_submit_ok", { @@ -154,20 +174,20 @@ export default class RegistrationForm extends React.Component { } } - async verifyFieldsBeforeSubmit() { + 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. - const activeElement = document.activeElement; + const activeElement = document.activeElement as HTMLElement; if (activeElement) { activeElement.blur(); } const fieldIDsInDisplayOrder = [ - FIELD_USERNAME, - FIELD_PASSWORD, - FIELD_PASSWORD_CONFIRM, - FIELD_EMAIL, - FIELD_PHONE_NUMBER, + RegistrationField.Username, + RegistrationField.Password, + RegistrationField.PasswordConfirm, + RegistrationField.Email, + RegistrationField.PhoneNumber, ]; // Run all fields with stricter validation that no longer allows empty @@ -208,7 +228,7 @@ export default class RegistrationForm extends React.Component { /** * @returns {boolean} true if all fields were valid last time they were validated. */ - allFieldsValid() { + private allFieldsValid() { const keys = Object.keys(this.state.fieldValid); for (let i = 0; i < keys.length; ++i) { if (!this.state.fieldValid[keys[i]]) { @@ -218,7 +238,7 @@ export default class RegistrationForm extends React.Component { return true; } - findFirstInvalidField(fieldIDs) { + private findFirstInvalidField(fieldIDs: RegistrationField[]) { for (const fieldID of fieldIDs) { if (!this.state.fieldValid[fieldID] && this[fieldID]) { return this[fieldID]; @@ -227,7 +247,7 @@ export default class RegistrationForm extends React.Component { return null; } - markFieldValid(fieldID, valid) { + private markFieldValid(fieldID: RegistrationField, valid: boolean) { const { fieldValid } = this.state; fieldValid[fieldID] = valid; this.setState({ @@ -235,26 +255,26 @@ export default class RegistrationForm extends React.Component { }); } - onEmailChange = ev => { + private onEmailChange = ev => { this.setState({ email: ev.target.value, }); }; - onEmailValidate = async fieldState => { + private onEmailValidate = async fieldState => { const result = await this.validateEmailRules(fieldState); - this.markFieldValid(FIELD_EMAIL, result.valid); + this.markFieldValid(RegistrationField.Email, result.valid); return result; }; - validateEmailRules = withValidation({ + private validateEmailRules = withValidation({ description: () => _t("Use an email address to recover your account"), hideDescriptionIfValid: true, rules: [ { key: "required", - test({ value, allowEmpty }) { - return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value; + test(this: RegistrationForm, { value, allowEmpty }) { + return allowEmpty || !this.authStepIsRequired('m.login.email.identity') || !!value; }, invalid: () => _t("Enter email address (required on this homeserver)"), }, @@ -266,29 +286,29 @@ export default class RegistrationForm extends React.Component { ], }); - onPasswordChange = ev => { + private onPasswordChange = ev => { this.setState({ password: ev.target.value, }); }; - onPasswordValidate = result => { - this.markFieldValid(FIELD_PASSWORD, result.valid); + private onPasswordValidate = result => { + this.markFieldValid(RegistrationField.Password, result.valid); }; - onPasswordConfirmChange = ev => { + private onPasswordConfirmChange = ev => { this.setState({ passwordConfirm: ev.target.value, }); }; - onPasswordConfirmValidate = async fieldState => { + private onPasswordConfirmValidate = async fieldState => { const result = await this.validatePasswordConfirmRules(fieldState); - this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid); + this.markFieldValid(RegistrationField.PasswordConfirm, result.valid); return result; }; - validatePasswordConfirmRules = withValidation({ + private validatePasswordConfirmRules = withValidation({ rules: [ { key: "required", @@ -297,41 +317,40 @@ export default class RegistrationForm extends React.Component { }, { key: "match", - test({ value }) { + test(this: RegistrationForm, { value }) { return !value || value === this.state.password; }, invalid: () => _t("Passwords don't match"), }, - ], + ], }); - onPhoneCountryChange = newVal => { + private onPhoneCountryChange = newVal => { this.setState({ phoneCountry: newVal.iso2, - phonePrefix: newVal.prefix, }); }; - onPhoneNumberChange = ev => { + private onPhoneNumberChange = ev => { this.setState({ phoneNumber: ev.target.value, }); }; - onPhoneNumberValidate = async fieldState => { + private onPhoneNumberValidate = async fieldState => { const result = await this.validatePhoneNumberRules(fieldState); - this.markFieldValid(FIELD_PHONE_NUMBER, result.valid); + this.markFieldValid(RegistrationField.PhoneNumber, result.valid); return result; }; - validatePhoneNumberRules = withValidation({ + private validatePhoneNumberRules = withValidation({ description: () => _t("Other users can invite you to rooms using your contact details"), hideDescriptionIfValid: true, rules: [ { key: "required", - test({ value, allowEmpty }) { - return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value; + test(this: RegistrationForm, { value, allowEmpty }) { + return allowEmpty || !this.authStepIsRequired('m.login.msisdn') || !!value; }, invalid: () => _t("Enter phone number (required on this homeserver)"), }, @@ -343,19 +362,19 @@ export default class RegistrationForm extends React.Component { ], }); - onUsernameChange = ev => { + private onUsernameChange = ev => { this.setState({ username: ev.target.value, }); }; - onUsernameValidate = async fieldState => { + private onUsernameValidate = async fieldState => { const result = await this.validateUsernameRules(fieldState); - this.markFieldValid(FIELD_USERNAME, result.valid); + this.markFieldValid(RegistrationField.Username, result.valid); return result; }; - validateUsernameRules = withValidation({ + private validateUsernameRules = withValidation({ description: () => _t("Use lowercase letters, numbers, dashes and underscores only"), hideDescriptionIfValid: true, rules: [ @@ -378,7 +397,7 @@ export default class RegistrationForm extends React.Component { * @param {string} step A stage name to check * @returns {boolean} Whether it is required */ - _authStepIsRequired(step) { + private authStepIsRequired(step: string) { return this.props.flows.every((flow) => { return flow.stages.includes(step); }); @@ -390,46 +409,46 @@ export default class RegistrationForm extends React.Component { * @param {string} step A stage name to check * @returns {boolean} Whether it is used */ - _authStepIsUsed(step) { + private authStepIsUsed(step: string) { return this.props.flows.some((flow) => { return flow.stages.includes(step); }); } - _showEmail() { + private showEmail() { const haveIs = Boolean(this.props.serverConfig.isUrl); if ( (this.props.serverRequiresIdServer && !haveIs) || - !this._authStepIsUsed('m.login.email.identity') + !this.authStepIsUsed('m.login.email.identity') ) { return false; } return true; } - _showPhoneNumber() { + private showPhoneNumber() { const threePidLogin = !SdkConfig.get().disable_3pid_login; const haveIs = Boolean(this.props.serverConfig.isUrl); if ( !threePidLogin || (this.props.serverRequiresIdServer && !haveIs) || - !this._authStepIsUsed('m.login.msisdn') + !this.authStepIsUsed('m.login.msisdn') ) { return false; } return true; } - renderEmail() { - if (!this._showEmail()) { + private renderEmail() { + if (!this.showEmail()) { return null; } const Field = sdk.getComponent('elements.Field'); - const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ? + const emailPlaceholder = this.authStepIsRequired('m.login.email.identity') ? _t("Email") : _t("Email (optional)"); return this[FIELD_EMAIL] = field} + ref={field => this[RegistrationField.Email] = field} type="text" label={emailPlaceholder} value={this.state.email} @@ -440,10 +459,10 @@ export default class RegistrationForm extends React.Component { />; } - renderPassword() { + private renderPassword() { return this[FIELD_PASSWORD] = field} + fieldRef={field => this[RegistrationField.Password] = field} minScore={PASSWORD_MIN_SCORE} value={this.state.password} onChange={this.onPasswordChange} @@ -457,7 +476,7 @@ export default class RegistrationForm extends React.Component { const Field = sdk.getComponent('elements.Field'); return this[FIELD_PASSWORD_CONFIRM] = field} + ref={field => this[RegistrationField.PasswordConfirm] = field} type="password" autoComplete="new-password" label={_t("Confirm password")} @@ -470,12 +489,12 @@ export default class RegistrationForm extends React.Component { } renderPhoneNumber() { - if (!this._showPhoneNumber()) { + if (!this.showPhoneNumber()) { return null; } const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown'); const Field = sdk.getComponent('elements.Field'); - const phoneLabel = this._authStepIsRequired('m.login.msisdn') ? + const phoneLabel = this.authStepIsRequired('m.login.msisdn') ? _t("Phone") : _t("Phone (optional)"); const phoneCountry = ; return this[FIELD_PHONE_NUMBER] = field} + ref={field => this[RegistrationField.PhoneNumber] = field} type="text" label={phoneLabel} value={this.state.phoneNumber} @@ -499,7 +518,7 @@ export default class RegistrationForm extends React.Component { const Field = sdk.getComponent('elements.Field'); return this[FIELD_USERNAME] = field} + ref={field => this[RegistrationField.Username] = field} type="text" autoFocus={true} label={_t("Username")} @@ -517,8 +536,8 @@ export default class RegistrationForm extends React.Component { ); let emailHelperText = null; - if (this._showEmail()) { - if (this._showPhoneNumber()) { + if (this.showEmail()) { + if (this.showPhoneNumber()) { emailHelperText =
{_t( "Set an email for account recovery. " + diff --git a/src/components/views/elements/Field.tsx b/src/components/views/elements/Field.tsx index 7fd154047d..fb34f51b60 100644 --- a/src/components/views/elements/Field.tsx +++ b/src/components/views/elements/Field.tsx @@ -64,7 +64,7 @@ interface IProps { // All other props pass through to the . } -interface IInputProps extends IProps, InputHTMLAttributes { +export interface IInputProps extends IProps, InputHTMLAttributes { // The element to create. Defaults to "input". element?: "input"; // The input's value. This is a controlled component, so the value is required. diff --git a/src/components/views/elements/Validation.tsx b/src/components/views/elements/Validation.tsx index 31f7c866b1..1b0659ab9b 100644 --- a/src/components/views/elements/Validation.tsx +++ b/src/components/views/elements/Validation.tsx @@ -32,7 +32,7 @@ interface IRule { interface IArgs { rules: IRule[]; - description(this: T, derivedData: D): React.ReactChild; + description?(this: T, derivedData: D): React.ReactChild; hideDescriptionIfValid?: boolean; deriveData?(data: Data): Promise; } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index dc707222e7..eb6688008c 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2286,10 +2286,11 @@ "Nice, strong password!": "Nice, strong password!", "Password is allowed, but unsafe": "Password is allowed, but unsafe", "Keep going...": "Keep going...", - "The email field must not be blank.": "The email field must not be blank.", - "The username field must not be blank.": "The username field must not be blank.", - "The phone number field must not be blank.": "The phone number field must not be blank.", - "The password field must not be blank.": "The password field must not be blank.", + "Enter username": "Enter username", + "Enter email address": "Enter email address", + "Doesn't look like a valid email address": "Doesn't look like a valid email address", + "Enter phone number": "Enter phone number", + "Doesn't look like a valid phone number": "Doesn't look like a valid phone number", "Email": "Email", "Username": "Username", "Phone": "Phone", @@ -2300,13 +2301,10 @@ "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", "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 lowercase letters, numbers, dashes and underscores only": "Use lowercase letters, numbers, dashes and underscores only", - "Enter username": "Enter username", "Email (optional)": "Email (optional)", "Phone (optional)": "Phone (optional)", "Register": "Register", @@ -2512,7 +2510,6 @@ "Incorrect username and/or password.": "Incorrect username and/or password.", "Please note you are logging into the %(hs)s server, not matrix.org.": "Please note you are logging into the %(hs)s server, not matrix.org.", "Failed to perform homeserver discovery": "Failed to perform homeserver discovery", - "The phone number entered looks invalid": "The phone number entered looks invalid", "This homeserver doesn't offer any login flows which are supported by this client.": "This homeserver doesn't offer any login flows which are supported by this client.", "Error: Problem communicating with the given homeserver.": "Error: Problem communicating with the given homeserver.", "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or enable unsafe scripts.": "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or enable unsafe scripts.",