Reauthenticate from stored token when app is reloaded (#137)

* Reauthenticate from stored token when app is reloaded

* Address feedback
This commit is contained in:
enahum 2016-12-15 15:17:26 -03:00 committed by Harrison Healey
parent bd3bd45c36
commit d2f2678880
25 changed files with 356 additions and 42 deletions

View file

@ -0,0 +1,52 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {AsyncStorage} from 'react-native';
import {GeneralTypes, UsersTypes} from 'service/constants';
import Client from 'service/client';
export function loadStorage() {
return async (dispatch, getState) => {
try {
const data = JSON.parse(await AsyncStorage.getItem('storage'));
dispatch({type: GeneralTypes.RECEIVED_APP_CREDENTIALS, data}, getState);
} catch (error) {
// Error loading data
dispatch({type: GeneralTypes.REMOVED_APP_CREDENTIALS, error}, getState);
}
};
}
export function saveStorage() {
return async (dispatch, getState) => {
try {
const data = {
token: Client.getToken(),
url: Client.getUrl()
};
await AsyncStorage.setItem('storage', JSON.stringify(data));
dispatch({type: GeneralTypes.RECEIVED_APP_CREDENTIALS, data}, getState);
} catch (error) {
// Error saving data
dispatch({type: GeneralTypes.REMOVED_APP_CREDENTIALS, error}, getState);
}
};
}
export function removeStorage() {
return async (dispatch, getState) => {
try {
await AsyncStorage.removeItem('storage');
} catch (error) {
// Error removing data
}
dispatch({type: UsersTypes.RESET_LOGOUT_STATE}, getState);
};
}
export default {
loadStorage,
saveStorage,
removeStorage
};

View file

@ -1,9 +1,14 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {batchActions} from 'redux-batched-actions';
import {NavigationTypes} from 'app/constants';
import Routes from 'app/navigation/routes';
import Client from 'service/client';
import {forceLogoutIfNecessary} from 'service/actions/helpers';
import {PreferencesTypes, TeamsTypes, UsersTypes} from 'service/constants';
export function goToSelectServer() {
return async (dispatch, getState) => {
dispatch({
@ -13,3 +18,85 @@ export function goToSelectServer() {
}, getState);
};
}
export function setStoreFromLocalData(data) {
return async (dispatch, getState) => {
Client.setToken(data.token);
Client.setUrl(data.url);
let user;
dispatch({type: UsersTypes.LOGIN_REQUEST}, getState);
try {
user = await Client.getMe();
} catch (error) {
forceLogoutIfNecessary(error, dispatch);
dispatch({type: UsersTypes.LOGIN_FAILURE, error}, getState);
return;
}
let preferences;
dispatch({type: PreferencesTypes.MY_PREFERENCES_REQUEST}, getState);
try {
preferences = await Client.getMyPreferences();
} catch (error) {
forceLogoutIfNecessary(error, dispatch);
dispatch({type: PreferencesTypes.MY_PREFERENCES_FAILURE, error}, getState);
return;
}
let teams;
dispatch({type: TeamsTypes.FETCH_TEAMS_REQUEST}, getState);
try {
teams = await Client.getAllTeams();
} catch (error) {
forceLogoutIfNecessary(error, dispatch);
dispatch({type: TeamsTypes.FETCH_TEAMS_FAILURE, error}, getState);
return;
}
// TODO: uncomment and sorround with try/catch when PLT-4167 is merged
// const teamMembers = Client.getMyTeamMembers();
const teamMembers = Object.keys(teams).map((key) => {
return {
team_id: key,
user_id: user.id
};
});
dispatch(batchActions([
{
type: UsersTypes.RECEIVED_ME,
data: user
},
{
type: UsersTypes.LOGIN_SUCCESS
},
{
type: PreferencesTypes.RECEIVED_PREFERENCES,
data: preferences
},
{
type: PreferencesTypes.MY_PREFERENCES_SUCCESS
},
{
type: TeamsTypes.RECEIVED_MY_TEAM_MEMBERS,
data: teamMembers
},
{
type: TeamsTypes.MY_TEAM_MEMBERS_SUCCESS
},
{
type: TeamsTypes.RECEIVED_ALL_TEAMS,
data: teams
},
{
type: TeamsTypes.FETCH_TEAMS_SUCCESS
}
]), getState);
};
}
export default {
goToSelectServer,
setStoreFromLocalData
};

View file

@ -2,9 +2,11 @@
// See License.txt for license information.
import NavigationTypes from './navigation';
import StorageTypes from './storage';
import ViewTypes from './view';
export {
NavigationTypes,
StorageTypes,
ViewTypes
};

15
app/constants/storage.js Normal file
View file

@ -0,0 +1,15 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import keymirror from 'keymirror';
const StorageTypes = keymirror({
SAVE_TO_STORAGE: null,
SAVE_TO_STORAGE_ERROR: null,
LOAD_FROM_STORAGE: null,
LOAD_FROM_STORAGE_ERROR: null,
REMOVE_FROM_STORAGE: null,
REMOVE_FROM_STORAGE_ERROR: null
});
export default StorageTypes;

View file

@ -2,16 +2,39 @@
// See License.txt for license information.
import React from 'react';
import {AppState} from 'react-native';
import {getTranslations} from 'service/i18n';
import {IntlProvider} from 'react-intl';
export default class RootLayout extends React.Component {
static propTypes = {
children: React.PropTypes.node,
locale: React.PropTypes.string.isRequired
locale: React.PropTypes.string.isRequired,
actions: React.PropTypes.shape({
setAppState: React.PropTypes.func
}).isRequired
};
constructor(props) {
super(props);
this.handleAppStateChange = this.handleAppStateChange.bind(this);
this.props.actions.setAppState(AppState.currentState === 'active');
}
componentDidMount() {
AppState.addEventListener('change', this.handleAppStateChange);
}
componentWillUnmount() {
AppState.removeEventListener('change', this.handleAppStateChange);
}
handleAppStateChange(appState) {
this.props.actions.setAppState(appState === 'active');
}
render() {
const locale = this.props.locale;

View file

@ -1,10 +1,11 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import Config from 'config/index';
import Config from 'config';
import {setAppState} from 'service/actions/general';
import RootLayout from './root_layout';
function mapStateToProps(state, ownProps) {
@ -22,4 +23,12 @@ function mapStateToProps(state, ownProps) {
};
}
export default connect(mapStateToProps)(RootLayout);
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
setAppState
}, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(RootLayout);

View file

@ -19,7 +19,7 @@ class Router extends React.Component {
actions: React.PropTypes.shape({
goBack: React.PropTypes.func
}).isRequired
}
};
renderTransition = (transitionProps) => {
let title;
@ -61,7 +61,7 @@ class Router extends React.Component {
{title}
</View>
);
}
};
renderTitle = ({scene}) => {
const title = scene.route.title;
@ -78,20 +78,20 @@ class Router extends React.Component {
/>
</NavigationExperimental.Header.Title>
);
}
};
renderScene = ({scene}) => {
const SceneComponent = getComponentForScene(scene.route.key);
return <SceneComponent {...scene.route.props}/>;
}
};
configureTransition = () => {
return {
duration: 500,
easing: Easing.inOut(Easing.ease)
};
}
};
render = () => {
return (
@ -102,7 +102,7 @@ class Router extends React.Component {
configureTransition={this.configureTransition}
/>
);
}
};
}
function mapStateToProps(state) {

View file

@ -1,10 +1,10 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import views from './views';
import navigation from './navigation';
import views from './views';
export default {
views,
navigation
navigation,
views
};

View file

@ -3,7 +3,7 @@
import {combineReducers} from 'redux';
import Config from 'config';
import Config from 'config/index';
import {UsersTypes} from 'service/constants';
function locale(state = Config.DefaultLocale, action) {

View file

@ -3,7 +3,7 @@
import {combineReducers} from 'redux';
import Config from 'config';
import Config from 'config/index';
import {ViewTypes} from 'app/constants';

View file

@ -14,7 +14,7 @@ import {StatusBar, Text, TouchableHighlight, View} from 'react-native';
export default class Channel extends React.Component {
static propTypes = {
actions: React.PropTypes.object.isRequired,
currentTeam: React.PropTypes.object.isRequired,
currentTeam: React.PropTypes.object,
currentChannel: React.PropTypes.object,
channels: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
theme: React.PropTypes.object.isRequired
@ -36,26 +36,26 @@ export default class Channel extends React.Component {
}
componentWillReceiveProps(nextProps) {
if (this.props.currentTeam.id !== nextProps.currentTeam.id) {
if (this.props.currentTeam && nextProps.currentTeam && this.props.currentTeam.id !== nextProps.currentTeam.id) {
this.props.actions.fetchMyChannelsAndMembers(nextProps.currentTeam.id);
}
}
openLeftSidebar = () => {
this.setState({leftSidebarOpen: true});
}
};
closeLeftSidebar = () => {
this.setState({leftSidebarOpen: false});
}
};
openRightSidebar = () => {
this.setState({rightSidebarOpen: true});
}
};
closeRightSidebar = () => {
this.setState({rightSidebarOpen: false});
}
};
render() {
const {

View file

@ -35,7 +35,7 @@ class Login extends Component {
componentWillReceiveProps(nextProps) {
if (this.props.loginRequest.status === RequestStatus.STARTED && nextProps.loginRequest.status === RequestStatus.SUCCESS) {
this.props.actions.goToSelectTeam();
this.props.actions.saveStorage().then(this.props.actions.goToSelectTeam);
}
}

View file

@ -6,6 +6,7 @@ import {connect} from 'react-redux';
import {getClientConfig, getLicenseConfig} from 'service/actions/general';
import * as LoginActions from 'app/actions/views/login';
import * as StorageActions from 'app/actions/storage';
import {goToSelectTeam} from 'app/actions/navigation';
import {login} from 'service/actions/users';
@ -26,6 +27,7 @@ function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
...LoginActions,
...StorageActions,
login,
getClientConfig,
getLicenseConfig,

View file

@ -2,19 +2,47 @@
// See License.txt for license information.
import React from 'react';
import Loading from 'app/components/loading';
import {RequestStatus} from 'service/constants';
export default class Root extends React.Component {
static propTypes = {
actions: React.PropTypes.object.isRequired
credentials: React.PropTypes.object,
logoutRequest: React.PropTypes.object,
actions: React.PropTypes.shape({
goToSelectServer: React.PropTypes.func,
goToSelectTeam: React.PropTypes.func,
loadStorage: React.PropTypes.func,
removeStorage: React.PropTypes.func,
setStoreFromLocalData: React.PropTypes.func,
resetLogout: React.PropTypes.func
}).isRequired
};
componentDidMount() {
// Any initialization logic for navigation, setting up the client, etc should go here
this.props.actions.goToSelectServer();
if (this.props.logoutRequest.status === RequestStatus.SUCCESS) {
this.props.actions.removeStorage().then(() => {
setTimeout(this.loadStoreAndScene, 1000);
});
} else {
this.loadStoreAndScene();
}
}
loadStoreAndScene = () => {
this.props.actions.loadStorage().then(() => {
if (this.props.credentials.token && this.props.credentials.url) {
this.props.actions.setStoreFromLocalData(this.props.credentials).then(this.props.actions.goToSelectTeam);
} else {
this.props.actions.goToSelectServer();
}
});
};
render() {
return null;
return <Loading/>;
}
}

View file

@ -4,18 +4,30 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {goToSelectServer} from 'app/actions/views/root';
import {loadStorage, removeStorage} from 'app/actions/storage';
import {goToSelectServer, setStoreFromLocalData} from 'app/actions/views/root';
import {goToSelectTeam} from 'app/actions/navigation';
import {resetLogout} from 'service/actions/users';
import Root from './root';
function mapStateToProps(state, ownProps) {
return ownProps;
return {
...ownProps,
credentials: state.entities.general.credentials,
logoutRequest: state.requests.users.logout
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
goToSelectServer
goToSelectServer,
goToSelectTeam,
loadStorage,
removeStorage,
setStoreFromLocalData,
resetLogout
}, dispatch)
};
}

View file

@ -42,3 +42,17 @@ export function logClientError(message, level = 'ERROR') {
level
);
}
export function setAppState(state) {
return async (dispatch, getState) => {
dispatch({type: GeneralTypes.RECEIVED_APP_STATE, data: state}, getState);
};
}
export default {
getPing,
getClientConfig,
getLicenseConfig,
logClientError,
setAppState
};

View file

@ -7,6 +7,7 @@ const HTTP_UNAUTHORIZED = 401;
export async function forceLogoutIfNecessary(err, dispatch) {
if (err.status_code === HTTP_UNAUTHORIZED && err.url.indexOf('/login') === -1) {
dispatch({type: UsersTypes.LOGOUT_REQUEST});
await Client.logout();
dispatch({type: UsersTypes.LOGOUT_SUCCESS});
}

View file

@ -22,10 +22,22 @@ export default class Client {
};
}
getUrl() {
return this.url;
}
setUrl(url) {
this.url = url;
}
getToken() {
return this.token;
}
setToken(token) {
this.token = token;
}
getBaseRoute() {
return `${this.url}${this.urlVersion}`;
}
@ -192,6 +204,13 @@ export default class Client {
if (response.headers.has(HEADER_TOKEN)) {
this.token = response.headers.get(HEADER_TOKEN);
} else {
// weird case where fetch does not parse the header correctly
const parseHeader = response.headers.get(HEADER_CONTENT_TYPE).split('\n');
const filter = parseHeader.filter((h) => h.indexOf('Token:') > -1);
if (filter.length) {
this.token = filter[0].replace('Token: ', '');
}
}
return data;
@ -242,6 +261,13 @@ export default class Client {
);
};
getMe = async () => {
return this.doFetch(
`${this.getUsersRoute()}/me`,
{method: 'get'}
);
};
getProfiles = async (offset, limit) => {
return this.doFetch(
`${this.getUsersRoute()}/${offset}/${limit}`,

View file

@ -4,6 +4,10 @@
import keymirror from 'keymirror';
const GeneralTypes = keymirror({
RECEIVED_APP_STATE: null,
RECEIVED_APP_CREDENTIALS: null,
REMOVED_APP_CREDENTIALS: null,
PING_REQUEST: null,
PING_SUCCESS: null,
PING_FAILURE: null,

View file

@ -54,7 +54,8 @@ const UserTypes = keymirror({
RECEIVED_SESSIONS: null,
RECEIVED_REVOKED_SESSION: null,
RECEIVED_AUDITS: null,
RECEIVED_STATUSES: null
RECEIVED_STATUSES: null,
RESET_LOGOUT_STATE: null
});
export default UserTypes;

View file

@ -26,7 +26,31 @@ function license(state = {}, action) {
}
}
function appState(state = false, action) {
switch (action.type) {
case GeneralTypes.RECEIVED_APP_STATE:
return action.data;
default:
return state;
}
}
function credentials(state = {}, action) {
switch (action.type) {
case GeneralTypes.RECEIVED_APP_CREDENTIALS:
return Object.assign({}, state, action.data);
case UsersTypes.LOGOUT_SUCCESS:
return {};
default:
return state;
}
}
export default combineReducers({
appState,
credentials,
config,
license
});

View file

@ -23,13 +23,18 @@ export function handleRequest(REQUEST, SUCCESS, FAILURE, state, action) {
status: RequestStatus.SUCCESS,
error: null
};
case FAILURE:
case FAILURE: {
let error = action.error;
if (error instanceof Error) {
error = error.toString();
}
return {
...state,
status: RequestStatus.FAILURE,
error: action.error
error
};
}
default:
return state;
}

View file

@ -26,13 +26,22 @@ function login(state = initialRequestState(), action) {
}
function logout(state = initialRequestState(), action) {
return handleRequest(
UsersTypes.LOGOUT_REQUEST,
UsersTypes.LOGOUT_SUCCESS,
UsersTypes.LOGOUT_FAILURE,
state,
action
);
switch (action.type) {
case UsersTypes.LOGOUT_REQUEST:
return {...state, status: RequestStatus.STARTED};
case UsersTypes.LOGOUT_SUCCESS:
return {...state, status: RequestStatus.SUCCESS, error: null};
case UsersTypes.LOGOUT_FAILURE:
return {...state, status: RequestStatus.FAILURE, error: action.error};
case UsersTypes.RESET_LOGOUT_STATE:
return initialRequestState();
default:
return state;
}
}
function getProfiles(state = initialRequestState(), action) {

View file

@ -96,7 +96,7 @@ describe('Actions.Channels', () => {
await getProfiles(0)(store.dispatch, store.getState);
Actions.createDirectChannel(TestHelper.basicUser.id, user.id)(store.dispatch, store.getState);
});
});
}).timeout(3000);
it('updateChannel', (done) => {
TestHelper.initBasic(Client).then(() => {

View file

@ -317,5 +317,5 @@ describe('Actions.Teams', () => {
Actions.addUserToTeam(TestHelper.basicTeam.id, user.id)(store.dispatch, store.getState);
});
});
}).timeout(3000);
});