diff --git a/client/package.json b/client/package.json index d6e54b3b..be1c6fae 100644 --- a/client/package.json +++ b/client/package.json @@ -55,6 +55,7 @@ "app-module-path": "^1.0.3", "classnames": "^2.1.3", "jquery": "^2.1.4", + "localStorage": "^1.0.3", "lodash": "^3.10.0", "messageformat": "^0.2.2", "react": "^15.0.1", @@ -63,7 +64,6 @@ "react-google-recaptcha": "^0.5.2", "react-motion": "^0.3.0", "react-router": "^2.4.0", - "reflux": "^0.4.1", - "sessionstorage": "0.0.1" + "reflux": "^0.4.1" } } diff --git a/client/src/app/index.js b/client/src/app/index.js index 7a01f3da..e4bec24b 100644 --- a/client/src/app/index.js +++ b/client/src/app/index.js @@ -1,6 +1,7 @@ import React from 'react'; import {render} from 'react-dom' import Router from 'react-router'; +import UserStore from 'stores/user-store'; import routes from './Routes'; @@ -13,4 +14,9 @@ if (noFixtures === 'disabled') { require('lib-app/fixtures-loader'); } -render(routes, document.getElementById('app')); +let onSessionInit = function () { + render(routes, document.getElementById('app')); +}; + +UserStore.initSession().then(onSessionInit, onSessionInit); + diff --git a/client/src/app/main/dashboard/dashboard-layout.js b/client/src/app/main/dashboard/dashboard-layout.js index d8bfa4bd..f7e13d1b 100644 --- a/client/src/app/main/dashboard/dashboard-layout.js +++ b/client/src/app/main/dashboard/dashboard-layout.js @@ -14,12 +14,12 @@ const DashboardLayout = React.createClass({ }, render() { - return ( + return (UserStore.isLoggedIn()) ? (
{this.props.children}
- ); + ) : null; } }); diff --git a/client/src/data/fixtures/user-fixtures.js b/client/src/data/fixtures/user-fixtures.js index f98981f2..fe8be0c0 100644 --- a/client/src/data/fixtures/user-fixtures.js +++ b/client/src/data/fixtures/user-fixtures.js @@ -5,19 +5,21 @@ module.exports = [ response: function (data) { let response; - if (data.password === 'invalid') { - response = { - status: 'fail', - message: 'Invalid Credientals' - }; - } else { + if (data.password === 'valid' || (data.rememberToken === 'aa41efe0a1b3eeb9bf303e4561ff8392' && data.userId === 12)) { response = { status: 'success', data: { 'userId': 12, - 'token': 'cc6b4921e6733d6aafe284ec0d7be57e' + 'token': 'cc6b4921e6733d6aafe284ec0d7be57e', + 'rememberToken': (data.remember) ? 'aa41efe0a1b3eeb9bf303e4561ff8392' : null, + 'rememberExpiration': (data.remember) ? 2018 : 0 } }; + } else { + response = { + status: 'fail', + message: 'Invalid Credientals' + }; } return response; @@ -25,12 +27,24 @@ module.exports = [ }, { path: 'user/logout', - time: 1000, + time: 100, response: function () { return { status: 'success', data: {} }; } + }, + { + path: 'user/check-session', + time: 100, + response: function () { + return { + status: 'success', + data: { + sessionActive: true + } + }; + } } ]; diff --git a/client/src/lib-app/__mocks__/api-call-mock.js b/client/src/lib-app/__mocks__/api-call-mock.js index 3fcd2003..f7de46f3 100644 --- a/client/src/lib-app/__mocks__/api-call-mock.js +++ b/client/src/lib-app/__mocks__/api-call-mock.js @@ -1,3 +1,5 @@ export default { - call: stub() + call: stub().returns(new Promise(function (resolve) { + resolve(); + })) }; \ No newline at end of file diff --git a/client/src/lib-app/__mocks__/session-store-mock.js b/client/src/lib-app/__mocks__/session-store-mock.js index 5ceed298..c75b4045 100644 --- a/client/src/lib-app/__mocks__/session-store-mock.js +++ b/client/src/lib-app/__mocks__/session-store-mock.js @@ -1,6 +1,10 @@ export default { createSession: stub(), getSessionData: stub().returns({}), + clearRememberData: stub(), + storeRememberData: stub(), + getRememberData: stub(), + isRememberDataExpired: stub().returns(false), isLoggedIn: stub().returns(false), closeSession: stub() }; \ No newline at end of file diff --git a/client/src/lib-app/__tests__/session-store-test.js b/client/src/lib-app/__tests__/session-store-test.js new file mode 100644 index 00000000..883d38a3 --- /dev/null +++ b/client/src/lib-app/__tests__/session-store-test.js @@ -0,0 +1,110 @@ +const LocalStorageMock = { + getItem: stub(), + setItem: stub(), + removeItem: stub() +}; +const date = { getCurrentDate: stub().returns(20160505)}; +const sessionStore = requireUnit('lib-app/session-store', { + 'localStorage': LocalStorageMock, + 'lib-app/date': date +}); + +describe('sessionStore library', function () { + + beforeEach(function () { + LocalStorageMock.getItem = stub(); + LocalStorageMock.setItem = stub(); + LocalStorageMock.removeItem = stub(); + }); + + it('should get, set and remove items from LocalStorage', function () { + sessionStore.getItem('SOME_KEY'); + expect(LocalStorageMock.getItem).to.have.been.calledWith('SOME_KEY'); + + sessionStore.setItem('SOME_KEY', 'SOME_VALUE'); + expect(LocalStorageMock.setItem).to.have.been.calledWith('SOME_KEY', 'SOME_VALUE'); + + sessionStore.removeItem('SOME_KEY'); + expect(LocalStorageMock.removeItem).to.have.been.calledWith('SOME_KEY'); + }); + + it('should create session correctly', function () { + sessionStore.createSession(14, 'TOKEN'); + + expect(LocalStorageMock.setItem).to.have.been.calledWith('userId', 14); + expect(LocalStorageMock.setItem).to.have.been.calledWith('token', 'TOKEN'); + }); + + it('should return session data', function () { + LocalStorageMock.getItem = function (key) { + if (key === 'userId') return 'USER_ID'; + if (key === 'token') return 'TOKEN'; + }; + let sessionData = sessionStore.getSessionData(); + + expect(sessionData.userId).to.equal('USER_ID'); + expect(sessionData.token).to.equal('TOKEN'); + + LocalStorageMock.getItem = stub().returns('ITEM'); + }); + + it('should inform if it is logged in', function () { + LocalStorageMock.getItem = stub().returns('TOKEN'); + expect(sessionStore.isLoggedIn()).to.equal(true); + + LocalStorageMock.getItem = stub().returns(null); + expect(sessionStore.isLoggedIn()).to.equal(false); + }); + + it('should clear session data if session is closed', function () { + sessionStore.closeSession(); + + expect(LocalStorageMock.removeItem).to.have.been.calledWith('userId'); + expect(LocalStorageMock.removeItem).to.have.been.calledWith('token'); + }); + + it('should store remember data', function () { + sessionStore.storeRememberData({ + token: 'SOME_TOKEN', + userId: 12, + expiration: 20160623 + }); + + expect(LocalStorageMock.setItem).to.have.been.calledWith('rememberData-token', 'SOME_TOKEN'); + expect(LocalStorageMock.setItem).to.have.been.calledWith('rememberData-userId', 12); + expect(LocalStorageMock.setItem).to.have.been.calledWith('rememberData-expiration', 20160623); + }); + + it('should inform if remember expired', function () { + LocalStorageMock.getItem = (key) => (key === 'rememberData-expiration') ? 20160505 : null; + date.getCurrentDate.returns(20160506); + expect(sessionStore.isRememberDataExpired()).to.equal(true); + + LocalStorageMock.getItem = (key) => (key === 'rememberData-expiration') ? 20160505 : null; + date.getCurrentDate.returns(20160503); + expect(sessionStore.isRememberDataExpired()).to.equal(false); + }); + + it('should return all remember data', function () { + LocalStorageMock.getItem = function (key) { + if (key === 'rememberData-userId') return 'USER_ID'; + if (key === 'rememberData-token') return 'TOKEN'; + if (key === 'rememberData-expiration') return 'EXPIRATION'; + }; + let rememberData = sessionStore.getRememberData(); + + expect(rememberData.userId).to.equal('USER_ID'); + expect(rememberData.token).to.equal('TOKEN'); + expect(rememberData.expiration).to.equal('EXPIRATION'); + + LocalStorageMock.getItem = stub().returns('ITEM'); + }); + + it('should clear remember data', function () { + sessionStore.clearRememberData(); + + expect(LocalStorageMock.removeItem).to.have.been.calledWith('rememberData-userId'); + expect(LocalStorageMock.removeItem).to.have.been.calledWith('rememberData-token'); + expect(LocalStorageMock.removeItem).to.have.been.calledWith('rememberData-expiration'); + }); +}); \ No newline at end of file diff --git a/client/src/lib-app/api-call.js b/client/src/lib-app/api-call.js index 2db55bb8..89d26ce1 100644 --- a/client/src/lib-app/api-call.js +++ b/client/src/lib-app/api-call.js @@ -9,13 +9,17 @@ function processData (data) { } module.exports = { - call: function ({path, data, onSuccess, onFail}) { - APIUtils.post(root + path, processData(data)).then(function (result) { - if (result.status === 'success') { - onSuccess && onSuccess(result); - } else { - onFail && onFail(result); - } + call: function ({path, data}) { + return new Promise(function (resolve, reject) { + APIUtils.post(root + path, processData(data)).then(function (result) { + console.log(result); + + if (result.status === 'success') { + resolve(result); + } else if (reject) { + reject(result); + } + }); }); } }; \ No newline at end of file diff --git a/client/src/lib-app/date.js b/client/src/lib-app/date.js new file mode 100644 index 00000000..f14beb2b --- /dev/null +++ b/client/src/lib-app/date.js @@ -0,0 +1,10 @@ +export default { + getCurrentDate() { + let date = new Date(); + let yyyy = date.getFullYear().toString(); + let mm = (date.getMonth()+1).toString(); // getMonth() is zero-based + let dd = date.getDate().toString(); + + return (yyyy + (mm[1]?mm:"0"+mm[0]) + (dd[1]?dd:"0"+dd[0])) * 1; + } +} \ No newline at end of file diff --git a/client/src/lib-app/session-store.js b/client/src/lib-app/session-store.js index f6a05b77..8fbce685 100644 --- a/client/src/lib-app/session-store.js +++ b/client/src/lib-app/session-store.js @@ -1,35 +1,73 @@ -import SessionStorage from 'sessionstorage'; - +import LocalStorage from 'localStorage'; +import date from 'lib-app/date'; class SessionStore { - static initialize() { - if (!SessionStorage.getItem('language')) { - SessionStorage.setItem('language', 'english'); + constructor() { + this.storage = LocalStorage; + + if (!this.getItem('language')) { + this.setItem('language', 'english'); } } - static createSession(userId, token) { - SessionStorage.setItem('userId', userId); - SessionStorage.setItem('token', token); + createSession(userId, token) { + this.setItem('userId', userId); + this.setItem('token', token); } - static getSessionData() { + getSessionData() { return { - userId: SessionStorage.getItem('userId'), - token: SessionStorage.getItem('token') + userId: this.getItem('userId'), + token: this.getItem('token') }; } - static isLoggedIn() { - return !!SessionStorage.getItem('userId'); + isLoggedIn() { + return !!this.getItem('token'); } - static closeSession() { - SessionStorage.removeItem('userId'); - SessionStorage.removeItem('token'); + closeSession() { + this.removeItem('userId'); + this.removeItem('token'); + } + + storeRememberData({token, userId, expiration}) { + this.setItem('rememberData-token', token); + this.setItem('rememberData-userId', userId); + this.setItem('rememberData-expiration', expiration); + } + + isRememberDataExpired() { + let rememberData = this.getRememberData(); + + return rememberData.expiration < date.getCurrentDate(); + } + + getRememberData() { + return { + token: this.getItem('rememberData-token'), + userId: this.getItem('rememberData-userId'), + expiration: this.getItem('rememberData-expiration') + }; + } + + clearRememberData() { + this.removeItem('rememberData-token'); + this.removeItem('rememberData-userId'); + this.removeItem('rememberData-expiration'); + } + + getItem(key) { + return this.storage.getItem(key); + } + + setItem(key, value) { + return this.storage.setItem(key, value); + } + + removeItem(key) { + this.storage.removeItem(key); } } -SessionStore.initialize(); - -export default SessionStore; \ No newline at end of file +export default new SessionStore(); \ No newline at end of file diff --git a/client/src/stores/__tests__/user-store-test.js b/client/src/stores/__tests__/user-store-test.js index 5ccc6790..f6fb225c 100644 --- a/client/src/stores/__tests__/user-store-test.js +++ b/client/src/stores/__tests__/user-store-test.js @@ -16,6 +16,13 @@ const UserStore = requireUnit('stores/user-store', { }); describe('UserStore', function () { + it ('should inform is the user is logged based on SessionStores\' info', function () { + SessionStore.isLoggedIn.returns(true); + expect(UserStore.isLoggedIn()).to.equal(true); + SessionStore.isLoggedIn.returns(false); + expect(UserStore.isLoggedIn()).to.equal(false); + }); + describe('when login user', function () { it('should call /user/login api path', function () { let mockLoginData = {email: 'mock', password: 'mock'}; @@ -23,9 +30,7 @@ describe('UserStore', function () { UserStore.loginUser(mockLoginData); expect(API.call).to.have.been.calledWith({ path: 'user/login', - data: mockLoginData, - onSuccess: sinon.match.func, - onFail: sinon.match.func + data: mockLoginData }); }); @@ -42,10 +47,13 @@ describe('UserStore', function () { spy(UserStore, 'trigger'); CommonActions.logged.reset(); SessionStore.createSession.reset(); - API.call = ({onSuccess}) => {onSuccess(mockSuccessData)}; + API.call.returns({ + then: (resolve) => {resolve(mockSuccessData)} + }); UserStore.loginUser(mockLoginData); + expect(SessionStore.storeRememberData).to.have.not.been.called; expect(SessionStore.createSession).to.have.been.calledWith(12, 'RANDOM_TOKEN'); expect(UserStore.trigger).to.have.been.calledWith('LOGIN_SUCCESS'); expect(CommonActions.logged).to.have.been.called; @@ -54,38 +62,68 @@ describe('UserStore', function () { it('should trigger fail event if login fails', function () { let mockLoginData = {email: 'mock', password: 'mock'}; - let mockSuccessData = { - status: 'success', - data: { - userId: 12, - token: 'RANDOM_TOKEN' - } - }; spy(UserStore, 'trigger'); - API.call = ({onFail}) => {onFail(mockSuccessData)}; + API.call.returns({ + then: (resolve, reject) => {reject()} + }); UserStore.loginUser(mockLoginData); expect(UserStore.trigger).to.have.been.calledWith('LOGIN_FAIL'); UserStore.trigger.restore(); }); + + it('should store remember data if remember is true', function () { + let mockLoginData = {email: 'mock', password: 'mock', remember: true}; + let mockSuccessData = { + status: 'success', + data: { + userId: 12, + token: 'RANDOM_TOKEN', + rememberToken: 'RANDOM_TOKEN_2', + rememberExpiration: 20150822 + } + }; + + spy(UserStore, 'trigger'); + CommonActions.logged.reset(); + SessionStore.createSession.reset(); + API.call.returns({ + then: (resolve) => {resolve(mockSuccessData)} + }); + + UserStore.loginUser(mockLoginData); + + expect(SessionStore.storeRememberData).to.have.been.calledWith({ + token: 'RANDOM_TOKEN_2', + userId: 12, + expiration: 20150822 + }); + expect(SessionStore.createSession).to.have.been.calledWith(12, 'RANDOM_TOKEN'); + expect(UserStore.trigger).to.have.been.calledWith('LOGIN_SUCCESS'); + expect(CommonActions.logged).to.have.been.called; + UserStore.trigger.restore(); + }); }); describe('when login out', function () { it('should call /user/logout api path', function () { - API.call = stub(); + API.call = stub().returns({ + then: (resolve) => {resolve()} + }); UserStore.logoutUser(); expect(API.call).to.have.been.calledWith({ - path: 'user/logout', - onSuccess: sinon.match.func + path: 'user/logout' }); }); it('should delete session, trigger LOGOUT event and inform common action of logout', function () { - API.call = ({onSuccess}) => {onSuccess()}; + API.call = stub().returns({ + then: (resolve) => {resolve()} + }); spy(UserStore, 'trigger'); UserStore.logoutUser(); @@ -96,10 +134,69 @@ describe('UserStore', function () { }) }); - it ('should inform is the user is logged based on SessionStores\' info', function () { - SessionStore.isLoggedIn.returns(true); - expect(UserStore.isLoggedIn()).to.equal(true); - SessionStore.isLoggedIn.returns(false); - expect(UserStore.isLoggedIn()).to.equal(false); - }); + describe('when calling initSession', function () {{ + + it('should check if session is active in the API', function () { + let mockSuccessData = { + status: 'success', + data: { + sessionActive: true + } + }; + API.call = stub().returns({ + then: (resolve) => {resolve(mockSuccessData)} + }); + + UserStore.initSession(); + + expect(API.call).to.have.been.calledWith({ + path: 'user/check-session', + data: {} + }); + }); + + describe('and no session is active', function () { + beforeEach(function() { + let mockSuccessData = { + status: 'success', + data: { + sessionActive: false + } + }; + API.call = stub().returns({ + then: (resolve) => {resolve(mockSuccessData)} + }); + }); + + it('should log out and delete remember data if expired', function () { + SessionStore.isRememberDataExpired.returns(true); + SessionStore.clearRememberData.reset(); + + UserStore.initSession(); + + expect(SessionStore.clearRememberData).to.have.been.called; + expect(SessionStore.closeSession).to.have.been.called; + expect(CommonActions.loggedOut).to.have.been.called; + }); + + it('should login with remember data', function () { + SessionStore.isRememberDataExpired.returns(false); + SessionStore.getRememberData.returns({ + userId: 'REMEMBER_USER_ID', + token: 'REMEMBER_TOKEN', + expiration: 20160721 + }); + + UserStore.initSession(); + + expect(API.call).to.have.been.calledWithMatch({ + path: 'user/login', + data: { + userId: 'REMEMBER_USER_ID', + rememberToken: 'REMEMBER_TOKEN' + } + }); + }); + }); + }}) }); diff --git a/client/src/stores/user-store.js b/client/src/stores/user-store.js index 93118c36..213123cc 100644 --- a/client/src/stores/user-store.js +++ b/client/src/stores/user-store.js @@ -1,6 +1,6 @@ const Reflux = require('reflux'); const API = require('lib-app/api-call'); -const SessionStore = require('lib-app/session-store'); +const sessionStore = require('lib-app/session-store'); const UserActions = require('actions/user-actions'); const CommonActions = require('actions/common-actions'); @@ -14,35 +14,73 @@ const UserStore = Reflux.createStore({ this.listenTo(UserActions.login, this.loginUser); this.listenTo(UserActions.logout, this.logoutUser); }, + + initSession() { + return API.call({ + path: 'user/check-session', + data: {} + }).then(this.tryLoginIfSessionIsInactive); + }, + + tryLoginIfSessionIsInactive(result) { + if (!result.data.sessionActive) { + if (sessionStore.isRememberDataExpired()) { + return this.logoutUser(); + } else { + return this.loginWithRememberData(); + } + } + }, loginUser(loginData) { - API.call({ + let onSuccessLogin = (loginData.remember) ? this.handleLoginSuccessWithRemember : this.handleLoginSuccess; + let onFailedLogin = (loginData.isAutomatic) ? null : this.handleLoginFail; + + return API.call({ path: 'user/login', - data: loginData, - onSuccess: this.handleLoginSuccess, - onFail: this.handleLoginFail - }); + data: loginData + }).then(onSuccessLogin, onFailedLogin); }, logoutUser() { - API.call({ - path: 'user/logout', - onSuccess: function () { - SessionStore.closeSession(); - this.trigger('LOGOUT'); - CommonActions.loggedOut(); - }.bind(this) + return API.call({ + path: 'user/logout' + }).then(() => { + sessionStore.closeSession(); + sessionStore.clearRememberData(); + CommonActions.loggedOut(); + this.trigger('LOGOUT'); }); }, isLoggedIn() { - return SessionStore.isLoggedIn(); + return sessionStore.isLoggedIn(); + }, + + loginWithRememberData() { + let rememberData = sessionStore.getRememberData(); + + return this.loginUser({ + userId: rememberData.userId, + rememberToken: rememberData.token, + isAutomatic: true + }); + }, + + handleLoginSuccessWithRemember(result) { + sessionStore.storeRememberData({ + token: result.data.rememberToken, + userId: result.data.userId, + expiration: result.data.rememberExpiration + }); + + this.handleLoginSuccess(result) }, handleLoginSuccess(result) { - SessionStore.createSession(result.data.userId, result.data.token); - this.trigger('LOGIN_SUCCESS'); + sessionStore.createSession(result.data.userId, result.data.token); CommonActions.logged(); + this.trigger('LOGIN_SUCCESS'); }, handleLoginFail() { diff --git a/server/controllers/user/check-session.php b/server/controllers/user/check-session.php new file mode 100644 index 00000000..88cc8fd3 --- /dev/null +++ b/server/controllers/user/check-session.php @@ -0,0 +1,20 @@ + 'any', + 'requestData' => [] + ]; + } + + public function handler() { + $session = Session::getInstance(); + + Response::respondSuccess([ + 'sessionActive' => $session->sessionExists() + ]); + } +}