├── .babelrc ├── .eslintrc ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── examples └── basic │ ├── .babelrc │ ├── README.md │ ├── actions │ └── user.js │ ├── app.js │ ├── components │ ├── Admin.js │ ├── App.js │ ├── Foo.js │ ├── Home.js │ ├── Login.js │ └── index.js │ ├── constants.js │ ├── index.html │ ├── package.json │ ├── reducers │ ├── index.js │ └── user.js │ └── webpack.config.babel.js ├── package.json ├── src └── index.js └── test ├── UserAuthWrapper-test.js └── init.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react", "stage-0"], 3 | "plugins": [ 4 | ["transform-decorators-legacy"], 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "rackt", 4 | ], 5 | "plugins": [ 6 | "react", 7 | ], 8 | "rules": { 9 | "react/jsx-uses-react": 1, 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | examples/**/bundle.js 3 | node_modules 4 | coverage 5 | npm-debug.log 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | branches: 3 | only: 4 | - master 5 | language: node_js 6 | cache: 7 | directories: 8 | - node_modules 9 | node_js: 10 | - "4" 11 | - "5" 12 | script: 13 | - npm run test:cov 14 | after_success: 15 | - cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [HEAD](https://github.com/mjrussell/redux-auth-wrapper/compare/v0.2.0...master) 2 | Nothing yet 3 | 4 | ## [0.2.0](https://github.com/mjrussell/redux-auth-wrapper/compare/v0.2.0...v0.1.1) 5 | - **Feature:** new replaceAction config arg, removes dependency on a redux-routing impl [#13](https://github.com/mjrussell/redux-auth-wrapper/issues/13) 6 | - **Feature:** New config object syntax for AuthWrapper [#12](https://github.com/mjrussell/redux-auth-wrapper/issues/12) 7 | - **Deprecation:** Deprecates AuthWrapper args syntax [#12](https://github.com/mjrussell/redux-auth-wrapper/issues/12) 8 | - **Feature:** Hoists wrapped component's statics up to the returned component 9 | 10 | ## [0.1.1](https://github.com/mjrussell/redux-auth-wrapper/compare/v0.1.0...v0.1.1) 11 | - Fixes the bad npm publish 12 | 13 | ## [0.1.0](https://github.com/mjrussell/redux-auth-wrapper/compare/fcbf49d0abcae7075daa146c05edff1b735b3a16...v0.1.0) 14 | - First release! 15 | - Adds AuthWrapper with args syntax 16 | - Examples using Redux-Simple-Router (now React-Router-Redux) 17 | - Lots of tests 18 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Matthew Russell 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redux-auth-wrapper 2 | 3 | [![npm version](https://badge.fury.io/js/redux-auth-wrapper.svg)](https://badge.fury.io/js/redux-auth-wrapper) 4 | [![Build Status](https://travis-ci.org/mjrussell/redux-auth-wrapper.svg?branch=master)](https://travis-ci.org/mjrussell/redux-auth-wrapper) 5 | [![Coverage Status](https://coveralls.io/repos/github/mjrussell/redux-auth-wrapper/badge.svg?branch=master)](https://coveralls.io/github/mjrussell/redux-auth-wrapper?branch=master) 6 | 7 | **Decouple your Authentication and Authorization from your components!** 8 | 9 | `npm install --save redux-auth-wrapper` 10 | 11 | ## Motivation 12 | 13 | At first, handling authentication and authorization seems easy in React-Router and Redux. After all, we have a handy [onEnter](https://github.com/rackt/react-router/blob/master/docs/API.md#onenternextstate-replace-callback) method, shouldn't we use it? 14 | 15 | `onEnter` is great, and useful in certain situations. However, here are some common authentication and authorization problems `onEnter` does not solve: 16 | * Decide authentication/authorization from redux store data 17 | * Recheck authentication/authorization if the store updates (but not the current route) 18 | * Recheck authentication/authorization if a child route changes underneath the protected route 19 | 20 | An alternative approach is to use [Higher Order Components](https://medium.com/@dan_abramov/mixins-are-dead-long-live-higher-order-components-94a0d2f9e750#.ao9jjxx89). 21 | > A higher-order component is just a function that takes an existing component and returns another component that wraps it 22 | 23 | Redux-auth-wrapper provides higher-order components for easy to read and apply authentication and authorization constraints for your components. 24 | 25 | ## Tutorial 26 | 27 | Usage with [React-Router-Redux](https://github.com/rackt/react-router-redux) 28 | 29 | ```js 30 | import React from 'react' 31 | import ReactDOM from 'react-dom' 32 | import { createStore, combineReducers, applyMiddleware, compose } from 'redux' 33 | import { Provider } from 'react-redux' 34 | import { Router, Route } from 'react-router' 35 | import { createHistory } from 'history' 36 | import { syncReduxAndRouter, routeReducer, routeActions } from 'react-router-redux' 37 | import { UserAuthWrapper } from 'redux-auth-wrapper' 38 | import userReducer from '/reducers/userReducer' 39 | 40 | const reducer = combineReducers({ 41 | routing: routeReducer, 42 | user: userReducer 43 | }) 44 | const history = createHistory() 45 | const routingMiddleware = syncHistory(history) 46 | 47 | const finalCreateStore = compose( 48 | applyMiddleware(routingMiddleware) 49 | )(createStore); 50 | const store = finalCreateStore(reducer) 51 | routingMiddleware.listenForReplays(store) 52 | 53 | // Redirects to /login by default 54 | const UserIsAuthenticated = UserAuthWrapper({ 55 | authSelector: state => state.user, // how to get the user state 56 | redirectAction: routeActions.replace, // the redux action to dispatch for redirect 57 | wrapperDisplayName: 'UserIsAuthenticated' // a nice name for this auth check 58 | }) 59 | 60 | ReactDOM.render( 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | , 70 | document.getElementById('mount') 71 | ) 72 | ``` 73 | 74 | And your userReducer looks something like: 75 | ```js 76 | const userReducer = (state = {}, { type, payload }) => { 77 | if (type === USER_LOGGED_IN) { 78 | return payload 79 | } 80 | if (type === USER_LOGGED_OUT) { 81 | return {} 82 | } 83 | return state 84 | } 85 | ``` 86 | 87 | When the user navigates to `/foo`, one of the following occurs: 88 | 89 | 1. If The user data is null or an empty object: 90 | 91 | The user is redirected to `/login?redirect=%2foo` 92 | 93 | *Notice the url contains the query parameter `redirect` for sending the user back to after you log them into your app* 94 | 2. Otherwise: 95 | 96 | The `` component is rendered and passed the user data as a property 97 | 98 | Any time the user data changes, the UserAuthWrapper will re-check for authentication. 99 | 100 | ## API 101 | 102 | `UserAuthWrapper(configObject)(DecoratedComponent)` 103 | 104 | #### Config Object Keys 105 | 106 | * `authSelector(state, [ownProps]): authData` \(*Function*): A state selector for the auth data. Just like `mapToStateProps` 107 | * `[failureRedirectPath]` \(*String*): Optional path to redirect the browser to on a failed check. Defaults to `/login` 108 | * `[redirectAction]` \(*Function*): Optional redux action creator for redirecting the user. If not present, will use React-Router's router context to perform the transition. 109 | * `[wrapperDisplayName]` \(*String*): Optional name describing this authentication or authorization check. 110 | It will display in React-devtools. Defaults to `UserAuthWrapper` 111 | * `[predicate(authData): Bool]` \(*Function*): Optional function to be passed the result of the `userAuthSelector` param. 112 | If it evaluates to false the browser will be redirected to `failureRedirectPath`, otherwise `DecoratedComponent` will be rendered. 113 | * `[allowRedirect]` \(*Bool*): Optional bool on whether to pass a `redirect` query parameter to the `failureRedirectPath` 114 | 115 | #### Component Parameter 116 | * `DecoratedComponent` \(*React Component*): The component to be wrapped in the auth check. It will pass down all props given to the returned component as well as the prop `authData` which is the result of the `authSelector` 117 | 118 | ## Authorization & More Advanced Usage 119 | 120 | ```js 121 | /* Allow only users with first name Bob */ 122 | const OnlyBob = UserAuthWrapper({ 123 | authSelector: state => state.user, 124 | redirectAction: routeActions.replace, 125 | failureRedirectPath: '/app', 126 | wrapperDisplayName: 'UserIsOnlyBob', 127 | predicate: user => user.firstName === 'Bob' 128 | }) 129 | 130 | /* Admins only */ 131 | 132 | // Take the regular authentication & redirect to login from before 133 | const UserIsAuthenticated = UserAuthWrapper({ 134 | authSelector: state => state.user, 135 | redirectAction: routeActions.replace, 136 | wrapperDisplayName: 'UserIsAuthenticated' 137 | }) 138 | // Admin Authorization, redirects non-admins to /app and don't send a redirect param 139 | const UserIsAdmin = UserAuthWrapper({ 140 | authSelector: state => state.user, 141 | redirectAction: routeActions.replace, 142 | failureRedirectPath: '/app', 143 | wrapperDisplayName: 'UserIsAdmin', 144 | predicate: user => user.isAdmin, 145 | allowRedirectBack: false 146 | }) 147 | 148 | // Now to secure the component: 149 | 150 | ``` 151 | 152 | The ordering of the nested higher order components is important because `UserIsAuthenticated(UserIsAdmin(Admin))` 153 | means that logged out admins will be redirected to `/login` before checking if they are an admin. 154 | 155 | Otherwise admins would be sent to `/app` if they weren't logged in and then redirected to `/login`, only to find themselves at `/app` 156 | after entering their credentials. 157 | 158 | ### Where to define & apply the wrappers 159 | 160 | One benefit of the beginning example is that it is clear from looking at the Routes where the 161 | authentication & authorization logic is applied. 162 | 163 | An alternative choice might be to use es7 decorators (after turning on the proper presets) in your component: 164 | 165 | ```js 166 | import { UserIsAuthenticated } from '/auth/authWrappers'; 167 | 168 | @UserIsAuthenticated 169 | class MyComponents extends Component { 170 | } 171 | ``` 172 | -------------------------------------------------------------------------------- /examples/basic/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /examples/basic/README.md: -------------------------------------------------------------------------------- 1 | redux-auth-wrapper basic example 2 | ================================= 3 | 4 | This is a basic example that demonstrates wrapping components 5 | with authentication and authorization checks. It shows how to handle 6 | nested checks and demonstrates redirect support. 7 | 8 | This example uses React-Router 2.x and React-Router-Redux 2.x. 9 | 10 | **To run, follow these steps:** 11 | 12 | 1. Install dependencies with `npm install` in this directory (make sure it creates a local node_modules) 13 | 2. By default, it uses the local version from `src` of redux-auth-wrapper, so you need to run `npm install` from there first. 14 | 3. `npm start` 15 | 4. `Browse to http://localhost:8080` 16 | 17 | Login as any user to access the protected page `foo`. 18 | Login with the admin box check to access the admin section. 19 | Logout from any protected page to get redirect back to the login page. 20 | -------------------------------------------------------------------------------- /examples/basic/actions/user.js: -------------------------------------------------------------------------------- 1 | import * as constants from '../constants' 2 | 3 | export function login(data) { 4 | return { 5 | type: constants.USER_LOGGED_IN, 6 | payload: data 7 | } 8 | } 9 | 10 | export function logout() { 11 | return { 12 | type: constants.USER_LOGGED_OUT 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/basic/app.js: -------------------------------------------------------------------------------- 1 | import { createDevTools } from 'redux-devtools' 2 | import LogMonitor from 'redux-devtools-log-monitor' 3 | import DockMonitor from 'redux-devtools-dock-monitor' 4 | 5 | import React from 'react' 6 | import ReactDOM from 'react-dom' 7 | import { createStore, combineReducers, applyMiddleware, compose } from 'redux' 8 | import { Provider } from 'react-redux' 9 | import { Router, Route, IndexRoute, browserHistory } from 'react-router' 10 | import { syncHistory, routeReducer, routeActions } from 'react-router-redux' 11 | import { UserAuthWrapper } from 'redux-auth-wrapper' 12 | 13 | import * as reducers from './reducers' 14 | import { App, Home, Foo, Admin, Login } from './components' 15 | 16 | const history = browserHistory 17 | const routingMiddleware = syncHistory(history) 18 | const reducer = combineReducers(Object.assign({}, reducers, { 19 | routing: routeReducer 20 | })) 21 | 22 | const DevTools = createDevTools( 23 | 25 | 26 | 27 | ) 28 | 29 | const finalCreateStore = compose( 30 | applyMiddleware(routingMiddleware), 31 | DevTools.instrument() 32 | )(createStore) 33 | const store = finalCreateStore(reducer) 34 | routingMiddleware.listenForReplays(store) 35 | 36 | const UserIsAuthenticated = UserAuthWrapper({ 37 | authSelector: state => state.user, 38 | redirectAction: routeActions.replace, 39 | wrapperDisplayName: 'UserIsAuthenticated' 40 | }) 41 | const UserIsAdmin = UserAuthWrapper({ 42 | authSelector: state => state.user, 43 | redirectAction: routeActions.replace, 44 | failureRedirectPath: '/', 45 | wrapperDisplayName: 'UserIsAdmin', 46 | predicate: user => user.isAdmin, 47 | allowRedirectBack: false 48 | }) 49 | 50 | ReactDOM.render( 51 | 52 |
53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 |
63 |
, 64 | document.getElementById('mount') 65 | ) 66 | -------------------------------------------------------------------------------- /examples/basic/components/Admin.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function Admin({ authData }) { 4 | return
{`Welcome admin user: ${authData.name}`}
5 | } 6 | -------------------------------------------------------------------------------- /examples/basic/components/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router' 3 | import { connect } from 'react-redux' 4 | import { logout } from '../actions/user' 5 | 6 | function App({ children, logout }) { 7 | return ( 8 |
9 |
10 | Links: 11 | {' '} 12 | Home 13 | {' '} 14 | {'Foo (Login Required)'} 15 | {' '} 16 | {'Admin'} 17 | {' '} 18 | Login 19 | {' '} 20 | 21 |
22 |
{children}
23 |
24 | ) 25 | } 26 | 27 | export default connect(false, { logout })(App) 28 | -------------------------------------------------------------------------------- /examples/basic/components/Foo.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function Foo({ authData }) { 4 | return ( 5 |
{`I am Foo! Welcome ${authData.name}`}
6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /examples/basic/components/Home.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function Home() { 4 | return ( 5 |
6 |

{"Welcome! Why dont you login and check out Foo? Or log in as an admin and click Admin"}

7 |

{"Or just try to navigate there and you will be redirected"}

8 |

{"Dont forget to try logging out on any page!"}

9 |
10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /examples/basic/components/Login.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react' 2 | import { routeActions } from 'react-router-redux' 3 | import { connect } from 'react-redux' 4 | 5 | import { login } from '../actions/user' 6 | 7 | function select(state) { 8 | const isAuthenticated = state.user.name || false 9 | const redirect = state.routing.location.query.redirect || '/' 10 | return { 11 | isAuthenticated, 12 | redirect 13 | } 14 | } 15 | 16 | class LoginContainer extends Component { 17 | 18 | static propTypes = { 19 | login: PropTypes.func.isRequired, 20 | replace: PropTypes.func.isRequired 21 | }; 22 | 23 | componentWillMount() { 24 | this.ensureNotLoggedIn(this.props) 25 | } 26 | 27 | componentWillReceiveProps(nextProps) { 28 | this.ensureNotLoggedIn(nextProps) 29 | } 30 | 31 | ensureNotLoggedIn = (props) => { 32 | const { isAuthenticated, replace, redirect } = props 33 | 34 | if (isAuthenticated) { 35 | replace(redirect) 36 | } 37 | }; 38 | 39 | onClick = (e) => { 40 | e.preventDefault() 41 | this.props.login({ 42 | name: this.refs.name.value, 43 | isAdmin: this.refs.admin.checked 44 | }) 45 | }; 46 | 47 | render() { 48 | return ( 49 |
50 |

Enter your name

51 | 52 |
53 | {'Admin?'} 54 | 55 |
56 | 57 |
58 | ) 59 | } 60 | 61 | } 62 | 63 | export default connect(select, { login, replace: routeActions.replace })(LoginContainer) 64 | -------------------------------------------------------------------------------- /examples/basic/components/index.js: -------------------------------------------------------------------------------- 1 | import App from './App' 2 | import Home from './Home' 3 | import Foo from './Foo' 4 | import Admin from './Admin' 5 | import Login from './Login' 6 | 7 | module.exports = { App, Home, Foo, Admin, Login } 8 | -------------------------------------------------------------------------------- /examples/basic/constants.js: -------------------------------------------------------------------------------- 1 | export const USER_LOGGED_IN = 'USER_LOGGED_IN' 2 | export const USER_LOGGED_OUT = 'USER_LOGGED_OUT' 3 | -------------------------------------------------------------------------------- /examples/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | redux-auth-wrapper basic example 5 | 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "raw-basic-example", 3 | "version": "0.0.0", 4 | "dependencies": { 5 | "react": "~0.14.2", 6 | "react-dom": "~0.14.2", 7 | "react-redux": "~4.2.0", 8 | "react-router": "2.0.0-rc5", 9 | "react-router-redux": "~3.0.0", 10 | "redux": "~3.2.1" 11 | }, 12 | "devDependencies": { 13 | "babel-core": "^6.1.21", 14 | "babel-loader": "^6.2.0", 15 | "babel-preset-es2015": "^6.1.18", 16 | "babel-preset-react": "^6.1.18", 17 | "babel-preset-stage-0": "^6.3.13", 18 | "eslint": "^1.7.1", 19 | "eslint-config-rackt": "^1.1.1", 20 | "eslint-plugin-react": "~3.16.0", 21 | "html-webpack-plugin": "^2.7.1", 22 | "redux-devtools": "^3.0.0", 23 | "redux-devtools-dock-monitor": "^1.0.1", 24 | "redux-devtools-log-monitor": "^1.0.1", 25 | "webpack": "^1.12.6", 26 | "webpack-dev-server": "^1.14.1" 27 | }, 28 | "scripts": { 29 | "start": "webpack-dev-server --config webpack.config.babel.js --progress" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/basic/reducers/index.js: -------------------------------------------------------------------------------- 1 | import user from './user' 2 | 3 | module.exports = { user } 4 | -------------------------------------------------------------------------------- /examples/basic/reducers/user.js: -------------------------------------------------------------------------------- 1 | import * as constants from '../constants' 2 | 3 | export default function userUpdate(state = {}, { type, payload }) { 4 | if(type === constants.USER_LOGGED_IN) { 5 | return payload 6 | } 7 | else if(type === constants.USER_LOGGED_OUT) { 8 | return {} 9 | } 10 | return state 11 | } 12 | -------------------------------------------------------------------------------- /examples/basic/webpack.config.babel.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import fs from 'fs' 3 | import HtmlWebpackPlugin from 'html-webpack-plugin' 4 | 5 | module.exports = { 6 | entry: './app.js', 7 | output: { 8 | path: path.join(__dirname, 'dist'), 9 | filename: 'bundle.js' 10 | }, 11 | devServer: { 12 | inline: true, 13 | historyApiFallback: true, 14 | stats: { 15 | colors: true, 16 | hash: false, 17 | version: false, 18 | chunks: false, 19 | children: false 20 | } 21 | }, 22 | module: { 23 | loaders: [ { 24 | test: /\.js$/, 25 | loaders: [ 'babel' ], 26 | exclude: /node_modules/, 27 | include: __dirname 28 | } ] 29 | }, 30 | plugins: [ 31 | new HtmlWebpackPlugin({ 32 | template: 'index.html', // Load a custom template 33 | inject: 'body' // Inject all scripts into the body 34 | }) 35 | ] 36 | } 37 | 38 | // This will make the redux-auth-wrapper module resolve to the 39 | // latest src instead of using it from npm. Remove this if running 40 | // outside of the source. 41 | const src = path.join(__dirname, '..', '..', 'src') 42 | if (fs.existsSync(src)) { 43 | // Use the latest src 44 | module.exports.resolve = { alias: { 'redux-auth-wrapper': src } } 45 | module.exports.module.loaders.push({ 46 | test: /\.js$/, 47 | loaders: [ 'babel' ], 48 | include: src 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-auth-wrapper", 3 | "version": "0.2.0", 4 | "description": "A utility library for handling authentication and authorization for redux and react-router", 5 | "main": "lib/index.js", 6 | "files": [ 7 | "*.md", 8 | "LICENSE", 9 | "lib", 10 | "src" 11 | ], 12 | "scripts": { 13 | "build": "mkdir -p lib && babel ./src/index.js --out-file ./lib/index.js", 14 | "lint": "eslint src test", 15 | "prepublish": "rm -rf lib && npm run build", 16 | "test": "mocha --compilers js:babel-core/register --recursive --require test/init.js test/**/*-test.js", 17 | "test:cov": "babel-node $(npm bin)/babel-istanbul cover $(npm bin)/_mocha -- --require test/init.js test/**/*-test.js", 18 | "test:watch": "mocha --compilers js:babel-core/register --recursive --require test/init.js -w test/**/*-test.js" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/mjrussell/redux-auth-wrapper.git" 23 | }, 24 | "authors": [ 25 | "Matthew Russell" 26 | ], 27 | "license": "MIT", 28 | "devDependencies": { 29 | "babel-cli": "^6.0.0", 30 | "babel-core": "^6.0.0", 31 | "babel-eslint": "^5.0.0-beta6", 32 | "babel-istanbul": "^0.6.0", 33 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 34 | "babel-polyfill": "^6.3.14", 35 | "babel-preset-es2015": "^6.3.13", 36 | "babel-preset-react": "^6.3.13", 37 | "babel-preset-stage-0": "^6.3.13", 38 | "chai": "^3.4.1", 39 | "coveralls": "^2.11.6", 40 | "eslint": "^1.7.1", 41 | "eslint-config-rackt": "^1.1.1", 42 | "eslint-plugin-react": "~3.16.0", 43 | "expect": "^1.10.0", 44 | "jsdom": "~8.0.0", 45 | "mocha": "^2.3.4", 46 | "react": "~0.14.3", 47 | "react-addons-test-utils": "^0.14.6", 48 | "react-redux": "^4.0.1", 49 | "react-router": "2.0.0-rc5", 50 | "react-router-redux": "~3.0.0", 51 | "redux": "~3.2.0" 52 | }, 53 | "dependencies": { 54 | "hoist-non-react-statics": "1.0.5", 55 | "lodash.isempty": "4.1.0", 56 | "warning": "2.1.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react' 2 | import { connect } from 'react-redux' 3 | import hoistStatics from 'hoist-non-react-statics' 4 | import isEmpty from 'lodash.isempty' 5 | import warning from 'warning' 6 | 7 | const defaults = { 8 | failureRedirectPath: '/login', 9 | wrapperDisplayName: 'AuthWrapper', 10 | predicate: x => !isEmpty(x), 11 | allowRedirectBack: true 12 | } 13 | 14 | const UserAuthWrapper = (args) => { 15 | const { authSelector, failureRedirectPath, wrapperDisplayName, predicate, allowRedirectBack, redirectAction } = { 16 | ...defaults, 17 | ...args 18 | } 19 | // Wraps the component that needs the auth enforcement 20 | return function wrapComponent(DecoratedComponent) { 21 | const displayName = DecoratedComponent.displayName || DecoratedComponent.name || 'Component' 22 | 23 | const mapDispatchToProps = (dispatch) => { 24 | if (redirectAction !== undefined) { 25 | return { redirect: (args) => dispatch(redirectAction(args)) } 26 | } else { 27 | return {} 28 | } 29 | } 30 | 31 | @connect( 32 | state => { return { authData: authSelector(state) } }, 33 | mapDispatchToProps, 34 | ) 35 | class UserAuthWrapper extends Component { 36 | 37 | static displayName = `${wrapperDisplayName}(${displayName})`; 38 | 39 | static propTypes = { 40 | location: PropTypes.shape({ 41 | pathname: PropTypes.string.isRequired, 42 | search: PropTypes.string.isRequired 43 | }).isRequired, 44 | redirect: PropTypes.func, 45 | authData: PropTypes.object 46 | }; 47 | 48 | static contextTypes = { 49 | // Only used if no redirectAction specified 50 | router: React.PropTypes.object.isRequired 51 | }; 52 | 53 | componentWillMount() { 54 | this.ensureLoggedIn(this.props) 55 | } 56 | 57 | componentWillReceiveProps(nextProps) { 58 | this.ensureLoggedIn(nextProps) 59 | } 60 | 61 | getRedirectFunc = () => this.props.redirect || this.context.router.replace; 62 | 63 | isAuthorized = (authData) => predicate(authData); 64 | 65 | ensureLoggedIn = (props) => { 66 | const { location, authData } = props 67 | let query 68 | if (allowRedirectBack) { 69 | query = { redirect: `${location.pathname}${location.search}` } 70 | } else { 71 | query = {} 72 | } 73 | 74 | if (!this.isAuthorized(authData)) { 75 | this.getRedirectFunc()({ 76 | pathname: failureRedirectPath, 77 | query 78 | }) 79 | } 80 | }; 81 | 82 | render() { 83 | // Allow everything but the replace aciton creator to be passed down 84 | // Includes route props from React-Router and authData 85 | const { redirect, authData, ...otherProps } = this.props 86 | 87 | if (this.isAuthorized(authData)) { 88 | return 89 | } else { 90 | // Don't need to display anything because the user will be redirected 91 | return
92 | } 93 | } 94 | } 95 | 96 | return hoistStatics(UserAuthWrapper, DecoratedComponent) 97 | } 98 | } 99 | 100 | // Support the old 0.1.x with deprecation warning 101 | const DeprecatedWrapper = authSelector => 102 | (failureRedirectPath, wrapperDisplayName, predicate = x => !isEmpty(x), allowRedirectBack = true) => { 103 | warning(false, `Deprecated arg style syntax found for auth wrapper named ${wrapperDisplayName}. Pass a config object instead`) 104 | return UserAuthWrapper({ 105 | ...defaults, 106 | authSelector, 107 | failureRedirectPath, 108 | wrapperDisplayName, 109 | predicate, 110 | allowRedirectBack 111 | }) 112 | } 113 | 114 | const BackwardsCompatWrapper = (arg) => { 115 | if (typeof arg === 'function') { 116 | return DeprecatedWrapper(arg) 117 | } else { 118 | return UserAuthWrapper(arg) 119 | } 120 | } 121 | 122 | module.exports.UserAuthWrapper = BackwardsCompatWrapper 123 | -------------------------------------------------------------------------------- /test/UserAuthWrapper-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node, mocha, jasmine */ 2 | import React, { Component, PropTypes } from 'react' 3 | import { Route, Router } from 'react-router' 4 | import { Provider } from 'react-redux' 5 | import { applyMiddleware, createStore, combineReducers, compose } from 'redux' 6 | import { renderIntoDocument, findRenderedComponentWithType } from 'react-addons-test-utils' 7 | import createMemoryHistory from 'react-router/lib/createMemoryHistory' 8 | import { routeReducer, syncHistory, routeActions } from 'react-router-redux' 9 | 10 | import { UserAuthWrapper } from '../src' 11 | 12 | const USER_LOGGED_IN = 'USER_LOGGED_IN' 13 | const USER_LOGGED_OUT = 'USER_LOGGED_OUT' 14 | 15 | const userReducer = (state = {}, { type, payload }) => { 16 | if (type === USER_LOGGED_IN) { 17 | return payload 18 | } 19 | if (type === USER_LOGGED_OUT) { 20 | return {} 21 | } 22 | return state 23 | } 24 | 25 | const rootReducer = combineReducers({ 26 | routing: routeReducer, 27 | user: userReducer 28 | }) 29 | 30 | const configureStore = (history, initialState) => { 31 | const routerMiddleware = syncHistory(history) 32 | 33 | const createStoreWithMiddleware = compose( 34 | applyMiddleware(routerMiddleware) 35 | )(createStore) 36 | 37 | return createStoreWithMiddleware(rootReducer, initialState) 38 | } 39 | 40 | const userSelector = state => state.user 41 | 42 | const UserIsAuthenticated = UserAuthWrapper({ 43 | authSelector: userSelector, 44 | redirectAction: routeActions.replace, 45 | wrapperDisplayName: 'UserIsAuthenticated' 46 | }) 47 | 48 | const HiddenNoRedir = UserAuthWrapper({ 49 | authSelector: userSelector, 50 | redirectAction: routeActions.replace, 51 | failureRedirectPath: '/', 52 | wrapperDisplayName: 'NoRedir', 53 | predicate: () => false, 54 | allowRedirectBack: false 55 | }) 56 | 57 | const UserIsOnlyTest = UserAuthWrapper({ 58 | authSelector: userSelector, 59 | redirectAction: routeActions.replace, 60 | failureRedirectPath: '/', 61 | wrapperDisplayName: 'UserIsOnlyTest', 62 | predicate: user => user.firstName === 'Test' 63 | }) 64 | 65 | // Intential deprecated version 66 | const UserIsOnlyMcDuderson = UserAuthWrapper(userSelector)('/', 'UserIsOnlyMcDuderson', user => user.lastName === 'McDuderson') 67 | 68 | class App extends Component { 69 | static propTypes = { 70 | children: PropTypes.node 71 | }; 72 | 73 | render() { 74 | return ( 75 |
76 | {this.props.children} 77 |
78 | ) 79 | } 80 | } 81 | 82 | class UnprotectedComponent extends Component { 83 | render() { 84 | return ( 85 |
86 | ) 87 | } 88 | } 89 | 90 | class PropParentComponent extends Component { 91 | static Child = UserIsAuthenticated(UnprotectedComponent); 92 | 93 | render() { 94 | // Need to pass down at least location from router, but can just pass it all down 95 | return 96 | } 97 | } 98 | 99 | class UnprotectedParentComponent extends Component { 100 | static propTypes = { 101 | children: PropTypes.node 102 | }; 103 | 104 | render() { 105 | return ( 106 |
107 | {this.props.children} 108 |
109 | ) 110 | } 111 | } 112 | 113 | const routes = ( 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | ) 126 | 127 | const userLoggedIn = (firstName = 'Test', lastName = 'McDuderson') => { 128 | return { 129 | type: USER_LOGGED_IN, 130 | payload: { 131 | email: 'test@test.com', 132 | firstName, 133 | lastName 134 | } 135 | } 136 | } 137 | 138 | const setupTest = () => { 139 | const history = createMemoryHistory() 140 | const store = configureStore(history) 141 | 142 | const tree = renderIntoDocument( 143 | 144 | 145 | {routes} 146 | 147 | 148 | ) 149 | 150 | return { 151 | history, 152 | store, 153 | tree 154 | } 155 | } 156 | 157 | describe('UserAuthWrapper', () => { 158 | it('redirects unauthenticated', () => { 159 | const { history, store } = setupTest() 160 | 161 | expect(store.getState().routing.location.pathname).to.equal('/') 162 | expect(store.getState().routing.location.search).to.equal('') 163 | history.push('/auth') 164 | expect(store.getState().routing.location.pathname).to.equal('/login') 165 | expect(store.getState().routing.location.search).to.equal('?redirect=%2Fauth') 166 | }) 167 | 168 | it('preserves query params on redirect', () => { 169 | const { history, store } = setupTest() 170 | 171 | expect(store.getState().routing.location.pathname).to.equal('/') 172 | expect(store.getState().routing.location.search).to.equal('') 173 | history.push('/auth?test=foo') 174 | expect(store.getState().routing.location.pathname).to.equal('/login') 175 | expect(store.getState().routing.location.search).to.equal('?redirect=%2Fauth%3Ftest%3Dfoo') 176 | }) 177 | 178 | it('allows authenticated users', () => { 179 | const { history, store } = setupTest() 180 | 181 | store.dispatch(userLoggedIn()) 182 | 183 | history.push('/auth') 184 | expect(store.getState().routing.location.pathname).to.equal('/auth') 185 | }) 186 | 187 | it('redirects on no longer authorized', () => { 188 | const { history, store } = setupTest() 189 | 190 | store.dispatch(userLoggedIn()) 191 | 192 | history.push('/auth') 193 | expect(store.getState().routing.location.pathname).to.equal('/auth') 194 | 195 | store.dispatch({ type: USER_LOGGED_OUT }) 196 | expect(store.getState().routing.location.pathname).to.equal('/login') 197 | }) 198 | 199 | it('allows predicate authorization', () => { 200 | const { history, store } = setupTest() 201 | 202 | store.dispatch(userLoggedIn('NotTest')) 203 | 204 | history.push('/testOnly') 205 | expect(store.getState().routing.location.pathname).to.equal('/') 206 | expect(store.getState().routing.location.search).to.equal('?redirect=%2FtestOnly') 207 | 208 | store.dispatch(userLoggedIn()) 209 | 210 | history.push('/testOnly') 211 | expect(store.getState().routing.location.pathname).to.equal('/testOnly') 212 | expect(store.getState().routing.location.search).to.equal('') 213 | }) 214 | 215 | 216 | it('optionally prevents redirection', () => { 217 | const { history, store } = setupTest() 218 | 219 | store.dispatch(userLoggedIn()) 220 | 221 | history.push('/hidden') 222 | expect(store.getState().routing.location.pathname).to.equal('/') 223 | expect(store.getState().routing.location.search).to.equal('') 224 | }) 225 | 226 | it('can be nested', () => { 227 | const { history, store } = setupTest() 228 | 229 | store.dispatch(userLoggedIn('NotTest')) 230 | 231 | history.push('/testMcDudersonOnly') 232 | expect(store.getState().routing.location.pathname).to.equal('/') 233 | expect(store.getState().routing.location.search).to.equal('?redirect=%2FtestMcDudersonOnly') 234 | 235 | store.dispatch(userLoggedIn('Test', 'NotMcDuderson')) 236 | 237 | history.push('/testMcDudersonOnly') 238 | expect(store.getState().routing.location.pathname).to.equal('/') 239 | expect(store.getState().routing.location.search).to.equal('?redirect=%2FtestMcDudersonOnly') 240 | 241 | store.dispatch(userLoggedIn()) 242 | 243 | history.push('/testMcDudersonOnly') 244 | expect(store.getState().routing.location.pathname).to.equal('/testMcDudersonOnly') 245 | expect(store.getState().routing.location.search).to.equal('') 246 | }) 247 | 248 | it('supports nested routes', () => { 249 | const { history, store } = setupTest() 250 | 251 | history.push('/parent/child') 252 | expect(store.getState().routing.location.pathname).to.equal('/login') 253 | expect(store.getState().routing.location.search).to.equal('?redirect=%2Fparent%2Fchild') 254 | 255 | store.dispatch(userLoggedIn()) 256 | 257 | history.push('/parent/child') 258 | expect(store.getState().routing.location.pathname).to.equal('/parent/child') 259 | expect(store.getState().routing.location.search).to.equal('') 260 | 261 | store.dispatch({ type: USER_LOGGED_OUT }) 262 | expect(store.getState().routing.location.pathname).to.equal('/login') 263 | expect(store.getState().routing.location.search).to.equal('?redirect=%2Fparent%2Fchild') 264 | }) 265 | 266 | it('passes props to authed components', () => { 267 | const { history, store, tree } = setupTest() 268 | 269 | store.dispatch(userLoggedIn()) 270 | 271 | history.push('/prop') 272 | 273 | const comp = findRenderedComponentWithType(tree, UnprotectedComponent) 274 | // Props from React-Router 275 | expect(comp.props.location.pathname).to.equal('/prop') 276 | // Props from auth selector 277 | expect(comp.props.authData).to.deep.equal({ 278 | email: 'test@test.com', 279 | firstName: 'Test', 280 | lastName: 'McDuderson' 281 | }) 282 | // Props from parent 283 | expect(comp.props.testProp).to.equal(true) 284 | }) 285 | 286 | it('hoists statics to the wrapper', () => { 287 | class WithStatic extends Component { 288 | static staticProp = true; 289 | 290 | render() { 291 | return
292 | } 293 | } 294 | 295 | WithStatic.staticFun = () => 'auth' 296 | 297 | const authed = UserIsAuthenticated(WithStatic) 298 | expect(authed.staticProp).to.equal(true) 299 | expect(authed.staticFun).to.be.a('function') 300 | expect(authed.staticFun()).to.equal('auth') 301 | }) 302 | }) 303 | -------------------------------------------------------------------------------- /test/init.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai' 2 | import jsdom from 'jsdom' 3 | 4 | // Use except 5 | global.expect = chai.expect 6 | 7 | // JsDom browser 8 | global.document = jsdom.jsdom('') 9 | global.window = document.defaultView 10 | global.navigator = global.window.navigator 11 | --------------------------------------------------------------------------------