Merge pull request #5433 from matrix-org/t3chguy/socials_preamble

Auth typescripting and validation tweaks
This commit is contained in:
Michael Telatynski 2020-11-23 14:14:25 +00:00 committed by GitHub
commit 56ffa17b89
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 924 additions and 783 deletions

View file

@ -1,7 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016, 2017, 2018, 2019 New Vector Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
import React from 'react'; import React, {ComponentProps, ReactNode} from 'react';
import PropTypes from 'prop-types';
import {_t, _td} from '../../../languageHandler'; import {_t, _td} from '../../../languageHandler';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import Login from '../../../Login'; import Login from '../../../Login';
@ -31,15 +29,12 @@ import PlatformPeg from '../../../PlatformPeg';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import {UIFeature} from "../../../settings/UIFeature"; import {UIFeature} from "../../../settings/UIFeature";
import CountlyAnalytics from "../../../CountlyAnalytics"; import CountlyAnalytics from "../../../CountlyAnalytics";
import {IMatrixClientCreds} from "../../../MatrixClientPeg";
// For validating phone numbers without country codes import ServerConfig from "../../views/auth/ServerConfig";
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; import PasswordLogin from "../../views/auth/PasswordLogin";
import SignInToText from "../../views/auth/SignInToText";
// Phases import InlineSpinner from "../../views/elements/InlineSpinner";
// Show controls to configure server details import Spinner from "../../views/elements/Spinner";
const PHASE_SERVER_DETAILS = 0;
// Show the appropriate login flow(s) for the server
const PHASE_LOGIN = 1;
// Enable phases for login // Enable phases for login
const PHASES_ENABLED = true; 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("Identity server URL does not appear to be a valid identity server");
_td("General failure"); _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 * A wire component which glues together login UI components and Login logic
*/ */
export default class LoginComponent extends React.Component { export default class LoginComponent extends React.Component<IProps, IState> {
static propTypes = { private unmounted = false;
// Called when the user has logged in. Params: private loginLogic: Login;
// - The object returned by the login API private readonly stepRendererMap: Record<string, () => ReactNode>;
// - 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,
};
constructor(props) { constructor(props) {
super(props); super(props);
this._unmounted = false;
this.state = { this.state = {
busy: false, busy: false,
busyLoggingIn: null, busyLoggingIn: null,
errorText: null, errorText: null,
loginIncorrect: false, loginIncorrect: false,
canTryLogin: true, // can we attempt to log in or are there validation errors? canTryLogin: true,
// used for preserving form values when changing homeserver
username: "", username: "",
phoneCountry: null, phoneCountry: null,
phoneNumber: "", phoneNumber: "",
phase: Phase.Login,
// Phase of the overall login dialog. currentFlow: null,
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.
serverIsAlive: true, serverIsAlive: true,
serverErrorIsFatal: false, serverErrorIsFatal: false,
serverDeadError: "", 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 // map from login step type to a function which will render a control
// letting you do that login type // letting you do that login type
this._stepRendererMap = { this.stepRendererMap = {
'm.login.password': this._renderPasswordStep, 'm.login.password': this.renderPasswordStep,
// CAS and SSO are the same thing, modulo the url we link to // CAS and SSO are the same thing, modulo the url we link to
'm.login.cas': () => this._renderSsoStep("cas"), 'm.login.cas': () => this.renderSsoStep("cas"),
'm.login.sso': () => this._renderSsoStep("sso"), 'm.login.sso': () => this.renderSsoStep("sso"),
}; };
CountlyAnalytics.instance.track("onboarding_login_begin"); 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 // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
UNSAFE_componentWillMount() { UNSAFE_componentWillMount() {
this._initLoginLogic(); this.initLoginLogic(this.props.serverConfig);
} }
componentWillUnmount() { componentWillUnmount() {
this._unmounted = true; this.unmounted = true;
} }
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event // 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; newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
// Ensure that we end up actually logging in to the right place // 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; isBusy = () => this.state.busy || this.props.busy;
onPasswordLogin = async (username, phoneCountry, phoneNumber, password) => { onPasswordLogin = async (username, phoneCountry, phoneNumber, password) => {
@ -194,13 +206,13 @@ export default class LoginComponent extends React.Component {
loginIncorrect: false, loginIncorrect: false,
}); });
this._loginLogic.loginViaPassword( this.loginLogic.loginViaPassword(
username, phoneCountry, phoneNumber, password, username, phoneCountry, phoneNumber, password,
).then((data) => { ).then((data) => {
this.setState({serverIsAlive: true}); // it must be, we logged in. this.setState({serverIsAlive: true}); // it must be, we logged in.
this.props.onLoggedIn(data, password); this.props.onLoggedIn(data, password);
}, (error) => { }, (error) => {
if (this._unmounted) { if (this.unmounted) {
return; return;
} }
let errorText; let errorText;
@ -212,21 +224,23 @@ export default class LoginComponent extends React.Component {
} else if (error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { } else if (error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError( const errorTop = messageForResourceLimitError(
error.data.limit_type, error.data.limit_type,
error.data.admin_contact, { error.data.admin_contact,
'monthly_active_user': _td( {
"This homeserver has hit its Monthly Active User limit.", 'monthly_active_user': _td(
), "This homeserver has hit its Monthly Active User limit.",
'': _td( ),
"This homeserver has exceeded one of its resource limits.", '': _td(
), "This homeserver has exceeded one of its resource limits.",
}); ),
},
);
const errorDetail = messageForResourceLimitError( const errorDetail = messageForResourceLimitError(
error.data.limit_type, error.data.limit_type,
error.data.admin_contact, { error.data.admin_contact,
'': _td( {
"Please <a>contact your service administrator</a> to continue using this service.", '': _td("Please <a>contact your service administrator</a> to continue using this service."),
), },
}); );
errorText = ( errorText = (
<div> <div>
<div>{errorTop}</div> <div>{errorTop}</div>
@ -253,7 +267,7 @@ export default class LoginComponent extends React.Component {
} }
} else { } else {
// other errors, not specific to doing a password login // other errors, not specific to doing a password login
errorText = this._errorTextFromError(error); errorText = this.errorTextFromError(error);
} }
this.setState({ 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 // 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. // 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 // 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. // busy state for its own liveness check.
this.setState({ this.setState({
busy: false, busy: false,
@ -304,7 +318,7 @@ export default class LoginComponent extends React.Component {
message = e.translatedMessage; message = e.translatedMessage;
} }
let errorText = message; let errorText: ReactNode = message;
let discoveryState = {}; let discoveryState = {};
if (AutoDiscoveryUtils.isLivelinessError(e)) { if (AutoDiscoveryUtils.isLivelinessError(e)) {
errorText = this.state.errorText; 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 => { onRegisterClick = ev => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
@ -352,14 +351,14 @@ export default class LoginComponent extends React.Component {
}; };
onTryRegisterClick = ev => { onTryRegisterClick = ev => {
const step = this._getCurrentFlowStep(); const step = this.getCurrentFlowStep();
if (step === 'm.login.sso' || step === 'm.login.cas') { if (step === 'm.login.sso' || step === 'm.login.cas') {
// If we're showing SSO it means that registration is also probably disabled, // 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'. // so intercept the click and instead pretend the user clicked 'Sign in with SSO'.
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
const ssoKind = step === 'm.login.sso' ? 'sso' : 'cas'; 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); this.props.fragmentAfterLogin);
} else { } else {
// Don't intercept - just go through to the register page // 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({ this.setState({
phase: PHASE_LOGIN, phase: Phase.Login,
}); });
}; };
onEditServerDetailsClick = ev => { private onEditServerDetailsClick = ev => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.setState({ this.setState({
phase: PHASE_SERVER_DETAILS, phase: Phase.ServerDetails,
}); });
}; };
async _initLoginLogic(hsUrl, isUrl) { private async initLoginLogic({hsUrl, isUrl}: ValidatedServerConfig) {
hsUrl = hsUrl || this.props.serverConfig.hsUrl;
isUrl = isUrl || this.props.serverConfig.isUrl;
let isDefaultServer = false; let isDefaultServer = false;
if (this.props.serverConfig.isDefault if (this.props.serverConfig.isDefault
&& hsUrl === this.props.serverConfig.hsUrl && hsUrl === this.props.serverConfig.hsUrl
@ -397,7 +393,7 @@ export default class LoginComponent extends React.Component {
const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, { const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName, defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
}); });
this._loginLogic = loginLogic; this.loginLogic = loginLogic;
this.setState({ this.setState({
busy: true, busy: true,
@ -428,7 +424,7 @@ export default class LoginComponent extends React.Component {
if (this.state.serverErrorIsFatal) { if (this.state.serverErrorIsFatal) {
// Server is dead: show server details prompt instead // Server is dead: show server details prompt instead
this.setState({ this.setState({
phase: PHASE_SERVER_DETAILS, phase: Phase.ServerDetails,
}); });
return; return;
} }
@ -437,7 +433,7 @@ export default class LoginComponent extends React.Component {
loginLogic.getFlows().then((flows) => { loginLogic.getFlows().then((flows) => {
// look for a flow where we understand all of the steps. // look for a flow where we understand all of the steps.
for (let i = 0; i < flows.length; i++ ) { for (let i = 0; i < flows.length; i++ ) {
if (!this._isSupportedFlow(flows[i])) { if (!this.isSupportedFlow(flows[i])) {
continue; continue;
} }
@ -446,7 +442,7 @@ export default class LoginComponent extends React.Component {
// that for now). // that for now).
loginLogic.chooseFlow(i); loginLogic.chooseFlow(i);
this.setState({ this.setState({
currentFlow: this._getCurrentFlowStep(), currentFlow: this.getCurrentFlowStep(),
}); });
return; return;
} }
@ -460,7 +456,7 @@ export default class LoginComponent extends React.Component {
}); });
}, (err) => { }, (err) => {
this.setState({ this.setState({
errorText: this._errorTextFromError(err), errorText: this.errorTextFromError(err),
loginIncorrect: false, loginIncorrect: false,
canTryLogin: 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 // 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. // 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); console.log("Skipping flow", flow, "due to unsupported login type", flow.type);
return false; return false;
} }
return true; return true;
} }
_getCurrentFlowStep() { private getCurrentFlowStep() {
return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null; return this.loginLogic ? this.loginLogic.getCurrentFlowStep() : null;
} }
_errorTextFromError(err) { private errorTextFromError(err) {
let errCode = err.errcode; let errCode = err.errcode;
if (!errCode && err.httpStatus) { if (!errCode && err.httpStatus) {
errCode = "HTTP " + err.httpStatus; errCode = "HTTP " + err.httpStatus;
} }
let errorText = _t("Error: Problem communicating with the given homeserver.") + let errorText: ReactNode = _t("Error: Problem communicating with the given homeserver.") +
(errCode ? " (" + errCode + ")" : ""); (errCode ? " (" + errCode + ")" : "");
if (err.cors === 'rejected') { if (err.cors === 'rejected') {
if (window.location.protocol === 'https:' && if (window.location.protocol === 'https:' &&
@ -502,29 +498,27 @@ export default class LoginComponent extends React.Component {
errorText = <span> errorText = <span>
{ _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " + { _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " +
"Either use HTTPS or <a>enable unsafe scripts</a>.", {}, "Either use HTTPS or <a>enable unsafe scripts</a>.", {},
{ {
'a': (sub) => { 'a': (sub) => {
return <a target="_blank" rel="noreferrer noopener" return <a target="_blank" rel="noreferrer noopener"
href="https://www.google.com/search?&q=enable%20unsafe%20scripts" href="https://www.google.com/search?&q=enable%20unsafe%20scripts"
> >
{ sub } { sub }
</a>; </a>;
},
}, },
) } }) }
</span>; </span>;
} else { } else {
errorText = <span> errorText = <span>
{ _t("Can't connect to homeserver - please check your connectivity, ensure your " + { _t("Can't connect to homeserver - please check your connectivity, ensure your " +
"<a>homeserver's SSL certificate</a> is trusted, and that a browser extension " + "<a>homeserver's SSL certificate</a> is trusted, and that a browser extension " +
"is not blocking requests.", {}, "is not blocking requests.", {},
{ {
'a': (sub) => 'a': (sub) =>
<a target="_blank" rel="noreferrer noopener" href={this.props.serverConfig.hsUrl}> <a target="_blank" rel="noreferrer noopener" href={this.props.serverConfig.hsUrl}>
{ sub } { sub }
</a>, </a>,
}, }) }
) }
</span>; </span>;
} }
} }
@ -532,18 +526,16 @@ export default class LoginComponent extends React.Component {
return errorText; return errorText;
} }
renderServerComponent() { private renderServerComponent() {
const ServerConfig = sdk.getComponent("auth.ServerConfig");
if (SdkConfig.get()['disable_custom_urls']) { if (SdkConfig.get()['disable_custom_urls']) {
return null; return null;
} }
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) { if (PHASES_ENABLED && this.state.phase !== Phase.ServerDetails) {
return null; return null;
} }
const serverDetailsProps = {}; const serverDetailsProps: ComponentProps<typeof ServerConfig> = {};
if (PHASES_ENABLED) { if (PHASES_ENABLED) {
serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick; serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick;
serverDetailsProps.submitText = _t("Next"); serverDetailsProps.submitText = _t("Next");
@ -558,8 +550,8 @@ export default class LoginComponent extends React.Component {
/>; />;
} }
renderLoginComponentForStep() { private renderLoginComponentForStep() {
if (PHASES_ENABLED && this.state.phase !== PHASE_LOGIN) { if (PHASES_ENABLED && this.state.phase !== Phase.Login) {
return null; return null;
} }
@ -569,7 +561,7 @@ export default class LoginComponent extends React.Component {
return null; return null;
} }
const stepRenderer = this._stepRendererMap[step]; const stepRenderer = this.stepRendererMap[step];
if (stepRenderer) { if (stepRenderer) {
return stepRenderer(); return stepRenderer();
@ -578,9 +570,7 @@ export default class LoginComponent extends React.Component {
return null; return null;
} }
_renderPasswordStep = () => { private renderPasswordStep = () => {
const PasswordLogin = sdk.getComponent('auth.PasswordLogin');
let onEditServerDetailsClick = null; let onEditServerDetailsClick = null;
// If custom URLs are allowed, wire up the server details edit link. // If custom URLs are allowed, wire up the server details edit link.
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) { if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
@ -589,29 +579,25 @@ export default class LoginComponent extends React.Component {
return ( return (
<PasswordLogin <PasswordLogin
onSubmit={this.onPasswordLogin} onSubmit={this.onPasswordLogin}
onError={this.onPasswordLoginError} onEditServerDetailsClick={onEditServerDetailsClick}
onEditServerDetailsClick={onEditServerDetailsClick} username={this.state.username}
initialUsername={this.state.username} phoneCountry={this.state.phoneCountry}
initialPhoneCountry={this.state.phoneCountry} phoneNumber={this.state.phoneNumber}
initialPhoneNumber={this.state.phoneNumber} onUsernameChanged={this.onUsernameChanged}
onUsernameChanged={this.onUsernameChanged} onUsernameBlur={this.onUsernameBlur}
onUsernameBlur={this.onUsernameBlur} onPhoneCountryChanged={this.onPhoneCountryChanged}
onPhoneCountryChanged={this.onPhoneCountryChanged} onPhoneNumberChanged={this.onPhoneNumberChanged}
onPhoneNumberChanged={this.onPhoneNumberChanged} onForgotPasswordClick={this.props.onForgotPasswordClick}
onPhoneNumberBlur={this.onPhoneNumberBlur} loginIncorrect={this.state.loginIncorrect}
onForgotPasswordClick={this.props.onForgotPasswordClick} serverConfig={this.props.serverConfig}
loginIncorrect={this.state.loginIncorrect} disableSubmit={this.isBusy()}
serverConfig={this.props.serverConfig} busy={this.props.isSyncing || this.state.busyLoggingIn}
disableSubmit={this.isBusy()}
busy={this.props.isSyncing || this.state.busyLoggingIn}
/> />
); );
}; };
_renderSsoStep = loginType => { private renderSsoStep = loginType => {
const SignInToText = sdk.getComponent('views.auth.SignInToText');
let onEditServerDetailsClick = null; let onEditServerDetailsClick = null;
// If custom URLs are allowed, wire up the server details edit link. // If custom URLs are allowed, wire up the server details edit link.
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) { if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
@ -632,7 +618,7 @@ export default class LoginComponent extends React.Component {
<SSOButton <SSOButton
className="mx_Login_sso_link mx_Login_submit" className="mx_Login_sso_link mx_Login_submit"
matrixClient={this._loginLogic.createTemporaryClient()} matrixClient={this.loginLogic.createTemporaryClient()}
loginType={loginType} loginType={loginType}
fragmentAfterLogin={this.props.fragmentAfterLogin} fragmentAfterLogin={this.props.fragmentAfterLogin}
/> />
@ -641,12 +627,10 @@ export default class LoginComponent extends React.Component {
}; };
render() { render() {
const Loader = sdk.getComponent("elements.Spinner");
const InlineSpinner = sdk.getComponent("elements.InlineSpinner");
const AuthHeader = sdk.getComponent("auth.AuthHeader"); const AuthHeader = sdk.getComponent("auth.AuthHeader");
const AuthBody = sdk.getComponent("auth.AuthBody"); const AuthBody = sdk.getComponent("auth.AuthBody");
const loader = this.isBusy() && !this.state.busyLoggingIn ? const loader = this.isBusy() && !this.state.busyLoggingIn ?
<div className="mx_Login_loader"><Loader /></div> : null; <div className="mx_Login_loader"><Spinner /></div> : null;
const errorText = this.state.errorText; const errorText = this.state.errorText;

View file

@ -1,8 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 Matrix from 'matrix-js-sdk';
import React from 'react'; import React, {ComponentProps, ReactNode} from 'react';
import PropTypes from 'prop-types'; import {MatrixClient} from "matrix-js-sdk/src/client";
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import { _t, _td } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
@ -34,36 +32,96 @@ import Login from "../../../Login";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
// Phases // Phases
// Show controls to configure server details enum Phase {
const PHASE_SERVER_DETAILS = 0; // Show controls to configure server details
// Show the appropriate registration flow(s) for the server ServerDetails = 0,
const PHASE_REGISTRATION = 1; // 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<string, string>;
// 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 // Enable phases for registration
const PHASES_ENABLED = true; const PHASES_ENABLED = true;
export default class Registration extends React.Component { export default class Registration extends React.Component<IProps, IState> {
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,
};
constructor(props) { constructor(props) {
super(props); super(props);
@ -71,56 +129,22 @@ export default class Registration extends React.Component {
this.state = { this.state = {
busy: false, busy: false,
errorText: null, 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: { formVals: {
email: this.props.email, 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), doingUIAuth: Boolean(this.props.sessionId),
serverType, serverType,
// Phase of the overall registration dialog. phase: Phase.Registration,
phase: PHASE_REGISTRATION,
flows: null, flows: null,
// If set, we've registered but are not going to log
// the user in to their new account automatically.
completedNoSignin: false, 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, serverIsAlive: true,
serverErrorIsFatal: false, serverErrorIsFatal: false,
serverDeadError: "", 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() { componentDidMount() {
this._unmounted = false; this.replaceClient(this.props.serverConfig);
this._replaceClient();
} }
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event // 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 && if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; 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 // Handle cases where the user enters "https://matrix.org" for their server
// from the advanced option - we should default to FREE at that point. // 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. // Reset the phase to default phase for the server type.
this.setState({ this.setState({
serverType, serverType,
phase: this.getDefaultPhaseForServerType(serverType), phase: Registration.getDefaultPhaseForServerType(serverType),
}); });
} }
} }
getDefaultPhaseForServerType(type) { private static getDefaultPhaseForServerType(type: IState["serverType"]) {
switch (type) { switch (type) {
case ServerType.FREE: { case ServerType.FREE: {
// Move directly to the registration phase since the server // Move directly to the registration phase since the server
// details are fixed. // details are fixed.
return PHASE_REGISTRATION; return Phase.Registration;
} }
case ServerType.PREMIUM: case ServerType.PREMIUM:
case ServerType.ADVANCED: case ServerType.ADVANCED:
return PHASE_SERVER_DETAILS; return Phase.ServerDetails;
} }
} }
onServerTypeChange = type => { private onServerTypeChange = (type: IState["serverType"]) => {
this.setState({ this.setState({
serverType: type, serverType: type,
}); });
@ -181,11 +205,11 @@ export default class Registration extends React.Component {
// Reset the phase to default phase for the server type. // Reset the phase to default phase for the server type.
this.setState({ this.setState({
phase: this.getDefaultPhaseForServerType(type), phase: Registration.getDefaultPhaseForServerType(type),
}); });
}; };
async _replaceClient(serverConfig) { private async replaceClient(serverConfig: ValidatedServerConfig) {
this.setState({ this.setState({
errorText: null, errorText: null,
serverDeadError: 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) // the UI auth component while we don't have a matrix client)
busy: true, busy: true,
}); });
if (!serverConfig) serverConfig = this.props.serverConfig;
// Do a liveliness check on the URLs // Do a liveliness check on the URLs
try { 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 // do SSO instead. If we've already started the UI Auth process though, we don't
// need to. // need to.
if (!this.state.doingUIAuth) { if (!this.state.doingUIAuth) {
await this._makeRegisterRequest(null); await this.makeRegisterRequest(null);
// This should never succeed since we specified no auth object. // This should never succeed since we specified no auth object.
console.log("Expecting 401 from register request but got success!"); 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({ this.setState({
errorText: "", errorText: "",
busy: true, 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( return this.state.matrixClient.requestRegisterEmailToken(
emailAddress, emailAddress,
clientSecret, 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) { if (!success) {
let msg = response.message || response.toString(); let msg = response.message || response.toString();
// can we give a better error message? // can we give a better error message?
if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError( const errorTop = messageForResourceLimitError(
response.data.limit_type, response.data.limit_type,
response.data.admin_contact, { response.data.admin_contact,
'monthly_active_user': _td( {
"This homeserver has hit its Monthly Active User limit.", 'monthly_active_user': _td("This homeserver has hit its Monthly Active User limit."),
), '': _td("This homeserver has exceeded one of its resource limits."),
'': _td( },
"This homeserver has exceeded one of its resource limits.", );
),
});
const errorDetail = messageForResourceLimitError( const errorDetail = messageForResourceLimitError(
response.data.limit_type, response.data.limit_type,
response.data.admin_contact, { response.data.admin_contact,
'': _td( {
"Please <a>contact your service administrator</a> to continue using this service.", '': _td("Please <a>contact your service administrator</a> to continue using this service."),
), },
}); );
msg = <div> msg = <div>
<p>{errorTop}</p> <p>{errorTop}</p>
<p>{errorDetail}</p> <p>{errorDetail}</p>
@ -339,7 +360,7 @@ export default class Registration extends React.Component {
} else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) { } else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
let msisdnAvailable = false; let msisdnAvailable = false;
for (const flow of response.available_flows) { 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) { if (!msisdnAvailable) {
msg = _t('This server does not support authentication with a phone number.'); 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 = { const newState = {
doingUIAuth: false, doingUIAuth: false,
registeredUsername: response.user_id, 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 // 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.`, `Found a session for ${sessionOwner} but ${response.userId} has just registered.`,
); );
newState.differentLoggedInUserId = sessionOwner; newState.differentLoggedInUserId = sessionOwner;
} else {
newState.differentLoggedInUserId = null;
} }
if (response.access_token) { if (response.access_token) {
@ -385,9 +408,7 @@ export default class Registration extends React.Component {
accessToken: response.access_token, accessToken: response.access_token,
}, this.state.formVals.password); }, this.state.formVals.password);
this._setupPushers(); this.setupPushers();
// we're still busy until we get unmounted: don't show the registration form again
newState.busy = true;
} else { } else {
newState.busy = false; newState.busy = false;
newState.completedNoSignin = true; newState.completedNoSignin = true;
@ -396,7 +417,7 @@ export default class Registration extends React.Component {
this.setState(newState); this.setState(newState);
}; };
_setupPushers() { private setupPushers() {
if (!this.props.brand) { if (!this.props.brand) {
return Promise.resolve(); return Promise.resolve();
} }
@ -419,38 +440,38 @@ export default class Registration extends React.Component {
}); });
} }
onLoginClick = ev => { private onLoginClick = ev => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.props.onLoginClick(); this.props.onLoginClick();
}; };
onGoToFormClicked = ev => { private onGoToFormClicked = ev => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this._replaceClient(); this.replaceClient(this.props.serverConfig);
this.setState({ this.setState({
busy: false, busy: false,
doingUIAuth: false, doingUIAuth: false,
phase: PHASE_REGISTRATION, phase: Phase.Registration,
}); });
}; };
onServerDetailsNextPhaseClick = async () => { private onServerDetailsNextPhaseClick = async () => {
this.setState({ this.setState({
phase: PHASE_REGISTRATION, phase: Phase.Registration,
}); });
}; };
onEditServerDetailsClick = ev => { private onEditServerDetailsClick = ev => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.setState({ 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 // 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 // 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 // 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, username: this.state.formVals.username,
password: this.state.formVals.password, password: this.state.formVals.password,
initial_device_display_name: this.props.defaultDeviceDisplayName, initial_device_display_name: this.props.defaultDeviceDisplayName,
auth: undefined,
inhibit_login: undefined,
}; };
if (auth) registerParams.auth = auth; if (auth) registerParams.auth = auth;
if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin; if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin;
return this.state.matrixClient.registerRequest(registerParams); return this.state.matrixClient.registerRequest(registerParams);
}; };
_getUIAuthInputs() { private getUIAuthInputs() {
return { return {
emailAddress: this.state.formVals.email, emailAddress: this.state.formVals.email,
phoneCountry: this.state.formVals.phoneCountry, 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 // 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 // which checks the user hasn't already logged in somewhere else (perhaps we should do
// this more generally?) // this more generally?)
_onLoginClickWithCheck = async ev => { private onLoginClickWithCheck = async ev => {
ev.preventDefault(); ev.preventDefault();
const sessionLoaded = await Lifecycle.loadSession({ignoreGuest: true}); 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 ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector");
const ServerConfig = sdk.getComponent("auth.ServerConfig"); const ServerConfig = sdk.getComponent("auth.ServerConfig");
const ModularServerConfig = sdk.getComponent("auth.ModularServerConfig"); 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 // 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; return null;
} }
@ -511,7 +534,7 @@ export default class Registration extends React.Component {
// which is always shown if we allow custom URLs at all. // 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 // (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). // 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 <div> return <div>
<ServerTypeSelector <ServerTypeSelector
selected={this.state.serverType} selected={this.state.serverType}
@ -520,7 +543,7 @@ export default class Registration extends React.Component {
</div>; </div>;
} }
const serverDetailsProps = {}; const serverDetailsProps: ComponentProps<typeof ServerConfig> = {};
if (PHASES_ENABLED) { if (PHASES_ENABLED) {
serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick; serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick;
serverDetailsProps.submitText = _t("Next"); serverDetailsProps.submitText = _t("Next");
@ -559,8 +582,8 @@ export default class Registration extends React.Component {
</div>; </div>;
} }
renderRegisterComponent() { private renderRegisterComponent() {
if (PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) { if (PHASES_ENABLED && this.state.phase !== Phase.Registration) {
return null; return null;
} }
@ -571,10 +594,10 @@ export default class Registration extends React.Component {
if (this.state.matrixClient && this.state.doingUIAuth) { if (this.state.matrixClient && this.state.doingUIAuth) {
return <InteractiveAuth return <InteractiveAuth
matrixClient={this.state.matrixClient} matrixClient={this.state.matrixClient}
makeRequest={this._makeRegisterRequest} makeRequest={this.makeRegisterRequest}
onAuthFinished={this._onUIAuthFinished} onAuthFinished={this.onUIAuthFinished}
inputs={this._getUIAuthInputs()} inputs={this.getUIAuthInputs()}
requestEmailToken={this._requestEmailToken} requestEmailToken={this.requestEmailToken}
sessionId={this.props.sessionId} sessionId={this.props.sessionId}
clientSecret={this.props.clientSecret} clientSecret={this.props.clientSecret}
emailSid={this.props.idSid} emailSid={this.props.idSid}
@ -633,7 +656,7 @@ export default class Registration extends React.Component {
// Only show the 'go back' button if you're not looking at the form // Only show the 'go back' button if you're not looking at the form
let goBack; let goBack;
if ((PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) || this.state.doingUIAuth) { if ((PHASES_ENABLED && this.state.phase !== Phase.Registration) || this.state.doingUIAuth) {
goBack = <a className="mx_AuthBody_changeFlow" onClick={this.onGoToFormClicked} href="#"> goBack = <a className="mx_AuthBody_changeFlow" onClick={this.onGoToFormClicked} href="#">
{ _t('Go back') } { _t('Go back') }
</a>; </a>;
@ -651,7 +674,7 @@ export default class Registration extends React.Component {
loggedInUserId: this.state.differentLoggedInUserId, loggedInUserId: this.state.differentLoggedInUserId,
}, },
)}</p> )}</p>
<p><AccessibleButton element="span" className="mx_linkButton" onClick={this._onLoginClickWithCheck}> <p><AccessibleButton element="span" className="mx_linkButton" onClick={this.onLoginClickWithCheck}>
{_t("Continue with previous account")} {_t("Continue with previous account")}
</AccessibleButton></p> </AccessibleButton></p>
</div>; </div>;
@ -660,7 +683,7 @@ export default class Registration extends React.Component {
regDoneText = <h3>{_t( regDoneText = <h3>{_t(
"<a>Log in</a> to your new account.", {}, "<a>Log in</a> to your new account.", {},
{ {
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>, a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{sub}</a>,
}, },
)}</h3>; )}</h3>;
} else { } else {
@ -670,7 +693,7 @@ export default class Registration extends React.Component {
regDoneText = <h3>{_t( regDoneText = <h3>{_t(
"You can now close this window or <a>log in</a> to your new account.", {}, "You can now close this window or <a>log in</a> to your new account.", {},
{ {
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>, a: (sub) => <a href="#/login" onClick={this.onLoginClickWithCheck}>{sub}</a>,
}, },
)}</h3>; )}</h3>;
} }
@ -679,7 +702,7 @@ export default class Registration extends React.Component {
{ regDoneText } { regDoneText }
</div>; </div>;
} else { } 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, serverName: this.props.serverConfig.hsName,
}); });
if (this.props.serverConfig.hsNameIsDifferent) { if (this.props.serverConfig.hsNameIsDifferent) {
@ -717,7 +740,7 @@ export default class Registration extends React.Component {
{ errorText } { errorText }
{ serverDeadSection } { serverDeadSection }
{ this.renderServerComponent() } { this.renderServerComponent() }
{ this.state.phase !== PHASE_SERVER_DETAILS && <h3> { this.state.phase !== Phase.ServerDetails && <h3>
{yourMatrixAccountText} {yourMatrixAccountText}
{editLink} {editLink}
</h3> } </h3> }

View file

@ -21,9 +21,9 @@ import zxcvbn from "zxcvbn";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
import withValidation, {IFieldState, IValidationResult} from "../elements/Validation"; import withValidation, {IFieldState, IValidationResult} from "../elements/Validation";
import {_t, _td} from "../../../languageHandler"; import {_t, _td} from "../../../languageHandler";
import Field from "../elements/Field"; import Field, {IInputProps} from "../elements/Field";
interface IProps { interface IProps extends Omit<IInputProps, "onValidate"> {
autoFocus?: boolean; autoFocus?: boolean;
id?: string; id?: string;
className?: string; className?: string;

View file

@ -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 <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
key="email_input"
type="text"
label={_t("Email")}
placeholder="joe@example.com"
value={this.state.username}
onChange={this.onUsernameChanged}
onFocus={this.onUsernameFocus}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
/>;
case PasswordLogin.LOGIN_FIELD_MXID:
classes.error = this.props.loginIncorrect && !this.state.username;
return <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
key="username_input"
type="text"
label={_t("Username")}
value={this.state.username}
onChange={this.onUsernameChanged}
onFocus={this.onUsernameFocus}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
/>;
case PasswordLogin.LOGIN_FIELD_PHONE: {
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
classes.error = this.props.loginIncorrect && !this.state.phoneNumber;
const phoneCountry = <CountryDropdown
value={this.state.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChanged}
/>;
return <Field
className={classNames(classes)}
name="phoneNumber"
key="phone_input"
type="text"
label={_t("Phone")}
value={this.state.phoneNumber}
prefixComponent={phoneCountry}
onChange={this.onPhoneNumberChanged}
onFocus={this.onPhoneNumberFocus}
onBlur={this.onPhoneNumberBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
/>;
}
}
}
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 = <span>
{_t('Not sure of your password? <a>Set a new one</a>', {}, {
a: sub => (
<AccessibleButton
className="mx_Login_forgot"
disabled={this.props.busy}
kind="link"
onClick={this.onForgotPasswordClick}
>
{sub}
</AccessibleButton>
),
})}
</span>;
}
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 = (
<div className="mx_Login_type_container">
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
<Field
element="select"
value={this.state.loginType}
onChange={this.onLoginTypeChange}
disabled={this.props.disableSubmit}
>
<option
key={PasswordLogin.LOGIN_FIELD_MXID}
value={PasswordLogin.LOGIN_FIELD_MXID}
>
{_t('Username')}
</option>
<option
key={PasswordLogin.LOGIN_FIELD_EMAIL}
value={PasswordLogin.LOGIN_FIELD_EMAIL}
>
{_t('Email address')}
</option>
<option
key={PasswordLogin.LOGIN_FIELD_PHONE}
value={PasswordLogin.LOGIN_FIELD_PHONE}
>
{_t('Phone')}
</option>
</Field>
</div>
);
}
return (
<div>
<SignInToText serverConfig={this.props.serverConfig}
onEditServerDetailsClick={this.props.onEditServerDetailsClick} />
<form onSubmit={this.onSubmitForm}>
{loginType}
{loginField}
<Field
className={pwFieldClass}
type="password"
name="password"
label={_t('Password')}
value={this.state.password}
onChange={this.onPasswordChanged}
disabled={this.props.disableSubmit}
autoFocus={autoFocusPassword}
/>
{forgotPasswordJsx}
{ !this.props.busy && <input className="mx_Login_submit"
type="submit"
value={_t('Sign in')}
disabled={this.props.disableSubmit}
/> }
</form>
</div>
);
}
}

View file

@ -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<Record<LoginField, boolean>>;
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<IProps, IState> {
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 <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
key="email_input"
type="text"
label={_t("Email")}
placeholder="joe@example.com"
value={this.props.username}
onChange={this.onUsernameChanged}
onFocus={this.onUsernameFocus}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
onValidate={this.onEmailValidate}
ref={field => this[LoginField.Email] = field}
/>;
case LoginField.MatrixId:
classes.error = this.props.loginIncorrect && !this.props.username;
return <Field
className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password
key="username_input"
type="text"
label={_t("Username")}
value={this.props.username}
onChange={this.onUsernameChanged}
onFocus={this.onUsernameFocus}
onBlur={this.onUsernameBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
onValidate={this.onUsernameValidate}
ref={field => this[LoginField.MatrixId] = field}
/>;
case LoginField.Phone: {
classes.error = this.props.loginIncorrect && !this.props.phoneNumber;
const phoneCountry = <CountryDropdown
value={this.props.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChanged}
/>;
return <Field
className={classNames(classes)}
name="phoneNumber"
key="phone_input"
type="text"
label={_t("Phone")}
value={this.props.phoneNumber}
prefixComponent={phoneCountry}
onChange={this.onPhoneNumberChanged}
onFocus={this.onPhoneNumberFocus}
onBlur={this.onPhoneNumberBlur}
disabled={this.props.disableSubmit}
autoFocus={autoFocus}
onValidate={this.onPhoneNumberValidate}
ref={field => 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 = <span>
{_t('Not sure of your password? <a>Set a new one</a>', {}, {
a: sub => (
<AccessibleButton
className="mx_Login_forgot"
disabled={this.props.busy}
kind="link"
onClick={this.onForgotPasswordClick}
>
{sub}
</AccessibleButton>
),
})}
</span>;
}
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 = (
<div className="mx_Login_type_container">
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
<Field
element="select"
value={this.state.loginType}
onChange={this.onLoginTypeChange}
disabled={this.props.disableSubmit}
>
<option key={LoginField.MatrixId} value={LoginField.MatrixId}>
{_t('Username')}
</option>
<option
key={LoginField.Email}
value={LoginField.Email}
>
{_t('Email address')}
</option>
<option key={LoginField.Password} value={LoginField.Password}>
{_t('Phone')}
</option>
</Field>
</div>
);
}
return (
<div>
<SignInToText serverConfig={this.props.serverConfig}
onEditServerDetailsClick={this.props.onEditServerDetailsClick} />
<form onSubmit={this.onSubmitForm}>
{loginType}
{loginField}
<Field
className={pwFieldClass}
type="password"
name="password"
label={_t('Password')}
value={this.state.password}
onChange={this.onPasswordChanged}
disabled={this.props.disableSubmit}
autoFocus={autoFocusPassword}
onValidate={this.onPasswordValidate}
ref={field => this[LoginField.Password] = field}
/>
{forgotPasswordJsx}
{ !this.props.busy && <input className="mx_Login_submit"
type="submit"
value={_t('Sign in')}
disabled={this.props.disableSubmit}
/> }
</form>
</div>
);
}
}

View file

@ -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 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import * as Email from '../../../email'; import * as Email from '../../../email';
import { looksValid as phoneNumberLooksValid } from '../../../phonenumber'; import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
@ -31,32 +29,57 @@ import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import PassphraseField from "./PassphraseField"; import PassphraseField from "./PassphraseField";
import CountlyAnalytics from "../../../CountlyAnalytics"; import CountlyAnalytics from "../../../CountlyAnalytics";
const FIELD_EMAIL = 'field_email'; enum RegistrationField {
const FIELD_PHONE_NUMBER = 'field_phone_number'; Email = "field_email",
const FIELD_USERNAME = 'field_username'; PhoneNumber = "field_phone_number",
const FIELD_PASSWORD = 'field_password'; Username = "field_username",
const FIELD_PASSWORD_CONFIRM = 'field_password_confirm'; Password = "field_password",
PasswordConfirm = "field_password_confirm",
}
const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario. 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<void>;
onEditServerDetailsClick?(): void;
}
interface IState {
// Field error codes by field ID
fieldValid: Partial<Record<RegistrationField, boolean>>;
// The ISO2 country code selected in the phone number entry
phoneCountry: string;
username: string;
email: string;
phoneNumber: string;
password: string;
passwordConfirm: string;
passwordComplexity?: number;
}
/* /*
* A pure UI component which displays a registration form. * A pure UI component which displays a registration form.
*/ */
export default class RegistrationForm extends React.Component { export default class RegistrationForm extends React.PureComponent<IProps, IState> {
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,
};
static defaultProps = { static defaultProps = {
onValidationChange: console.error, onValidationChange: console.error,
canSubmit: true, canSubmit: true,
@ -66,9 +89,7 @@ export default class RegistrationForm extends React.Component {
super(props); super(props);
this.state = { this.state = {
// Field error codes by field ID
fieldValid: {}, fieldValid: {},
// The ISO2 country code selected in the phone number entry
phoneCountry: this.props.defaultPhoneCountry, phoneCountry: this.props.defaultPhoneCountry,
username: this.props.defaultUsername || "", username: this.props.defaultUsername || "",
email: this.props.defaultEmail || "", email: this.props.defaultEmail || "",
@ -81,7 +102,7 @@ export default class RegistrationForm extends React.Component {
CountlyAnalytics.instance.track("onboarding_registration_begin"); CountlyAnalytics.instance.track("onboarding_registration_begin");
} }
onSubmit = async ev => { private onSubmit = async ev => {
ev.preventDefault(); ev.preventDefault();
if (!this.props.canSubmit) return; if (!this.props.canSubmit) return;
@ -92,7 +113,6 @@ export default class RegistrationForm extends React.Component {
return; return;
} }
const self = this;
if (this.state.email === '') { if (this.state.email === '') {
const haveIs = Boolean(this.props.serverConfig.isUrl); 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 " + "No identity server is configured so you cannot add an email address in order to " +
"reset your password in the future.", "reset your password in the future.",
); );
} else if (this._showEmail()) { } else if (this.showEmail()) {
desc = _t( desc = _t(
"If you don't specify an email address, you won't be able to reset your password. " + "If you don't specify an email address, you won't be able to reset your password. " +
"Are you sure?", "Are you sure?",
); );
} else { } else {
// user can't set an e-mail so don't prompt them to // user can't set an e-mail so don't prompt them to
self._doSubmit(ev); this.doSubmit(ev);
return; return;
} }
@ -120,18 +140,18 @@ export default class RegistrationForm extends React.Component {
title: _t("Warning!"), title: _t("Warning!"),
description: desc, description: desc,
button: _t("Continue"), button: _t("Continue"),
onFinished(confirmed) { onFinished: (confirmed) => {
if (confirmed) { if (confirmed) {
self._doSubmit(ev); this.doSubmit(ev);
} }
}, },
}); });
} else { } else {
self._doSubmit(ev); this.doSubmit(ev);
} }
}; };
_doSubmit(ev) { private doSubmit(ev) {
const email = this.state.email.trim(); const email = this.state.email.trim();
CountlyAnalytics.instance.track("onboarding_registration_submit_ok", { 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, // 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. // 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) { if (activeElement) {
activeElement.blur(); activeElement.blur();
} }
const fieldIDsInDisplayOrder = [ const fieldIDsInDisplayOrder = [
FIELD_USERNAME, RegistrationField.Username,
FIELD_PASSWORD, RegistrationField.Password,
FIELD_PASSWORD_CONFIRM, RegistrationField.PasswordConfirm,
FIELD_EMAIL, RegistrationField.Email,
FIELD_PHONE_NUMBER, RegistrationField.PhoneNumber,
]; ];
// Run all fields with stricter validation that no longer allows empty // 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. * @returns {boolean} true if all fields were valid last time they were validated.
*/ */
allFieldsValid() { private allFieldsValid() {
const keys = Object.keys(this.state.fieldValid); const keys = Object.keys(this.state.fieldValid);
for (let i = 0; i < keys.length; ++i) { for (let i = 0; i < keys.length; ++i) {
if (!this.state.fieldValid[keys[i]]) { if (!this.state.fieldValid[keys[i]]) {
@ -218,7 +238,7 @@ export default class RegistrationForm extends React.Component {
return true; return true;
} }
findFirstInvalidField(fieldIDs) { private findFirstInvalidField(fieldIDs: RegistrationField[]) {
for (const fieldID of fieldIDs) { for (const fieldID of fieldIDs) {
if (!this.state.fieldValid[fieldID] && this[fieldID]) { if (!this.state.fieldValid[fieldID] && this[fieldID]) {
return this[fieldID]; return this[fieldID];
@ -227,7 +247,7 @@ export default class RegistrationForm extends React.Component {
return null; return null;
} }
markFieldValid(fieldID, valid) { private markFieldValid(fieldID: RegistrationField, valid: boolean) {
const { fieldValid } = this.state; const { fieldValid } = this.state;
fieldValid[fieldID] = valid; fieldValid[fieldID] = valid;
this.setState({ this.setState({
@ -235,26 +255,26 @@ export default class RegistrationForm extends React.Component {
}); });
} }
onEmailChange = ev => { private onEmailChange = ev => {
this.setState({ this.setState({
email: ev.target.value, email: ev.target.value,
}); });
}; };
onEmailValidate = async fieldState => { private onEmailValidate = async fieldState => {
const result = await this.validateEmailRules(fieldState); const result = await this.validateEmailRules(fieldState);
this.markFieldValid(FIELD_EMAIL, result.valid); this.markFieldValid(RegistrationField.Email, result.valid);
return result; return result;
}; };
validateEmailRules = withValidation({ private validateEmailRules = withValidation({
description: () => _t("Use an email address to recover your account"), description: () => _t("Use an email address to recover your account"),
hideDescriptionIfValid: true, hideDescriptionIfValid: true,
rules: [ rules: [
{ {
key: "required", key: "required",
test({ value, allowEmpty }) { test(this: RegistrationForm, { value, allowEmpty }) {
return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value; return allowEmpty || !this.authStepIsRequired('m.login.email.identity') || !!value;
}, },
invalid: () => _t("Enter email address (required on this homeserver)"), 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({ this.setState({
password: ev.target.value, password: ev.target.value,
}); });
}; };
onPasswordValidate = result => { private onPasswordValidate = result => {
this.markFieldValid(FIELD_PASSWORD, result.valid); this.markFieldValid(RegistrationField.Password, result.valid);
}; };
onPasswordConfirmChange = ev => { private onPasswordConfirmChange = ev => {
this.setState({ this.setState({
passwordConfirm: ev.target.value, passwordConfirm: ev.target.value,
}); });
}; };
onPasswordConfirmValidate = async fieldState => { private onPasswordConfirmValidate = async fieldState => {
const result = await this.validatePasswordConfirmRules(fieldState); const result = await this.validatePasswordConfirmRules(fieldState);
this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid); this.markFieldValid(RegistrationField.PasswordConfirm, result.valid);
return result; return result;
}; };
validatePasswordConfirmRules = withValidation({ private validatePasswordConfirmRules = withValidation({
rules: [ rules: [
{ {
key: "required", key: "required",
@ -297,41 +317,40 @@ export default class RegistrationForm extends React.Component {
}, },
{ {
key: "match", key: "match",
test({ value }) { test(this: RegistrationForm, { value }) {
return !value || value === this.state.password; return !value || value === this.state.password;
}, },
invalid: () => _t("Passwords don't match"), invalid: () => _t("Passwords don't match"),
}, },
], ],
}); });
onPhoneCountryChange = newVal => { private onPhoneCountryChange = newVal => {
this.setState({ this.setState({
phoneCountry: newVal.iso2, phoneCountry: newVal.iso2,
phonePrefix: newVal.prefix,
}); });
}; };
onPhoneNumberChange = ev => { private onPhoneNumberChange = ev => {
this.setState({ this.setState({
phoneNumber: ev.target.value, phoneNumber: ev.target.value,
}); });
}; };
onPhoneNumberValidate = async fieldState => { private onPhoneNumberValidate = async fieldState => {
const result = await this.validatePhoneNumberRules(fieldState); const result = await this.validatePhoneNumberRules(fieldState);
this.markFieldValid(FIELD_PHONE_NUMBER, result.valid); this.markFieldValid(RegistrationField.PhoneNumber, result.valid);
return result; return result;
}; };
validatePhoneNumberRules = withValidation({ private validatePhoneNumberRules = withValidation({
description: () => _t("Other users can invite you to rooms using your contact details"), description: () => _t("Other users can invite you to rooms using your contact details"),
hideDescriptionIfValid: true, hideDescriptionIfValid: true,
rules: [ rules: [
{ {
key: "required", key: "required",
test({ value, allowEmpty }) { test(this: RegistrationForm, { value, allowEmpty }) {
return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value; return allowEmpty || !this.authStepIsRequired('m.login.msisdn') || !!value;
}, },
invalid: () => _t("Enter phone number (required on this homeserver)"), 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({ this.setState({
username: ev.target.value, username: ev.target.value,
}); });
}; };
onUsernameValidate = async fieldState => { private onUsernameValidate = async fieldState => {
const result = await this.validateUsernameRules(fieldState); const result = await this.validateUsernameRules(fieldState);
this.markFieldValid(FIELD_USERNAME, result.valid); this.markFieldValid(RegistrationField.Username, result.valid);
return result; return result;
}; };
validateUsernameRules = withValidation({ private validateUsernameRules = withValidation({
description: () => _t("Use lowercase letters, numbers, dashes and underscores only"), description: () => _t("Use lowercase letters, numbers, dashes and underscores only"),
hideDescriptionIfValid: true, hideDescriptionIfValid: true,
rules: [ rules: [
@ -378,7 +397,7 @@ export default class RegistrationForm extends React.Component {
* @param {string} step A stage name to check * @param {string} step A stage name to check
* @returns {boolean} Whether it is required * @returns {boolean} Whether it is required
*/ */
_authStepIsRequired(step) { private authStepIsRequired(step: string) {
return this.props.flows.every((flow) => { return this.props.flows.every((flow) => {
return flow.stages.includes(step); return flow.stages.includes(step);
}); });
@ -390,46 +409,46 @@ export default class RegistrationForm extends React.Component {
* @param {string} step A stage name to check * @param {string} step A stage name to check
* @returns {boolean} Whether it is used * @returns {boolean} Whether it is used
*/ */
_authStepIsUsed(step) { private authStepIsUsed(step: string) {
return this.props.flows.some((flow) => { return this.props.flows.some((flow) => {
return flow.stages.includes(step); return flow.stages.includes(step);
}); });
} }
_showEmail() { private showEmail() {
const haveIs = Boolean(this.props.serverConfig.isUrl); const haveIs = Boolean(this.props.serverConfig.isUrl);
if ( if (
(this.props.serverRequiresIdServer && !haveIs) || (this.props.serverRequiresIdServer && !haveIs) ||
!this._authStepIsUsed('m.login.email.identity') !this.authStepIsUsed('m.login.email.identity')
) { ) {
return false; return false;
} }
return true; return true;
} }
_showPhoneNumber() { private showPhoneNumber() {
const threePidLogin = !SdkConfig.get().disable_3pid_login; const threePidLogin = !SdkConfig.get().disable_3pid_login;
const haveIs = Boolean(this.props.serverConfig.isUrl); const haveIs = Boolean(this.props.serverConfig.isUrl);
if ( if (
!threePidLogin || !threePidLogin ||
(this.props.serverRequiresIdServer && !haveIs) || (this.props.serverRequiresIdServer && !haveIs) ||
!this._authStepIsUsed('m.login.msisdn') !this.authStepIsUsed('m.login.msisdn')
) { ) {
return false; return false;
} }
return true; return true;
} }
renderEmail() { private renderEmail() {
if (!this._showEmail()) { if (!this.showEmail()) {
return null; return null;
} }
const Field = sdk.getComponent('elements.Field'); 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") :
_t("Email (optional)"); _t("Email (optional)");
return <Field return <Field
ref={field => this[FIELD_EMAIL] = field} ref={field => this[RegistrationField.Email] = field}
type="text" type="text"
label={emailPlaceholder} label={emailPlaceholder}
value={this.state.email} value={this.state.email}
@ -440,10 +459,10 @@ export default class RegistrationForm extends React.Component {
/>; />;
} }
renderPassword() { private renderPassword() {
return <PassphraseField return <PassphraseField
id="mx_RegistrationForm_password" id="mx_RegistrationForm_password"
fieldRef={field => this[FIELD_PASSWORD] = field} fieldRef={field => this[RegistrationField.Password] = field}
minScore={PASSWORD_MIN_SCORE} minScore={PASSWORD_MIN_SCORE}
value={this.state.password} value={this.state.password}
onChange={this.onPasswordChange} onChange={this.onPasswordChange}
@ -457,7 +476,7 @@ export default class RegistrationForm extends React.Component {
const Field = sdk.getComponent('elements.Field'); const Field = sdk.getComponent('elements.Field');
return <Field return <Field
id="mx_RegistrationForm_passwordConfirm" id="mx_RegistrationForm_passwordConfirm"
ref={field => this[FIELD_PASSWORD_CONFIRM] = field} ref={field => this[RegistrationField.PasswordConfirm] = field}
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
label={_t("Confirm password")} label={_t("Confirm password")}
@ -470,12 +489,12 @@ export default class RegistrationForm extends React.Component {
} }
renderPhoneNumber() { renderPhoneNumber() {
if (!this._showPhoneNumber()) { if (!this.showPhoneNumber()) {
return null; return null;
} }
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown'); const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
const Field = sdk.getComponent('elements.Field'); const Field = sdk.getComponent('elements.Field');
const phoneLabel = this._authStepIsRequired('m.login.msisdn') ? const phoneLabel = this.authStepIsRequired('m.login.msisdn') ?
_t("Phone") : _t("Phone") :
_t("Phone (optional)"); _t("Phone (optional)");
const phoneCountry = <CountryDropdown const phoneCountry = <CountryDropdown
@ -485,7 +504,7 @@ export default class RegistrationForm extends React.Component {
onOptionChange={this.onPhoneCountryChange} onOptionChange={this.onPhoneCountryChange}
/>; />;
return <Field return <Field
ref={field => this[FIELD_PHONE_NUMBER] = field} ref={field => this[RegistrationField.PhoneNumber] = field}
type="text" type="text"
label={phoneLabel} label={phoneLabel}
value={this.state.phoneNumber} value={this.state.phoneNumber}
@ -499,7 +518,7 @@ export default class RegistrationForm extends React.Component {
const Field = sdk.getComponent('elements.Field'); const Field = sdk.getComponent('elements.Field');
return <Field return <Field
id="mx_RegistrationForm_username" id="mx_RegistrationForm_username"
ref={field => this[FIELD_USERNAME] = field} ref={field => this[RegistrationField.Username] = field}
type="text" type="text"
autoFocus={true} autoFocus={true}
label={_t("Username")} label={_t("Username")}
@ -517,8 +536,8 @@ export default class RegistrationForm extends React.Component {
); );
let emailHelperText = null; let emailHelperText = null;
if (this._showEmail()) { if (this.showEmail()) {
if (this._showPhoneNumber()) { if (this.showPhoneNumber()) {
emailHelperText = <div> emailHelperText = <div>
{_t( {_t(
"Set an email for account recovery. " + "Set an email for account recovery. " +

View file

@ -64,7 +64,7 @@ interface IProps {
// All other props pass through to the <input>. // All other props pass through to the <input>.
} }
interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> { export interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
// The element to create. Defaults to "input". // The element to create. Defaults to "input".
element?: "input"; element?: "input";
// The input's value. This is a controlled component, so the value is required. // The input's value. This is a controlled component, so the value is required.

View file

@ -32,7 +32,7 @@ interface IRule<T, D = void> {
interface IArgs<T, D = void> { interface IArgs<T, D = void> {
rules: IRule<T, D>[]; rules: IRule<T, D>[];
description(this: T, derivedData: D): React.ReactChild; description?(this: T, derivedData: D): React.ReactChild;
hideDescriptionIfValid?: boolean; hideDescriptionIfValid?: boolean;
deriveData?(data: Data): Promise<D>; deriveData?(data: Data): Promise<D>;
} }

View file

@ -2286,10 +2286,11 @@
"Nice, strong password!": "Nice, strong password!", "Nice, strong password!": "Nice, strong password!",
"Password is allowed, but unsafe": "Password is allowed, but unsafe", "Password is allowed, but unsafe": "Password is allowed, but unsafe",
"Keep going...": "Keep going...", "Keep going...": "Keep going...",
"The email field must not be blank.": "The email field must not be blank.", "Enter username": "Enter username",
"The username field must not be blank.": "The username field must not be blank.", "Enter email address": "Enter email address",
"The phone number field must not be blank.": "The phone number field must not be blank.", "Doesn't look like a valid email address": "Doesn't look like a valid email address",
"The password field must not be blank.": "The password field must not be blank.", "Enter phone number": "Enter phone number",
"Doesn't look like a valid phone number": "Doesn't look like a valid phone number",
"Email": "Email", "Email": "Email",
"Username": "Username", "Username": "Username",
"Phone": "Phone", "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?", "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", "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)", "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", "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", "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)", "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", "Use lowercase letters, numbers, dashes and underscores only": "Use lowercase letters, numbers, dashes and underscores only",
"Enter username": "Enter username",
"Email (optional)": "Email (optional)", "Email (optional)": "Email (optional)",
"Phone (optional)": "Phone (optional)", "Phone (optional)": "Phone (optional)",
"Register": "Register", "Register": "Register",
@ -2512,7 +2510,6 @@
"Incorrect username and/or password.": "Incorrect username and/or password.", "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.", "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", "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.", "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.", "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 <a>enable unsafe scripts</a>.": "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or <a>enable unsafe scripts</a>.", "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or <a>enable unsafe scripts</a>.": "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or <a>enable unsafe scripts</a>.",