diff --git a/app/actions/navigation/index.js b/app/actions/navigation/index.js
index 8f55f3713..93f6c633a 100644
--- a/app/actions/navigation/index.js
+++ b/app/actions/navigation/index.js
@@ -29,6 +29,15 @@ export function goToLogin() {
};
}
+export function goToMfa() {
+ return async (dispatch, getState) => {
+ dispatch({
+ type: NavigationTypes.NAVIGATION_PUSH,
+ route: Routes.Mfa
+ }, getState);
+ };
+}
+
export function goToLoadTeam() {
return async (dispatch, getState) => {
dispatch({
diff --git a/app/components/error_text.js b/app/components/error_text.js
index 539b5d4c9..c8fe7c91f 100644
--- a/app/components/error_text.js
+++ b/app/components/error_text.js
@@ -3,6 +3,7 @@
import React, {Component, PropTypes} from 'react';
import {Text} from 'react-native';
+import FormattedText from 'app/components/formatted_text';
import {GlobalStyles} from 'app/styles';
@@ -18,6 +19,18 @@ export default class ErrorText extends Component {
return null;
}
+ if (this.props.error.hasOwnProperty('id')) {
+ const {error} = this.props;
+ return (
+
+ );
+ }
+
return (
{this.props.error.message || this.props.error}
diff --git a/app/components/formatted_text.js b/app/components/formatted_text.js
index 5bf601fff..32781dfdb 100644
--- a/app/components/formatted_text.js
+++ b/app/components/formatted_text.js
@@ -11,10 +11,14 @@ class FormattedText extends Component {
static propTypes = {
intl: intlShape.isRequired,
id: PropTypes.string.isRequired,
- defaultMessage: PropTypes.string.isRequired,
+ defaultMessage: PropTypes.string,
values: PropTypes.object
};
+ static defaultProps = {
+ defaultMessage: ''
+ };
+
render() {
const {
id,
diff --git a/app/navigation/router.js b/app/navigation/router.js
index bace732c9..7c5c2d48e 100644
--- a/app/navigation/router.js
+++ b/app/navigation/router.js
@@ -63,9 +63,11 @@ class Router extends React.Component {
});
return (
-
+
+
+ {renderedScenes}
+
{title}
- {renderedScenes}
);
};
diff --git a/app/navigation/routes.js b/app/navigation/routes.js
index d55d717c6..2ea0d45da 100644
--- a/app/navigation/routes.js
+++ b/app/navigation/routes.js
@@ -28,6 +28,11 @@ export const Routes = {
title: {id: 'mobile.routes.login', defaultMessage: 'Login'},
transition: RouteTransitions.Horizontal
},
+ Mfa: {
+ key: 'Mfa',
+ title: {id: 'mobile.routes.mfa', defaultMessage: 'Multi-factor Authentication'},
+ transition: RouteTransitions.Horizontal
+ },
RightMenuDrawer: {
key: 'RightMenuDrawer'
},
diff --git a/app/reducers/views/login.js b/app/reducers/views/login.js
index 2287fb0d0..7078c75b0 100644
--- a/app/reducers/views/login.js
+++ b/app/reducers/views/login.js
@@ -8,7 +8,7 @@ import {ViewTypes} from 'app/constants';
function loginId(state = '', action) {
switch (action.type) {
case ViewTypes.LOGIN_ID_CHANGED:
- return action.loginId;
+ return action.loginId.trim();
case UsersTypes.LOGOUT_SUCCESS:
return '';
default:
diff --git a/app/scenes/index.js b/app/scenes/index.js
index 472ecd989..c18d19676 100644
--- a/app/scenes/index.js
+++ b/app/scenes/index.js
@@ -6,6 +6,7 @@ import ChannelDrawer from './channel_drawer';
import ChannelInfo from './channel_info';
import LoadTeam from './load_team';
import Login from './login/login_container.js';
+import Mfa from './mfa';
import RightMenuDrawer from './right_menu_drawer';
import Root from './root/root_container.js';
import Search from './search/search_container.js';
@@ -18,6 +19,7 @@ const scenes = {
ChannelInfo,
LoadTeam,
Login,
+ Mfa,
RightMenuDrawer,
Root,
Search,
diff --git a/app/scenes/login/login.js b/app/scenes/login/login.js
index 91abd37fb..8c9384f4a 100644
--- a/app/scenes/login/login.js
+++ b/app/scenes/login/login.js
@@ -18,16 +18,35 @@ import {RequestStatus} from 'service/constants';
class Login extends Component {
static propTypes = {
intl: intlShape.isRequired,
+ actions: React.PropTypes.shape({
+ handleLoginIdChanged: React.PropTypes.func.isRequired,
+ handlePasswordChanged: React.PropTypes.func.isRequired,
+ handleSuccessfulLogin: React.PropTypes.func.isRequired,
+ checkMfa: React.PropTypes.func.isRequired,
+ login: React.PropTypes.func.isRequired,
+ getClientConfig: React.PropTypes.func.isRequired,
+ getLicenseConfig: React.PropTypes.func.isRequired,
+ goToMfa: React.PropTypes.func.isRequired,
+ goToLoadTeam: React.PropTypes.func.isRequired
+ }).isRequired,
config: PropTypes.object.isRequired,
license: PropTypes.object.isRequired,
- actions: PropTypes.object.isRequired,
loginId: PropTypes.string.isRequired,
password: PropTypes.string.isRequired,
+ checkMfaRequest: PropTypes.object.isRequired,
loginRequest: PropTypes.object.isRequired,
configRequest: PropTypes.object.isRequired,
licenseRequest: PropTypes.object.isRequired
};
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ error: null
+ };
+ }
+
componentWillMount() {
this.props.actions.getClientConfig();
this.props.actions.getLicenseConfig();
@@ -39,11 +58,58 @@ class Login extends Component {
}
}
- signIn() {
+ preSignIn = () => {
+ this.setState({error: null});
+ if (!this.props.loginId) {
+ // it's slightly weird to be constructing the message ID, but it's a bit nicer than triply nested if statements
+ let msgId = 'login.no';
+ if (this.props.config.EnableSignInWithEmail === 'true') {
+ msgId += 'Email';
+ }
+ if (this.props.config.EnableSignInWithUsername === 'true') {
+ msgId += 'Username';
+ }
+ if (this.props.license.IsLicensed === 'true' && this.props.config.EnableLdap === 'true') {
+ msgId += 'LdapUsername';
+ }
+
+ this.setState({
+ error: {
+ id: msgId,
+ defaultMessage: '',
+ values: {
+ ldapUsername: this.props.config.LdapLoginFieldName ||
+ this.props.intl.formatMessage({id: 'login.ldapUsernameLower', defaultMessage: 'AD/LDAP username'})
+ }
+ }
+ });
+ return;
+ }
+
+ if (!this.props.password) {
+ this.setState({
+ error: {id: 'login.noPassword', defaultMessage: 'Please enter your password'}
+ });
+ return;
+ }
+ if (this.props.config.EnableMultifactorAuthentication === 'true') {
+ this.props.actions.checkMfa(this.props.loginId).then((result) => {
+ if (result.mfa_required === 'true') {
+ this.props.actions.goToMfa();
+ } else {
+ this.signIn();
+ }
+ });
+ } else {
+ this.signIn();
+ }
+ };
+
+ signIn = () => {
if (this.props.loginRequest.status !== RequestStatus.STARTED) {
this.props.actions.login(this.props.loginId, this.props.password);
}
- }
+ };
createLoginPlaceholder() {
const {formatMessage} = this.props.intl;
@@ -99,6 +165,7 @@ class Login extends Component {
id='web.root.signup_info'
defaultMessage='All team communication in one place, searchable and accessible anywhere'
/>
+
-
-
);
}
diff --git a/app/scenes/login/login_container.js b/app/scenes/login/login_container.js
index bc348dd02..bc95c8747 100644
--- a/app/scenes/login/login_container.js
+++ b/app/scenes/login/login_container.js
@@ -6,19 +6,23 @@ import {connect} from 'react-redux';
import {getClientConfig, getLicenseConfig} from 'service/actions/general';
import LoginActions from 'app/actions/views/login';
-import {goToLoadTeam} from 'app/actions/navigation';
-import {login} from 'service/actions/users';
+import {goToMfa, goToLoadTeam} from 'app/actions/navigation';
+import {checkMfa, login} from 'service/actions/users';
import Login from './login.js';
function mapStateToProps(state) {
+ const {config, license} = state.entities.general;
+ const {config: configRequest, license: licenseRequest} = state.requests.general;
+ const {checkMfa: checkMfaRequest, login: loginRequest} = state.requests.users;
return {
...state.views.login,
- loginRequest: state.requests.users.login,
- configRequest: state.requests.general.config,
- licenseRequest: state.requests.general.license,
- config: state.entities.general.config,
- license: state.entities.general.license
+ checkMfaRequest,
+ loginRequest,
+ configRequest,
+ licenseRequest,
+ config,
+ license
};
}
@@ -26,9 +30,11 @@ function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
...LoginActions,
+ checkMfa,
login,
getClientConfig,
getLicenseConfig,
+ goToMfa,
goToLoadTeam
}, dispatch)
};
diff --git a/app/scenes/mfa/index.js b/app/scenes/mfa/index.js
new file mode 100644
index 000000000..8e1580bf7
--- /dev/null
+++ b/app/scenes/mfa/index.js
@@ -0,0 +1,6 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import MfaContainer from './mfa_container';
+
+export default MfaContainer;
diff --git a/app/scenes/mfa/mfa.js b/app/scenes/mfa/mfa.js
new file mode 100644
index 000000000..880dac555
--- /dev/null
+++ b/app/scenes/mfa/mfa.js
@@ -0,0 +1,107 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React, {Component} from 'react';
+import {Image, KeyboardAvoidingView} from 'react-native';
+
+import Button from 'react-native-button';
+import ErrorText from 'app/components/error_text';
+import FormattedText from 'app/components/formatted_text';
+import TextInputWithLocalizedPlaceholder from 'app/components/text_input_with_localized_placeholder';
+import {GlobalStyles} from 'app/styles';
+
+import logo from 'assets/images/logo.png';
+
+import RequestStatus from 'service/constants/request_status';
+
+export default class Mfa extends Component {
+ static propTypes = {
+ actions: React.PropTypes.shape({
+ goBack: React.PropTypes.func.isRequired,
+ login: React.PropTypes.func.isRequired
+ }).isRequired,
+ loginId: React.PropTypes.string.isRequired,
+ password: React.PropTypes.string.isRequired,
+ loginRequest: React.PropTypes.object.isRequired
+ };
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ token: '',
+ error: null
+ };
+ }
+
+ componentWillReceiveProps(nextProps) {
+ // In case the login is successful the previous scene (login) will take care of the transition
+ if (this.props.loginRequest.status === RequestStatus.STARTED &&
+ nextProps.loginRequest.status === RequestStatus.FAILURE) {
+ this.props.actions.goBack();
+ }
+ }
+
+ handleInput = (token) => {
+ this.setState({
+ token,
+ error: null
+ });
+ };
+
+ submit = () => {
+ if (!this.state.token) {
+ this.setState({
+ error: {
+ id: 'login_mfa.tokenReq',
+ defaultMessage: 'Please enter an MFA token'
+ }
+ });
+ return;
+ }
+
+ this.props.actions.login(this.props.loginId, this.props.password, this.state.token);
+ };
+
+ render() {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/app/scenes/mfa/mfa_container.js b/app/scenes/mfa/mfa_container.js
new file mode 100644
index 000000000..2f7217e95
--- /dev/null
+++ b/app/scenes/mfa/mfa_container.js
@@ -0,0 +1,31 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import {bindActionCreators} from 'redux';
+import {connect} from 'react-redux';
+
+import {goBack} from 'app/actions/navigation';
+import {login} from 'service/actions/users';
+
+import Mfa from './mfa';
+
+function mapStateToProps(state) {
+ const {login: loginRequest} = state.requests.users;
+ const {loginId, password} = state.views.login;
+ return {
+ loginId,
+ password,
+ loginRequest
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators({
+ goBack,
+ login
+ }, dispatch)
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(Mfa);
diff --git a/app/scenes/select_server/select_server.js b/app/scenes/select_server/select_server.js
index 80a429293..c6662de30 100644
--- a/app/scenes/select_server/select_server.js
+++ b/app/scenes/select_server/select_server.js
@@ -73,7 +73,6 @@ class SelectServer extends Component {
/>
-
);
}
diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json
index e1e72238b..41c722199 100644
--- a/assets/base/i18n/en.json
+++ b/assets/base/i18n/en.json
@@ -1491,6 +1491,7 @@
"mobile.routes.channels": "Channels",
"mobile.routes.enterServerUrl": "Enter Server URL",
"mobile.routes.login": "Login",
+ "mobile.routes.mfa": "Multi-factor Authentication",
"mobile.routes.postsList": "Posts List",
"mobile.routes.selectTeam": "Select Team",
"mobile.routes.channelInfo": "Info",
diff --git a/service/actions/users.js b/service/actions/users.js
index 04e1035fe..29c6111a0 100644
--- a/service/actions/users.js
+++ b/service/actions/users.js
@@ -7,6 +7,20 @@ import {Constants, PreferencesTypes, UsersTypes, TeamsTypes} from 'service/const
import {fetchTeams} from 'service/actions/teams';
import {bindClientFunc, forceLogoutIfNecessary} from './helpers';
+export function checkMfa(loginId) {
+ return async (dispatch, getState) => {
+ dispatch({type: UsersTypes.CHECK_MFA_REQUEST}, getState);
+ try {
+ const mfa = await Client.checkMfa(loginId);
+ dispatch({type: UsersTypes.CHECK_MFA_SUCCESS}, getState);
+ return mfa;
+ } catch (error) {
+ dispatch({type: UsersTypes.CHECK_MFA_FAILURE, error}, getState);
+ return null;
+ }
+ };
+}
+
export function login(loginId, password, mfaToken = '') {
return async (dispatch, getState) => {
dispatch({type: UsersTypes.LOGIN_REQUEST}, getState);
@@ -61,8 +75,6 @@ export function login(loginId, password, mfaToken = '') {
export function loadMe() {
return async (dispatch, getState) => {
- dispatch({type: UsersTypes.LOGIN_REQUEST}, getState);
-
let user;
dispatch({type: UsersTypes.LOGIN_REQUEST}, getState);
try {
@@ -288,6 +300,7 @@ export function getAudits(userId) {
}
export default {
+ checkMfa,
login,
logout,
getProfiles,
diff --git a/service/client/client.js b/service/client/client.js
index a1ab2415e..cecfc4c96 100644
--- a/service/client/client.js
+++ b/service/client/client.js
@@ -194,6 +194,13 @@ export default class Client {
);
};
+ checkMfa = async (loginId) => {
+ return this.doFetch(
+ `${this.getUsersRoute()}/mfa`,
+ {method: 'post', body: JSON.stringify({login_id: loginId})}
+ );
+ };
+
login = async (loginId, password, token = '') => {
const body = {
login_id: loginId,
diff --git a/service/constants/users.js b/service/constants/users.js
index 0bf8cbf0b..dd21bcadb 100644
--- a/service/constants/users.js
+++ b/service/constants/users.js
@@ -44,6 +44,10 @@ const UserTypes = keyMirror({
AUDITS_SUCCESS: null,
AUDITS_FAILURE: null,
+ CHECK_MFA_REQUEST: null,
+ CHECK_MFA_SUCCESS: null,
+ CHECK_MFA_FAILURE: null,
+
RECEIVED_ME: null,
RECEIVED_PROFILES: null,
RECEIVED_PROFILES_IN_TEAM: null,
diff --git a/service/reducers/requests/users.js b/service/reducers/requests/users.js
index 8b844984d..7502bba2d 100644
--- a/service/reducers/requests/users.js
+++ b/service/reducers/requests/users.js
@@ -6,6 +6,25 @@ import {UsersTypes, RequestStatus} from 'service/constants';
import {combineReducers} from 'redux';
+function checkMfa(state = initialRequestState(), action) {
+ switch (action.type) {
+ case UsersTypes.CHECK_MFA_REQUEST:
+ return {...state, status: RequestStatus.STARTED};
+
+ case UsersTypes.CHECK_MFA_SUCCESS:
+ return {...state, status: RequestStatus.SUCCESS, error: null};
+
+ case UsersTypes.CHECK_MFA_FAILURE:
+ return {...state, status: RequestStatus.FAILURE, error: action.error};
+
+ case UsersTypes.LOGOUT_SUCCESS:
+ return {...state, status: RequestStatus.NOT_STARTED, error: null};
+
+ default:
+ return state;
+ }
+}
+
function login(state = initialRequestState(), action) {
switch (action.type) {
case UsersTypes.LOGIN_REQUEST:
@@ -125,6 +144,7 @@ function getAudits(state = initialRequestState(), action) {
}
export default combineReducers({
+ checkMfa,
login,
logout,
getProfiles,