diff --git a/src/actions/auth.js b/src/actions/auth.js new file mode 100644 index 00000000..4adff757 --- /dev/null +++ b/src/actions/auth.js @@ -0,0 +1,38 @@ +import { currentBackend } from '../backends/backend'; + +export const AUTH_REQUEST = 'AUTH_REQUEST'; +export const AUTH_SUCCESS = 'AUTH_SUCCESS'; +export const AUTH_FAILURE = 'AUTH_FAILURE'; + +export function authenticating() { + return { + type: AUTH_REQUEST + }; +} + +export function authenticate(userData) { + return { + type: AUTH_SUCCESS, + payload: userData + }; +} + +export function authError(error) { + return { + type: AUTH_FAILURE, + error: 'Failed to authenticate', + payload: error, + }; +} + +export function loginUser(credentials) { + return (dispatch, getState) => { + const state = getState(); + const backend = currentBackend(state.config); + + dispatch(authenticating()); + backend.authenticate(credentials) + .then((user) => dispatch(authenticate(user))) + .catch((err) => dispatch(authError(err))); + }; +} diff --git a/src/actions/config.js b/src/actions/config.js index 85141672..1bb3a346 100644 --- a/src/actions/config.js +++ b/src/actions/config.js @@ -1,27 +1,25 @@ import yaml from 'js-yaml'; -export const CONFIG = { - REQUEST: 'REQUEST', - SUCCESS: 'SUCCESS', - FAILURE: 'FAILURE' -}; +export const CONFIG_REQUEST = 'CONFIG_REQUEST'; +export const CONFIG_SUCCESS = 'CONFIG_SUCCESS'; +export const CONFIG_FAILURE = 'CONFIG_FAILURE'; export function configLoaded(config) { return { - type: CONFIG.SUCCESS, + type: CONFIG_SUCCESS, payload: config }; } export function configLoading() { return { - type: CONFIG.REQUEST + type: CONFIG_REQUEST }; } export function configFailed(err) { return { - type: CONFIG.FAILURE, + type: CONFIG_FAILURE, error: 'Error loading config', payload: err }; diff --git a/src/backends/backend.js b/src/backends/backend.js new file mode 100644 index 00000000..073b96f6 --- /dev/null +++ b/src/backends/backend.js @@ -0,0 +1,49 @@ +import TestRepoBackend from './test-repo/Implementation'; + +export function resolveBackend(config) { + const name = config.getIn(['backend', 'name']); + if (name == null) { + throw 'No backend defined in configuration'; + } + + switch (name) { + case 'test-repo': + return new Backend(new TestRepoBackend(config)); + default: + throw `Backend not found: ${name}`; + } +} + +class Backend { + constructor(implementation, authStore = null) { + this.implementation = implementation; + this.authStore = authStore; + if (this.implementation == null) { + throw 'Cannot instantiate a Backend with no implementation'; + } + } + + currentUser() { + if (this.user) { return this.user; } + return this.authStore && this.authStore.retrieve(); + } + + authComponent() { + return this.implementation.authComponent(); + } + + authenticate(state) { + return this.implementation.authenticate(state); + } +} + +export const currentBackend = (function() { + let backend = null; + + return (config) => { + if (backend) { return backend; } + if (config.get('backend')) { + return backend = resolveBackend(config); + } + }; +})(); diff --git a/src/backends/test-repo/AuthenticationPage.js b/src/backends/test-repo/AuthenticationPage.js new file mode 100644 index 00000000..6e46d546 --- /dev/null +++ b/src/backends/test-repo/AuthenticationPage.js @@ -0,0 +1,33 @@ +import React from 'react'; + +export default class AuthenticationPage extends React.Component { + static propTypes = { + onLogin: React.PropTypes.func.isRequired + }; + + constructor(props) { + super(props); + this.state = {email: ''}; + this.handleLogin = this.handleLogin.bind(this); + this.handleEmailChange = this.handleEmailChange.bind(this); + } + + handleLogin() { + this.props.onLogin(this.state); + } + + handleEmailChange(e) { + this.setState({email: e.target.value}); + } + + render() { + return
+

+ +

+

+ +

+
; + } +} diff --git a/src/backends/test-repo/implementation.js b/src/backends/test-repo/implementation.js new file mode 100644 index 00000000..f029d873 --- /dev/null +++ b/src/backends/test-repo/implementation.js @@ -0,0 +1,15 @@ +import AuthenticationPage from './AuthenticationPage'; + +export default class TestRepo { + constructor(config) { + this.config = config; + } + + authComponent() { + return AuthenticationPage; + } + + authenticate(state) { + return Promise.resolve({email: state.email}); + } +} diff --git a/src/containers/App.js b/src/containers/App.js index 2f001964..629169e0 100644 --- a/src/containers/App.js +++ b/src/containers/App.js @@ -1,6 +1,8 @@ import React from 'react'; import { connect } from 'react-redux'; import { loadConfig } from '../actions/config'; +import { loginUser } from '../actions/auth'; +import { currentBackend } from '../backends/backend'; class App extends React.Component { constructor(props) { @@ -11,6 +13,10 @@ class App extends React.Component { this.props.dispatch(loadConfig()); } + componentWillReceiveProps(nextProps) { + //this.props.dispatch(loadBackend()); + } + configError(config) { return

Error loading the CMS configuration

@@ -28,8 +34,29 @@ class App extends React.Component {
; } + handleLogin(credentials) { + this.props.dispatch(loginUser(credentials)); + } + + authenticating() { + const { auth } = this.props; + const backend = currentBackend(this.props.config); + + if (backend == null) { + return

Waiting for backend...

; + } + + return
+ {React.createElement(backend.authComponent(), { + onLogin: this.handleLogin.bind(this), + error: auth && auth.get('error'), + isFetching: auth && auth.get('isFetching') + })} +
; + } + render() { - const { config, children } = this.props; + const { user, config, children } = this.props; if (config === null) { return null; @@ -43,6 +70,10 @@ class App extends React.Component { return this.configLoading(); } + if (user == null) { + return this.authenticating(); + } + return (
{children}
); @@ -50,7 +81,11 @@ class App extends React.Component { } function mapStateToProps(state) { + const { auth } = state; + return { + auth: auth, + user: auth && auth.get('user'), config: state.config }; } diff --git a/src/containers/NotFoundPage.js b/src/containers/NotFoundPage.js new file mode 100644 index 00000000..1361270a --- /dev/null +++ b/src/containers/NotFoundPage.js @@ -0,0 +1,5 @@ +import React from 'react'; + +export default () => ( +

Not Found

+); diff --git a/src/reducers/auth.js b/src/reducers/auth.js new file mode 100644 index 00000000..4a4573cf --- /dev/null +++ b/src/reducers/auth.js @@ -0,0 +1,15 @@ +import Immutable from 'immutable'; +import { AUTH_REQUEST, AUTH_SUCCESS, AUTH_FAILURE } from '../actions/auth'; + +export function auth(state = null, action) { + switch (action.type) { + case AUTH_REQUEST: + return Immutable.Map({isFetching: true}); + case AUTH_SUCCESS: + return Immutable.fromJS({user: action.payload}); + case AUTH_FAILURE: + return Immutable.Map({error: action.payload.toString()}); + default: + return state; + } +} diff --git a/src/reducers/collections.js b/src/reducers/collections.js index cee6d770..5451dbbb 100644 --- a/src/reducers/collections.js +++ b/src/reducers/collections.js @@ -1,9 +1,9 @@ import Immutable from 'immutable'; -import { CONFIG } from '../actions/config'; +import { CONFIG_SUCCESS } from '../actions/config'; export function collections(state = null, action) { switch (action.type) { - case CONFIG.SUCCESS: + case CONFIG_SUCCESS: const collections = action.payload && action.payload.collections; return Immutable.OrderedMap().withMutations((map) => { (collections || []).forEach(function(collection) { diff --git a/src/reducers/config.js b/src/reducers/config.js index 740c7db9..c343001a 100644 --- a/src/reducers/config.js +++ b/src/reducers/config.js @@ -1,13 +1,13 @@ import Immutable from 'immutable'; -import { CONFIG } from '../actions/config'; +import { CONFIG_REQUEST, CONFIG_SUCCESS, CONFIG_FAILURE } from '../actions/config'; export function config(state = null, action) { switch (action.type) { - case CONFIG.REQUEST: + case CONFIG_REQUEST: return Immutable.Map({isFetching: true}); - case CONFIG.SUCCESS: + case CONFIG_SUCCESS: return Immutable.fromJS(action.payload); - case CONFIG.FAILURE: + case CONFIG_FAILURE: return Immutable.Map({error: action.payload.toString()}); default: return state; diff --git a/src/routes/routes.js b/src/routes/routes.js index 340c47cf..98eed6d1 100644 --- a/src/routes/routes.js +++ b/src/routes/routes.js @@ -2,11 +2,13 @@ import React from 'react'; import { Router, Route, IndexRoute, browserHistory } from 'react-router'; import App from '../containers/App'; import DashboardPage from '../containers/DashboardPage'; +import NotFoundPage from '../containers/NotFoundPage'; export default () => ( + ); diff --git a/src/store/configureStore.js b/src/store/configureStore.js index ffb31c87..92ea91d3 100644 --- a/src/store/configureStore.js +++ b/src/store/configureStore.js @@ -2,10 +2,12 @@ import { createStore, applyMiddleware, combineReducers, compose } from 'redux'; import thunkMiddleware from 'redux-thunk'; import { browserHistory } from 'react-router'; import { syncHistory, routeReducer } from 'react-router-redux'; +import { auth } from '../reducers/auth'; import { config } from '../reducers/config'; import { collections } from '../reducers/collections'; const reducer = combineReducers({ + auth, config, collections, router: routeReducer diff --git a/test/reducers/auth.spec.js b/test/reducers/auth.spec.js new file mode 100644 index 00000000..1d766f53 --- /dev/null +++ b/test/reducers/auth.spec.js @@ -0,0 +1,40 @@ +import expect from 'expect'; +import Immutable from 'immutable'; +import { authenticating, authenticate, authError } from '../../src/actions/auth'; +import { auth } from '../../src/reducers/auth'; + +describe('auth', () => { + it('should handle an empty state', () => { + expect( + auth(undefined, {}) + ).toEqual( + null + ); + }); + + it('should handle an authentication request', () => { + expect( + auth(undefined, authenticating()) + ).toEqual( + Immutable.Map({isFetching: true}) + ); + }); + + it('should handle authentication', () => { + expect( + auth(undefined, authenticate({email: 'joe@example.com'})) + ).toEqual( + Immutable.fromJS({user: {email: 'joe@example.com'}}) + ); + }); + + it('should handle an authentication error', () => { + expect( + auth(undefined, authError(new Error('Bad credentials'))) + ).toEqual( + Immutable.Map({ + error: 'Error: Bad credentials' + }) + ); + }); +});