mirror of
https://github.com/element-hq/element-web
synced 2024-11-28 20:38:55 +03:00
Merge pull request #750 from matrix-org/dbkr/msisdn_signin_2
Login / registration with phone number, mark 2
This commit is contained in:
commit
4a29d674f8
12 changed files with 2054 additions and 31 deletions
|
@ -58,6 +58,22 @@ export function unicodeToImage(str) {
|
|||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given one or more unicode characters (represented by unicode
|
||||
* character number), return an image node with the corresponding
|
||||
* emoji.
|
||||
*
|
||||
* @param alt {string} String to use for the image alt text
|
||||
* @param unicode {integer} One or more integers representing unicode characters
|
||||
* @returns A img node with the corresponding emoji
|
||||
*/
|
||||
export function charactersToImageNode(alt, ...unicode) {
|
||||
const fileName = unicode.map((u) => {
|
||||
return u.toString(16);
|
||||
}).join('-');
|
||||
return <img alt={alt} src={`${emojione.imagePathSVG}${fileName}.svg${emojione.cacheBustParam}`}/>;
|
||||
}
|
||||
|
||||
export function stripParagraphs(html: string): string {
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.innerHTML = html;
|
||||
|
|
51
src/Login.js
51
src/Login.js
|
@ -105,21 +105,48 @@ export default class Login {
|
|||
});
|
||||
}
|
||||
|
||||
loginViaPassword(username, pass) {
|
||||
var self = this;
|
||||
var isEmail = username.indexOf("@") > 0;
|
||||
var loginParams = {
|
||||
password: pass,
|
||||
initial_device_display_name: this._defaultDeviceDisplayName,
|
||||
};
|
||||
if (isEmail) {
|
||||
loginParams.medium = 'email';
|
||||
loginParams.address = username;
|
||||
loginViaPassword(username, phoneCountry, phoneNumber, pass) {
|
||||
const self = this;
|
||||
|
||||
const isEmail = username.indexOf("@") > 0;
|
||||
|
||||
let identifier;
|
||||
let legacyParams; // parameters added to support old HSes
|
||||
if (phoneCountry && phoneNumber) {
|
||||
identifier = {
|
||||
type: 'm.id.phone',
|
||||
country: phoneCountry,
|
||||
number: phoneNumber,
|
||||
};
|
||||
// No legacy support for phone number login
|
||||
} else if (isEmail) {
|
||||
identifier = {
|
||||
type: 'm.id.thirdparty',
|
||||
medium: 'email',
|
||||
address: username,
|
||||
};
|
||||
legacyParams = {
|
||||
medium: 'email',
|
||||
address: username,
|
||||
};
|
||||
} else {
|
||||
loginParams.user = username;
|
||||
identifier = {
|
||||
type: 'm.id.user',
|
||||
user: username,
|
||||
};
|
||||
legacyParams = {
|
||||
user: username,
|
||||
};
|
||||
}
|
||||
|
||||
var client = this._createTemporaryClient();
|
||||
const loginParams = {
|
||||
password: pass,
|
||||
identifier: identifier,
|
||||
initial_device_display_name: this._defaultDeviceDisplayName,
|
||||
};
|
||||
Object.assign(loginParams, legacyParams);
|
||||
|
||||
const client = this._createTemporaryClient();
|
||||
return client.login('m.login.password', loginParams).then(function(data) {
|
||||
return q({
|
||||
homeserverUrl: self._hsUrl,
|
||||
|
|
|
@ -111,6 +111,8 @@ import views$elements$DeviceVerifyButtons from './components/views/elements/Devi
|
|||
views$elements$DeviceVerifyButtons && (module.exports.components['views.elements.DeviceVerifyButtons'] = views$elements$DeviceVerifyButtons);
|
||||
import views$elements$DirectorySearchBox from './components/views/elements/DirectorySearchBox';
|
||||
views$elements$DirectorySearchBox && (module.exports.components['views.elements.DirectorySearchBox'] = views$elements$DirectorySearchBox);
|
||||
import views$elements$Dropdown from './components/views/elements/Dropdown';
|
||||
views$elements$Dropdown && (module.exports.components['views.elements.Dropdown'] = views$elements$Dropdown);
|
||||
import views$elements$EditableText from './components/views/elements/EditableText';
|
||||
views$elements$EditableText && (module.exports.components['views.elements.EditableText'] = views$elements$EditableText);
|
||||
import views$elements$EditableTextContainer from './components/views/elements/EditableTextContainer';
|
||||
|
@ -133,6 +135,8 @@ import views$login$CaptchaForm from './components/views/login/CaptchaForm';
|
|||
views$login$CaptchaForm && (module.exports.components['views.login.CaptchaForm'] = views$login$CaptchaForm);
|
||||
import views$login$CasLogin from './components/views/login/CasLogin';
|
||||
views$login$CasLogin && (module.exports.components['views.login.CasLogin'] = views$login$CasLogin);
|
||||
import views$login$CountryDropdown from './components/views/login/CountryDropdown';
|
||||
views$login$CountryDropdown && (module.exports.components['views.login.CountryDropdown'] = views$login$CountryDropdown);
|
||||
import views$login$CustomServerDialog from './components/views/login/CustomServerDialog';
|
||||
views$login$CustomServerDialog && (module.exports.components['views.login.CustomServerDialog'] = views$login$CustomServerDialog);
|
||||
import views$login$InteractiveAuthEntryComponents from './components/views/login/InteractiveAuthEntryComponents';
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -64,8 +65,10 @@ module.exports = React.createClass({
|
|||
enteredHomeserverUrl: this.props.customHsUrl || this.props.defaultHsUrl,
|
||||
enteredIdentityServerUrl: this.props.customIsUrl || this.props.defaultIsUrl,
|
||||
|
||||
// used for preserving username when changing homeserver
|
||||
// used for preserving form values when changing homeserver
|
||||
username: "",
|
||||
phoneCountry: null,
|
||||
phoneNumber: "",
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -73,20 +76,21 @@ module.exports = React.createClass({
|
|||
this._initLoginLogic();
|
||||
},
|
||||
|
||||
onPasswordLogin: function(username, password) {
|
||||
var self = this;
|
||||
self.setState({
|
||||
onPasswordLogin: function(username, phoneCountry, phoneNumber, password) {
|
||||
this.setState({
|
||||
busy: true,
|
||||
errorText: null,
|
||||
loginIncorrect: false,
|
||||
});
|
||||
|
||||
this._loginLogic.loginViaPassword(username, password).then(function(data) {
|
||||
self.props.onLoggedIn(data);
|
||||
}, function(error) {
|
||||
self._setStateFromError(error, true);
|
||||
}).finally(function() {
|
||||
self.setState({
|
||||
this._loginLogic.loginViaPassword(
|
||||
username, phoneCountry, phoneNumber, password,
|
||||
).then((data) => {
|
||||
this.props.onLoggedIn(data);
|
||||
}, (error) => {
|
||||
this._setStateFromError(error, true);
|
||||
}).finally(() => {
|
||||
this.setState({
|
||||
busy: false
|
||||
});
|
||||
}).done();
|
||||
|
@ -119,6 +123,14 @@ module.exports = React.createClass({
|
|||
this.setState({ username: username });
|
||||
},
|
||||
|
||||
onPhoneCountryChanged: function(phoneCountry) {
|
||||
this.setState({ phoneCountry: phoneCountry });
|
||||
},
|
||||
|
||||
onPhoneNumberChanged: function(phoneNumber) {
|
||||
this.setState({ phoneNumber: phoneNumber });
|
||||
},
|
||||
|
||||
onHsUrlChanged: function(newHsUrl) {
|
||||
var self = this;
|
||||
this.setState({
|
||||
|
@ -225,7 +237,11 @@ module.exports = React.createClass({
|
|||
<PasswordLogin
|
||||
onSubmit={this.onPasswordLogin}
|
||||
initialUsername={this.state.username}
|
||||
initialPhoneCountry={this.state.phoneCountry}
|
||||
initialPhoneNumber={this.state.phoneNumber}
|
||||
onUsernameChanged={this.onUsernameChanged}
|
||||
onPhoneCountryChanged={this.onPhoneCountryChanged}
|
||||
onPhoneNumberChanged={this.onPhoneNumberChanged}
|
||||
onForgotPasswordClick={this.props.onForgotPasswordClick}
|
||||
loginIncorrect={this.state.loginIncorrect}
|
||||
/>
|
||||
|
|
|
@ -155,10 +155,21 @@ module.exports = React.createClass({
|
|||
|
||||
_onUIAuthFinished: function(success, response, extra) {
|
||||
if (!success) {
|
||||
let msg = response.message || response.toString();
|
||||
// can we give a better error message?
|
||||
if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
|
||||
let msisdn_available = false;
|
||||
for (const flow of response.available_flows) {
|
||||
msisdn_available |= flow.stages.indexOf('m.login.msisdn') > -1;
|
||||
}
|
||||
if (!msisdn_available) {
|
||||
msg = "This server does not support authentication with a phone number";
|
||||
}
|
||||
}
|
||||
this.setState({
|
||||
busy: false,
|
||||
doingUIAuth: false,
|
||||
errorText: response.message || response.toString(),
|
||||
errorText: msg,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -261,6 +272,9 @@ module.exports = React.createClass({
|
|||
case "RegistrationForm.ERR_EMAIL_INVALID":
|
||||
errMsg = "This doesn't look like a valid email address";
|
||||
break;
|
||||
case "RegistrationForm.ERR_PHONE_NUMBER_INVALID":
|
||||
errMsg = "This doesn't look like a valid phone number";
|
||||
break;
|
||||
case "RegistrationForm.ERR_USERNAME_INVALID":
|
||||
errMsg = "User names may only contain letters, numbers, dots, hyphens and underscores.";
|
||||
break;
|
||||
|
@ -295,15 +309,20 @@ module.exports = React.createClass({
|
|||
guestAccessToken = null;
|
||||
}
|
||||
|
||||
// Only send the bind params if we're sending username / pw params
|
||||
// (Since we need to send no params at all to use the ones saved in the
|
||||
// session).
|
||||
const bindThreepids = this.state.formVals.password ? {
|
||||
email: true,
|
||||
msisdn: true,
|
||||
} : {};
|
||||
|
||||
return this._matrixClient.register(
|
||||
this.state.formVals.username,
|
||||
this.state.formVals.password,
|
||||
undefined, // session id: included in the auth dict already
|
||||
auth,
|
||||
// Only send the bind_email param if we're sending username / pw params
|
||||
// (Since we need to send no params at all to use the ones saved in the
|
||||
// session).
|
||||
Boolean(this.state.formVals.username) || undefined,
|
||||
bindThreepids,
|
||||
guestAccessToken,
|
||||
);
|
||||
},
|
||||
|
@ -354,6 +373,8 @@ module.exports = React.createClass({
|
|||
<RegistrationForm
|
||||
defaultUsername={this.state.formVals.username}
|
||||
defaultEmail={this.state.formVals.email}
|
||||
defaultPhoneCountry={this.state.formVals.phoneCountry}
|
||||
defaultPhoneNumber={this.state.formVals.phoneNumber}
|
||||
defaultPassword={this.state.formVals.password}
|
||||
teamsConfig={this.state.teamsConfig}
|
||||
guestUsername={guestUsername}
|
||||
|
|
|
@ -27,8 +27,8 @@ import React from 'react';
|
|||
export default function AccessibleButton(props) {
|
||||
const {element, onClick, children, ...restProps} = props;
|
||||
restProps.onClick = onClick;
|
||||
restProps.onKeyDown = function(e) {
|
||||
if (e.keyCode == 13 || e.keyCode == 32) return onClick();
|
||||
restProps.onKeyUp = function(e) {
|
||||
if (e.keyCode == 13 || e.keyCode == 32) return onClick(e);
|
||||
};
|
||||
restProps.tabIndex = restProps.tabIndex || "0";
|
||||
restProps.role = "button";
|
||||
|
|
324
src/components/views/elements/Dropdown.js
Normal file
324
src/components/views/elements/Dropdown.js
Normal file
|
@ -0,0 +1,324 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations 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 AccessibleButton from './AccessibleButton';
|
||||
|
||||
class MenuOption extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this._onMouseEnter = this._onMouseEnter.bind(this);
|
||||
this._onClick = this._onClick.bind(this);
|
||||
}
|
||||
|
||||
_onMouseEnter() {
|
||||
this.props.onMouseEnter(this.props.dropdownKey);
|
||||
}
|
||||
|
||||
_onClick(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.onClick(this.props.dropdownKey);
|
||||
}
|
||||
|
||||
render() {
|
||||
const optClasses = classnames({
|
||||
mx_Dropdown_option: true,
|
||||
mx_Dropdown_option_highlight: this.props.highlighted,
|
||||
});
|
||||
|
||||
return <div className={optClasses}
|
||||
onClick={this._onClick} onKeyPress={this._onKeyPress}
|
||||
onMouseEnter={this._onMouseEnter}
|
||||
>
|
||||
{this.props.children}
|
||||
</div>
|
||||
}
|
||||
};
|
||||
|
||||
MenuOption.propTypes = {
|
||||
children: React.PropTypes.oneOfType([
|
||||
React.PropTypes.arrayOf(React.PropTypes.node),
|
||||
React.PropTypes.node
|
||||
]),
|
||||
highlighted: React.PropTypes.bool,
|
||||
dropdownKey: React.PropTypes.string,
|
||||
onClick: React.PropTypes.func.isRequired,
|
||||
onMouseEnter: React.PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
/*
|
||||
* Reusable dropdown select control, akin to react-select,
|
||||
* but somewhat simpler as react-select is 79KB of minified
|
||||
* javascript.
|
||||
*
|
||||
* TODO: Port NetworkDropdown to use this.
|
||||
*/
|
||||
export default class Dropdown extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.dropdownRootElement = null;
|
||||
this.ignoreEvent = null;
|
||||
|
||||
this._onInputClick = this._onInputClick.bind(this);
|
||||
this._onRootClick = this._onRootClick.bind(this);
|
||||
this._onDocumentClick = this._onDocumentClick.bind(this);
|
||||
this._onMenuOptionClick = this._onMenuOptionClick.bind(this);
|
||||
this._onInputKeyPress = this._onInputKeyPress.bind(this);
|
||||
this._onInputKeyUp = this._onInputKeyUp.bind(this);
|
||||
this._onInputChange = this._onInputChange.bind(this);
|
||||
this._collectRoot = this._collectRoot.bind(this);
|
||||
this._collectInputTextBox = this._collectInputTextBox.bind(this);
|
||||
this._setHighlightedOption = this._setHighlightedOption.bind(this);
|
||||
|
||||
this.inputTextBox = null;
|
||||
|
||||
this._reindexChildren(this.props.children);
|
||||
|
||||
const firstChild = React.Children.toArray(props.children)[0];
|
||||
|
||||
this.state = {
|
||||
// True if the menu is dropped-down
|
||||
expanded: false,
|
||||
// The key of the highlighted option
|
||||
// (the option that would become selected if you pressed enter)
|
||||
highlightedOption: firstChild ? firstChild.key : null,
|
||||
// the current search query
|
||||
searchQuery: '',
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
// Listen for all clicks on the document so we can close the
|
||||
// menu when the user clicks somewhere else
|
||||
document.addEventListener('click', this._onDocumentClick, false);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('click', this._onDocumentClick, false);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
this._reindexChildren(nextProps.children);
|
||||
const firstChild = React.Children.toArray(nextProps.children)[0];
|
||||
this.setState({
|
||||
highlightedOption: firstChild ? firstChild.key : null,
|
||||
});
|
||||
}
|
||||
|
||||
_reindexChildren(children) {
|
||||
this.childrenByKey = {};
|
||||
React.Children.forEach(children, (child) => {
|
||||
this.childrenByKey[child.key] = child;
|
||||
});
|
||||
}
|
||||
|
||||
_onDocumentClick(ev) {
|
||||
// Close the dropdown if the user clicks anywhere that isn't
|
||||
// within our root element
|
||||
if (ev !== this.ignoreEvent) {
|
||||
this.setState({
|
||||
expanded: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_onRootClick(ev) {
|
||||
// This captures any clicks that happen within our elements,
|
||||
// such that we can then ignore them when they're seen by the
|
||||
// click listener on the document handler, ie. not close the
|
||||
// dropdown immediately after opening it.
|
||||
// NB. We can't just stopPropagation() because then the event
|
||||
// doesn't reach the React onClick().
|
||||
this.ignoreEvent = ev;
|
||||
}
|
||||
|
||||
_onInputClick(ev) {
|
||||
this.setState({
|
||||
expanded: !this.state.expanded,
|
||||
});
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
_onMenuOptionClick(dropdownKey) {
|
||||
this.setState({
|
||||
expanded: false,
|
||||
});
|
||||
this.props.onOptionChange(dropdownKey);
|
||||
}
|
||||
|
||||
_onInputKeyPress(e) {
|
||||
// This needs to be on the keypress event because otherwise
|
||||
// it can't cancel the form submission
|
||||
if (e.key == 'Enter') {
|
||||
this.setState({
|
||||
expanded: false,
|
||||
});
|
||||
this.props.onOptionChange(this.state.highlightedOption);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
_onInputKeyUp(e) {
|
||||
// These keys don't generate keypress events and so needs to
|
||||
// be on keyup
|
||||
if (e.key == 'Escape') {
|
||||
this.setState({
|
||||
expanded: false,
|
||||
});
|
||||
} else if (e.key == 'ArrowDown') {
|
||||
this.setState({
|
||||
highlightedOption: this._nextOption(this.state.highlightedOption),
|
||||
});
|
||||
} else if (e.key == 'ArrowUp') {
|
||||
this.setState({
|
||||
highlightedOption: this._prevOption(this.state.highlightedOption),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_onInputChange(e) {
|
||||
this.setState({
|
||||
searchQuery: e.target.value,
|
||||
});
|
||||
if (this.props.onSearchChange) {
|
||||
this.props.onSearchChange(e.target.value);
|
||||
}
|
||||
}
|
||||
|
||||
_collectRoot(e) {
|
||||
if (this.dropdownRootElement) {
|
||||
this.dropdownRootElement.removeEventListener(
|
||||
'click', this._onRootClick, false,
|
||||
);
|
||||
}
|
||||
if (e) {
|
||||
e.addEventListener('click', this._onRootClick, false);
|
||||
}
|
||||
this.dropdownRootElement = e;
|
||||
}
|
||||
|
||||
_collectInputTextBox(e) {
|
||||
this.inputTextBox = e;
|
||||
if (e) e.focus();
|
||||
}
|
||||
|
||||
_setHighlightedOption(optionKey) {
|
||||
this.setState({
|
||||
highlightedOption: optionKey,
|
||||
});
|
||||
}
|
||||
|
||||
_nextOption(optionKey) {
|
||||
const keys = Object.keys(this.childrenByKey);
|
||||
const index = keys.indexOf(optionKey);
|
||||
return keys[(index + 1) % keys.length];
|
||||
}
|
||||
|
||||
_prevOption(optionKey) {
|
||||
const keys = Object.keys(this.childrenByKey);
|
||||
const index = keys.indexOf(optionKey);
|
||||
return keys[(index - 1) % keys.length];
|
||||
}
|
||||
|
||||
_getMenuOptions() {
|
||||
const options = React.Children.map(this.props.children, (child) => {
|
||||
return (
|
||||
<MenuOption key={child.key} dropdownKey={child.key}
|
||||
highlighted={this.state.highlightedOption == child.key}
|
||||
onMouseEnter={this._setHighlightedOption}
|
||||
onClick={this._onMenuOptionClick}
|
||||
>
|
||||
{child}
|
||||
</MenuOption>
|
||||
);
|
||||
});
|
||||
|
||||
if (!this.state.searchQuery) {
|
||||
options.push(
|
||||
<div key="_searchprompt" className="mx_Dropdown_searchPrompt">
|
||||
Type to search...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
render() {
|
||||
let currentValue;
|
||||
|
||||
const menuStyle = {};
|
||||
if (this.props.menuWidth) menuStyle.width = this.props.menuWidth;
|
||||
|
||||
let menu;
|
||||
if (this.state.expanded) {
|
||||
currentValue = <input type="text" className="mx_Dropdown_option"
|
||||
ref={this._collectInputTextBox} onKeyPress={this._onInputKeyPress}
|
||||
onKeyUp={this._onInputKeyUp}
|
||||
onChange={this._onInputChange}
|
||||
value={this.state.searchQuery}
|
||||
/>;
|
||||
menu = <div className="mx_Dropdown_menu" style={menuStyle}>
|
||||
{this._getMenuOptions()}
|
||||
</div>;
|
||||
} else {
|
||||
const selectedChild = this.props.getShortOption ?
|
||||
this.props.getShortOption(this.props.value) :
|
||||
this.childrenByKey[this.props.value];
|
||||
currentValue = <div className="mx_Dropdown_option">
|
||||
{selectedChild}
|
||||
</div>
|
||||
}
|
||||
|
||||
const dropdownClasses = {
|
||||
mx_Dropdown: true,
|
||||
};
|
||||
if (this.props.className) {
|
||||
dropdownClasses[this.props.className] = true;
|
||||
}
|
||||
|
||||
// Note the menu sits inside the AccessibleButton div so it's anchored
|
||||
// to the input, but overflows below it. The root contains both.
|
||||
return <div className={classnames(dropdownClasses)} ref={this._collectRoot}>
|
||||
<AccessibleButton className="mx_Dropdown_input" onClick={this._onInputClick}>
|
||||
{currentValue}
|
||||
<span className="mx_Dropdown_arrow"></span>
|
||||
{menu}
|
||||
</AccessibleButton>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
Dropdown.propTypes = {
|
||||
// The width that the dropdown should be. If specified,
|
||||
// the dropped-down part of the menu will be set to this
|
||||
// width.
|
||||
menuWidth: React.PropTypes.number,
|
||||
// Called when the selected option changes
|
||||
onOptionChange: React.PropTypes.func.isRequired,
|
||||
// Called when the value of the search field changes
|
||||
onSearchChange: React.PropTypes.func,
|
||||
// Function that, given the key of an option, returns
|
||||
// a node representing that option to be displayed in the
|
||||
// box itself as the currently-selected option (ie. as
|
||||
// opposed to in the actual dropped-down part). If
|
||||
// unspecified, the appropriate child element is used as
|
||||
// in the dropped-down menu.
|
||||
getShortOption: React.PropTypes.func,
|
||||
value: React.PropTypes.string,
|
||||
}
|
123
src/components/views/login/CountryDropdown.js
Normal file
123
src/components/views/login/CountryDropdown.js
Normal file
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations 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 sdk from '../../../index';
|
||||
|
||||
import { COUNTRIES } from '../../../phonenumber';
|
||||
import { charactersToImageNode } from '../../../HtmlUtils';
|
||||
|
||||
const COUNTRIES_BY_ISO2 = new Object(null);
|
||||
for (const c of COUNTRIES) {
|
||||
COUNTRIES_BY_ISO2[c.iso2] = c;
|
||||
}
|
||||
|
||||
function countryMatchesSearchQuery(query, country) {
|
||||
if (country.name.toUpperCase().indexOf(query.toUpperCase()) == 0) return true;
|
||||
if (country.iso2 == query.toUpperCase()) return true;
|
||||
if (country.prefix == query) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const MAX_DISPLAYED_ROWS = 2;
|
||||
|
||||
export default class CountryDropdown extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this._onSearchChange = this._onSearchChange.bind(this);
|
||||
|
||||
this.state = {
|
||||
searchQuery: '',
|
||||
}
|
||||
|
||||
if (!props.value) {
|
||||
// If no value is given, we start with the first
|
||||
// country selected, but our parent component
|
||||
// doesn't know this, therefore we do this.
|
||||
this.props.onOptionChange(COUNTRIES[0].iso2);
|
||||
}
|
||||
}
|
||||
|
||||
_onSearchChange(search) {
|
||||
this.setState({
|
||||
searchQuery: search,
|
||||
});
|
||||
}
|
||||
|
||||
_flagImgForIso2(iso2) {
|
||||
// Unicode Regional Indicator Symbol letter 'A'
|
||||
const RIS_A = 0x1F1E6;
|
||||
const ASCII_A = 65;
|
||||
return charactersToImageNode(iso2,
|
||||
RIS_A + (iso2.charCodeAt(0) - ASCII_A),
|
||||
RIS_A + (iso2.charCodeAt(1) - ASCII_A),
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const Dropdown = sdk.getComponent('elements.Dropdown');
|
||||
|
||||
let displayedCountries;
|
||||
if (this.state.searchQuery) {
|
||||
displayedCountries = COUNTRIES.filter(
|
||||
countryMatchesSearchQuery.bind(this, this.state.searchQuery),
|
||||
);
|
||||
if (
|
||||
this.state.searchQuery.length == 2 &&
|
||||
COUNTRIES_BY_ISO2[this.state.searchQuery.toUpperCase()]
|
||||
) {
|
||||
// exact ISO2 country name match: make the first result the matches ISO2
|
||||
const matched = COUNTRIES_BY_ISO2[this.state.searchQuery.toUpperCase()];
|
||||
displayedCountries = displayedCountries.filter((c) => {
|
||||
return c.iso2 != matched.iso2;
|
||||
});
|
||||
displayedCountries.unshift(matched);
|
||||
}
|
||||
} else {
|
||||
displayedCountries = COUNTRIES;
|
||||
}
|
||||
|
||||
if (displayedCountries.length > MAX_DISPLAYED_ROWS) {
|
||||
displayedCountries = displayedCountries.slice(0, MAX_DISPLAYED_ROWS);
|
||||
}
|
||||
|
||||
const options = displayedCountries.map((country) => {
|
||||
return <div key={country.iso2}>
|
||||
{this._flagImgForIso2(country.iso2)}
|
||||
{country.name}
|
||||
</div>;
|
||||
});
|
||||
|
||||
// default value here too, otherwise we need to handle null / undefined
|
||||
// values between mounting and the initial value propgating
|
||||
const value = this.props.value || COUNTRIES[0].iso2;
|
||||
|
||||
return <Dropdown className={this.props.className}
|
||||
onOptionChange={this.props.onOptionChange} onSearchChange={this._onSearchChange}
|
||||
menuWidth={298} getShortOption={this._flagImgForIso2}
|
||||
value={value}
|
||||
>
|
||||
{options}
|
||||
</Dropdown>
|
||||
}
|
||||
}
|
||||
|
||||
CountryDropdown.propTypes = {
|
||||
className: React.PropTypes.string,
|
||||
onOptionChange: React.PropTypes.func.isRequired,
|
||||
value: React.PropTypes.string,
|
||||
};
|
|
@ -16,6 +16,8 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import url from 'url';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import sdk from '../../../index';
|
||||
|
||||
|
@ -255,6 +257,137 @@ export const EmailIdentityAuthEntry = React.createClass({
|
|||
},
|
||||
});
|
||||
|
||||
export const MsisdnAuthEntry = React.createClass({
|
||||
displayName: 'MsisdnAuthEntry',
|
||||
|
||||
statics: {
|
||||
LOGIN_TYPE: "m.login.msisdn",
|
||||
},
|
||||
|
||||
propTypes: {
|
||||
inputs: React.PropTypes.shape({
|
||||
phoneCountry: React.PropTypes.string,
|
||||
phoneNumber: React.PropTypes.string,
|
||||
}),
|
||||
fail: React.PropTypes.func,
|
||||
clientSecret: React.PropTypes.func,
|
||||
submitAuthDict: React.PropTypes.func.isRequired,
|
||||
matrixClient: React.PropTypes.object,
|
||||
submitAuthDict: React.PropTypes.func,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
token: '',
|
||||
requestingToken: false,
|
||||
};
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
this._sid = null;
|
||||
this._msisdn = null;
|
||||
this._tokenBox = null;
|
||||
|
||||
this.setState({requestingToken: true});
|
||||
this._requestMsisdnToken().catch((e) => {
|
||||
this.props.fail(e);
|
||||
}).finally(() => {
|
||||
this.setState({requestingToken: false});
|
||||
}).done();
|
||||
},
|
||||
|
||||
/*
|
||||
* Requests a verification token by SMS.
|
||||
*/
|
||||
_requestMsisdnToken: function() {
|
||||
return this.props.matrixClient.requestRegisterMsisdnToken(
|
||||
this.props.inputs.phoneCountry,
|
||||
this.props.inputs.phoneNumber,
|
||||
this.props.clientSecret,
|
||||
1, // TODO: Multiple send attempts?
|
||||
).then((result) => {
|
||||
this._sid = result.sid;
|
||||
this._msisdn = result.msisdn;
|
||||
});
|
||||
},
|
||||
|
||||
_onTokenChange: function(e) {
|
||||
this.setState({
|
||||
token: e.target.value,
|
||||
});
|
||||
},
|
||||
|
||||
_onFormSubmit: function(e) {
|
||||
e.preventDefault();
|
||||
if (this.state.token == '') return;
|
||||
|
||||
this.setState({
|
||||
errorText: null,
|
||||
});
|
||||
|
||||
this.props.matrixClient.submitMsisdnToken(
|
||||
this._sid, this.props.clientSecret, this.state.token
|
||||
).then((result) => {
|
||||
if (result.success) {
|
||||
const idServerParsedUrl = url.parse(
|
||||
this.props.matrixClient.getIdentityServerUrl(),
|
||||
)
|
||||
this.props.submitAuthDict({
|
||||
type: MsisdnAuthEntry.LOGIN_TYPE,
|
||||
threepid_creds: {
|
||||
sid: this._sid,
|
||||
client_secret: this.props.clientSecret,
|
||||
id_server: idServerParsedUrl.host,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
errorText: "Token incorrect",
|
||||
});
|
||||
}
|
||||
}).catch((e) => {
|
||||
this.props.fail(e);
|
||||
console.log("Failed to submit msisdn token");
|
||||
}).done();
|
||||
},
|
||||
|
||||
render: function() {
|
||||
if (this.state.requestingToken) {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
return <Loader />;
|
||||
} else {
|
||||
const enableSubmit = Boolean(this.state.token);
|
||||
const submitClasses = classnames({
|
||||
mx_InteractiveAuthEntryComponents_msisdnSubmit: true,
|
||||
mx_UserSettings_button: true, // XXX button classes
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<p>A text message has been sent to +<i>{this._msisdn}</i></p>
|
||||
<p>Please enter the code it contains:</p>
|
||||
<div className="mx_InteractiveAuthEntryComponents_msisdnWrapper">
|
||||
<form onSubmit={this._onFormSubmit}>
|
||||
<input type="text"
|
||||
className="mx_InteractiveAuthEntryComponents_msisdnEntry"
|
||||
value={this.state.token}
|
||||
onChange={this._onTokenChange}
|
||||
/>
|
||||
<br />
|
||||
<input type="submit" value="Submit"
|
||||
className={submitClasses}
|
||||
disabled={!enableSubmit}
|
||||
/>
|
||||
</form>
|
||||
<div className="error">
|
||||
{this.state.errorText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const FallbackAuthEntry = React.createClass({
|
||||
displayName: 'FallbackAuthEntry',
|
||||
|
||||
|
@ -313,6 +446,7 @@ const AuthEntryComponents = [
|
|||
PasswordAuthEntry,
|
||||
RecaptchaAuthEntry,
|
||||
EmailIdentityAuthEntry,
|
||||
MsisdnAuthEntry,
|
||||
];
|
||||
|
||||
export function getEntryComponentForLoginType(loginType) {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -17,6 +18,7 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import classNames from 'classnames';
|
||||
import sdk from '../../../index';
|
||||
import {field_input_incorrect} from '../../../UiEffects';
|
||||
|
||||
|
||||
|
@ -28,8 +30,12 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
|
|||
onSubmit: React.PropTypes.func.isRequired, // fn(username, password)
|
||||
onForgotPasswordClick: React.PropTypes.func, // fn()
|
||||
initialUsername: React.PropTypes.string,
|
||||
initialPhoneCountry: React.PropTypes.string,
|
||||
initialPhoneNumber: React.PropTypes.string,
|
||||
initialPassword: React.PropTypes.string,
|
||||
onUsernameChanged: React.PropTypes.func,
|
||||
onPhoneCountryChanged: React.PropTypes.func,
|
||||
onPhoneNumberChanged: React.PropTypes.func,
|
||||
onPasswordChanged: React.PropTypes.func,
|
||||
loginIncorrect: React.PropTypes.bool,
|
||||
},
|
||||
|
@ -38,7 +44,11 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
|
|||
return {
|
||||
onUsernameChanged: function() {},
|
||||
onPasswordChanged: function() {},
|
||||
onPhoneCountryChanged: function() {},
|
||||
onPhoneNumberChanged: function() {},
|
||||
initialUsername: "",
|
||||
initialPhoneCountry: "",
|
||||
initialPhoneNumber: "",
|
||||
initialPassword: "",
|
||||
loginIncorrect: false,
|
||||
};
|
||||
|
@ -48,6 +58,8 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
|
|||
return {
|
||||
username: this.props.initialUsername,
|
||||
password: this.props.initialPassword,
|
||||
phoneCountry: this.props.initialPhoneCountry,
|
||||
phoneNumber: this.props.initialPhoneNumber,
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -63,7 +75,12 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
|
|||
|
||||
onSubmitForm: function(ev) {
|
||||
ev.preventDefault();
|
||||
this.props.onSubmit(this.state.username, this.state.password);
|
||||
this.props.onSubmit(
|
||||
this.state.username,
|
||||
this.state.phoneCountry,
|
||||
this.state.phoneNumber,
|
||||
this.state.password,
|
||||
);
|
||||
},
|
||||
|
||||
onUsernameChanged: function(ev) {
|
||||
|
@ -71,6 +88,16 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
|
|||
this.props.onUsernameChanged(ev.target.value);
|
||||
},
|
||||
|
||||
onPhoneCountryChanged: function(country) {
|
||||
this.setState({phoneCountry: country});
|
||||
this.props.onPhoneCountryChanged(country);
|
||||
},
|
||||
|
||||
onPhoneNumberChanged: function(ev) {
|
||||
this.setState({phoneNumber: ev.target.value});
|
||||
this.props.onPhoneNumberChanged(ev.target.value);
|
||||
},
|
||||
|
||||
onPasswordChanged: function(ev) {
|
||||
this.setState({password: ev.target.value});
|
||||
this.props.onPasswordChanged(ev.target.value);
|
||||
|
@ -92,13 +119,28 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
|
|||
error: this.props.loginIncorrect,
|
||||
});
|
||||
|
||||
const CountryDropdown = sdk.getComponent('views.login.CountryDropdown');
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={this.onSubmitForm}>
|
||||
<input className="mx_Login_field" type="text"
|
||||
<input className="mx_Login_field mx_Login_username" type="text"
|
||||
name="username" // make it a little easier for browser's remember-password
|
||||
value={this.state.username} onChange={this.onUsernameChanged}
|
||||
placeholder="Email or user name" autoFocus />
|
||||
or
|
||||
<div className="mx_Login_phoneSection">
|
||||
<CountryDropdown ref="phone_country" onOptionChange={this.onPhoneCountryChanged}
|
||||
className="mx_Login_phoneCountry"
|
||||
value={this.state.phoneCountry}
|
||||
/>
|
||||
<input type="text" ref="phoneNumber"
|
||||
onChange={this.onPhoneNumberChanged}
|
||||
placeholder="Mobile phone number"
|
||||
className="mx_Login_phoneNumberField mx_Login_field"
|
||||
value={this.state.phoneNumber}
|
||||
name="phoneNumber"
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
<input className={pwFieldClass} ref={(e) => {this._passwordField = e;}} type="password"
|
||||
name="password"
|
||||
|
|
|
@ -19,9 +19,12 @@ import React from 'react';
|
|||
import { field_input_incorrect } from '../../../UiEffects';
|
||||
import sdk from '../../../index';
|
||||
import Email from '../../../email';
|
||||
import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
|
||||
import Modal from '../../../Modal';
|
||||
|
||||
const FIELD_EMAIL = 'field_email';
|
||||
const FIELD_PHONE_COUNTRY = 'field_phone_country';
|
||||
const FIELD_PHONE_NUMBER = 'field_phone_number';
|
||||
const FIELD_USERNAME = 'field_username';
|
||||
const FIELD_PASSWORD = 'field_password';
|
||||
const FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
|
||||
|
@ -35,6 +38,8 @@ module.exports = React.createClass({
|
|||
propTypes: {
|
||||
// Values pre-filled in the input boxes when the component loads
|
||||
defaultEmail: React.PropTypes.string,
|
||||
defaultPhoneCountry: React.PropTypes.string,
|
||||
defaultPhoneNumber: React.PropTypes.string,
|
||||
defaultUsername: React.PropTypes.string,
|
||||
defaultPassword: React.PropTypes.string,
|
||||
teamsConfig: React.PropTypes.shape({
|
||||
|
@ -71,6 +76,8 @@ module.exports = React.createClass({
|
|||
return {
|
||||
fieldValid: {},
|
||||
selectedTeam: null,
|
||||
// The ISO2 country code selected in the phone number entry
|
||||
phoneCountry: this.props.defaultPhoneCountry,
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -85,6 +92,7 @@ module.exports = React.createClass({
|
|||
this.validateField(FIELD_PASSWORD_CONFIRM);
|
||||
this.validateField(FIELD_PASSWORD);
|
||||
this.validateField(FIELD_USERNAME);
|
||||
this.validateField(FIELD_PHONE_NUMBER);
|
||||
this.validateField(FIELD_EMAIL);
|
||||
|
||||
var self = this;
|
||||
|
@ -118,6 +126,8 @@ module.exports = React.createClass({
|
|||
username: this.refs.username.value.trim() || this.props.guestUsername,
|
||||
password: this.refs.password.value.trim(),
|
||||
email: email,
|
||||
phoneCountry: this.state.phoneCountry,
|
||||
phoneNumber: this.refs.phoneNumber.value.trim(),
|
||||
});
|
||||
|
||||
if (promise) {
|
||||
|
@ -174,6 +184,11 @@ module.exports = React.createClass({
|
|||
const emailValid = email === '' || Email.looksValid(email);
|
||||
this.markFieldValid(field_id, emailValid, "RegistrationForm.ERR_EMAIL_INVALID");
|
||||
break;
|
||||
case FIELD_PHONE_NUMBER:
|
||||
const phoneNumber = this.refs.phoneNumber.value;
|
||||
const phoneNumberValid = phoneNumber === '' || phoneNumberLooksValid(phoneNumber);
|
||||
this.markFieldValid(field_id, phoneNumberValid, "RegistrationForm.ERR_PHONE_NUMBER_INVALID");
|
||||
break;
|
||||
case FIELD_USERNAME:
|
||||
// XXX: SPEC-1
|
||||
var username = this.refs.username.value.trim() || this.props.guestUsername;
|
||||
|
@ -233,6 +248,8 @@ module.exports = React.createClass({
|
|||
switch (field_id) {
|
||||
case FIELD_EMAIL:
|
||||
return this.refs.email;
|
||||
case FIELD_PHONE_NUMBER:
|
||||
return this.refs.phoneNumber;
|
||||
case FIELD_USERNAME:
|
||||
return this.refs.username;
|
||||
case FIELD_PASSWORD:
|
||||
|
@ -251,6 +268,12 @@ module.exports = React.createClass({
|
|||
return cls;
|
||||
},
|
||||
|
||||
_onPhoneCountryChange(newVal) {
|
||||
this.setState({
|
||||
phoneCountry: newVal,
|
||||
});
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var self = this;
|
||||
|
||||
|
@ -286,6 +309,25 @@ module.exports = React.createClass({
|
|||
}
|
||||
}
|
||||
|
||||
const CountryDropdown = sdk.getComponent('views.login.CountryDropdown');
|
||||
const phoneSection = (
|
||||
<div className="mx_Login_phoneSection">
|
||||
<CountryDropdown ref="phone_country" onOptionChange={this._onPhoneCountryChange}
|
||||
className="mx_Login_phoneCountry"
|
||||
value={this.state.phoneCountry}
|
||||
/>
|
||||
<input type="text" ref="phoneNumber"
|
||||
placeholder="Mobile phone number (optional)"
|
||||
defaultValue={this.props.defaultPhoneNumber}
|
||||
className={this._classForField(
|
||||
FIELD_PHONE_NUMBER, 'mx_Login_phoneNumberField', 'mx_Login_field'
|
||||
)}
|
||||
onBlur={function() {self.validateField(FIELD_PHONE_NUMBER);}}
|
||||
value={self.state.phoneNumber}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const registerButton = (
|
||||
<input className="mx_Login_submit" type="submit" value="Register" />
|
||||
);
|
||||
|
@ -300,6 +342,7 @@ module.exports = React.createClass({
|
|||
<form onSubmit={this.onSubmit}>
|
||||
{emailSection}
|
||||
{belowEmailSection}
|
||||
{phoneSection}
|
||||
<input type="text" ref="username"
|
||||
placeholder={ placeholderUserName } defaultValue={this.props.defaultUsername}
|
||||
className={this._classForField(FIELD_USERNAME, 'mx_Login_field')}
|
||||
|
|
1273
src/phonenumber.js
Normal file
1273
src/phonenumber.js
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue