├── .gitignore ├── src ├── index.js ├── DefaultNotFound.jsx ├── utils │ ├── omitRouteRenderProperties.js │ ├── omitRouteRenderProperties.spec.js │ ├── checkPermissions.js │ └── checkPermissions.spec.js ├── DefaultLayout.jsx └── AclRouter.jsx ├── .prettierrc ├── .babelrc ├── .eslintrc.json ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | coverage/ 4 | .history/ 5 | yarn-error.log -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import AclRouter from './AclRouter'; 2 | 3 | export default AclRouter; 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "semi": true 6 | } 7 | -------------------------------------------------------------------------------- /src/DefaultNotFound.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const DefaultNotFound = () => <>404; 4 | 5 | export default DefaultNotFound; 6 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "sourceMaps": true, 3 | "presets": ["@babel/env", "@babel/react"], 4 | "plugins": ["@babel/plugin-proposal-class-properties"], 5 | "env": { 6 | "production": { 7 | "ignore": ["**/*.spec.js"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/omitRouteRenderProperties.js: -------------------------------------------------------------------------------- 1 | import omit from 'lodash/omit'; 2 | 3 | const OMIT_ROUTE_RENDER_PROPERTIES = ['render', 'component']; 4 | 5 | const omitRouteRenderProperties = (route) => 6 | omit(route, OMIT_ROUTE_RENDER_PROPERTIES); 7 | 8 | export default omitRouteRenderProperties; 9 | -------------------------------------------------------------------------------- /src/DefaultLayout.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const DefaultLayout = ({ children }) => <>{children}; 5 | 6 | DefaultLayout.propTypes = { 7 | children: PropTypes.element, 8 | }; 9 | 10 | DefaultLayout.defaultProps = { 11 | children: <>, 12 | }; 13 | 14 | export default DefaultLayout; 15 | -------------------------------------------------------------------------------- /src/utils/omitRouteRenderProperties.spec.js: -------------------------------------------------------------------------------- 1 | import omitRouteRenderProperties from './omitRouteRenderProperties'; 2 | 3 | test('empty authorities', () => { 4 | expect( 5 | omitRouteRenderProperties({ 6 | path: '/', 7 | component: '123', 8 | render: () => {}, 9 | }), 10 | ).toEqual({ 11 | path: '/', 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": ["airbnb", "plugin:prettier/recommended"], 4 | "plugins": ["react", "jsx-a11y", "import", "prettier"], 5 | "rules": { 6 | "react/jsx-props-no-spreading": [ 7 | "off", 8 | { 9 | "custom": "ignore", 10 | "html": "ignore" 11 | } 12 | ], 13 | "prettier/prettier": "error" 14 | }, 15 | "globals": { 16 | "document": true, 17 | "window": true 18 | }, 19 | "env": { 20 | "es6": true, 21 | "node": true, 22 | "jest": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/checkPermissions.js: -------------------------------------------------------------------------------- 1 | import isEmpty from 'lodash/isEmpty'; 2 | import isArray from 'lodash/isArray'; 3 | import isString from 'lodash/isString'; 4 | import isFunction from 'lodash/isFunction'; 5 | import indexOf from 'lodash/indexOf'; 6 | 7 | const checkPermissions = (authorities, permissions) => { 8 | if (isEmpty(permissions)) { 9 | return true; 10 | } 11 | 12 | if (isArray(authorities)) { 13 | for (let i = 0; i < authorities.length; i += 1) { 14 | if (indexOf(permissions, authorities[i]) !== -1) { 15 | return true; 16 | } 17 | } 18 | return false; 19 | } 20 | 21 | if (isString(authorities)) { 22 | return indexOf(permissions, authorities) !== -1; 23 | } 24 | 25 | if (isFunction(authorities)) { 26 | return authorities(permissions); 27 | } 28 | 29 | throw new Error('[react-acl-router]: Unsupport type of authorities.'); 30 | }; 31 | 32 | export default checkPermissions; 33 | -------------------------------------------------------------------------------- /src/utils/checkPermissions.spec.js: -------------------------------------------------------------------------------- 1 | import indexOf from 'lodash/indexOf'; 2 | import checkPermissions from './checkPermissions'; 3 | 4 | test('empty authorities', () => { 5 | expect(checkPermissions('', [])).toEqual(true); 6 | }); 7 | 8 | test('array authorities no match', () => { 9 | expect(checkPermissions(['user'], ['admin'])).toEqual(false); 10 | }); 11 | 12 | test('array authorities single match', () => { 13 | expect(checkPermissions(['admin'], ['admin'])).toEqual(true); 14 | }); 15 | 16 | test('array authorities multiple match', () => { 17 | expect(checkPermissions(['admin', 'user'], ['admin', 'user'])).toEqual(true); 18 | }); 19 | 20 | test('string authorities', () => { 21 | expect(checkPermissions('admin', ['admin', 'user'])).toEqual(true); 22 | }); 23 | 24 | test('function authorities', () => { 25 | expect( 26 | checkPermissions((permissions) => indexOf(permissions, 'admin') !== -1, [ 27 | 'admin', 28 | 'user', 29 | ]), 30 | ).toEqual(true); 31 | }); 32 | 33 | test('unsupport type of authorities', () => { 34 | expect(() => checkPermissions(123, ['admin'])).toThrowError( 35 | '[react-acl-router]: Unsupport type of authorities.', 36 | ); 37 | }); 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-acl-router", 3 | "version": "1.0.0", 4 | "description": "Router with Access Control for React Applications.", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "build": "npm run clean && cross-env NODE_ENV=production babel src --out-dir lib", 8 | "test": "jest --coverage", 9 | "lint": "cross-env eslint --ext .js --ext .jsx src", 10 | "clean": "rm -rf lib", 11 | "pre:release": "npm run clean && npm run test && npm run lint" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/AlanWei/react-acl-router.git" 16 | }, 17 | "author": "Alan Wei", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/AlanWei/react-acl-router/issues" 21 | }, 22 | "homepage": "https://github.com/AlanWei/react-acl-router#readme", 23 | "devDependencies": { 24 | "@babel/cli": "^7.11.6", 25 | "@babel/core": "^7.11.6", 26 | "@babel/plugin-proposal-class-properties": "^7.10.4", 27 | "@babel/preset-env": "^7.11.5", 28 | "@babel/preset-react": "^7.10.4", 29 | "babel-eslint": "^10.1.0", 30 | "cross-env": "^7.0.2", 31 | "eslint": "^7.10.0", 32 | "eslint-config-airbnb": "^18.2.0", 33 | "eslint-config-prettier": "^6.12.0", 34 | "eslint-plugin-import": "^2.22.1", 35 | "eslint-plugin-jsx-a11y": "^6.3.1", 36 | "eslint-plugin-prettier": "^3.1.4", 37 | "eslint-plugin-react": "^7.21.3", 38 | "jest": "^26.5.2", 39 | "prettier": "^2.1.2" 40 | }, 41 | "peerDependencies": { 42 | "lodash": "^4.17.20", 43 | "react": "^16.13.1", 44 | "react-router-dom": "^5.2.0" 45 | }, 46 | "dependencies": { 47 | "lodash": "^4.17.20", 48 | "prop-types": "^15.6.1", 49 | "react": "^16.13.1", 50 | "react-router-dom": "^5.2.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/AclRouter.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Switch, Route, Redirect } from 'react-router-dom'; 4 | import map from 'lodash/map'; 5 | import isNil from 'lodash/isNil'; 6 | import omitRouteRenderProperties from './utils/omitRouteRenderProperties'; 7 | import checkPermissions from './utils/checkPermissions'; 8 | import DefaultLayout from './DefaultLayout'; 9 | import DefaultNotFound from './DefaultNotFound'; 10 | 11 | const propTypes = { 12 | authorities: PropTypes.oneOfType([ 13 | PropTypes.string, 14 | PropTypes.array, 15 | PropTypes.func, 16 | ]), 17 | normalRoutes: PropTypes.arrayOf( 18 | PropTypes.shape({ 19 | path: PropTypes.string, 20 | redirect: PropTypes.string, 21 | component: PropTypes.func, 22 | }), 23 | ), 24 | normalLayout: PropTypes.func, 25 | authorizedRoutes: PropTypes.arrayOf( 26 | PropTypes.shape({ 27 | path: PropTypes.string, 28 | permissions: PropTypes.arrayOf(PropTypes.string), 29 | component: PropTypes.func, 30 | redirect: PropTypes.string, 31 | unauthorized: PropTypes.func, 32 | }), 33 | ), 34 | authorizedLayout: PropTypes.func, 35 | notFound: PropTypes.func, 36 | }; 37 | 38 | const defaultProps = { 39 | authorities: '', 40 | normalRoutes: [], 41 | normalLayout: DefaultLayout, 42 | authorizedRoutes: [], 43 | authorizedLayout: DefaultLayout, 44 | notFound: DefaultNotFound, 45 | }; 46 | 47 | class AclRouter extends Component { 48 | renderRedirectRoute = (route) => ( 49 | } 53 | /> 54 | ); 55 | 56 | /** 57 | * props pass to Layout & Component are history, location, match 58 | */ 59 | renderAuthorizedRoute = (route) => { 60 | const { authorizedLayout: AuthorizedLayout, authorities } = this.props; 61 | const { 62 | permissions, 63 | path, 64 | component: RouteComponent, 65 | unauthorized: Unauthorized, 66 | } = route; 67 | 68 | const hasPermission = checkPermissions(authorities, permissions); 69 | 70 | if (!hasPermission && route.unauthorized) { 71 | return ( 72 | ( 76 | 77 | 78 | 79 | )} 80 | /> 81 | ); 82 | } 83 | 84 | if (!hasPermission && route.redirect) { 85 | return this.renderRedirectRoute(route); 86 | } 87 | 88 | return ( 89 | ( 93 | 94 | 95 | 96 | )} 97 | /> 98 | ); 99 | }; 100 | 101 | /** 102 | * props pass to Layout & Component are history, location, match 103 | */ 104 | renderUnAuthorizedRoute = (route) => { 105 | const { normalLayout: NormalLayout } = this.props; 106 | const { redirect, path, component: RouteComponent } = route; 107 | 108 | // check if current route is a redirect route (doesn't have component but redirect path) 109 | if (isNil(RouteComponent) && !isNil(redirect)) { 110 | return this.renderRedirectRoute(route); 111 | } 112 | 113 | return ( 114 | ( 118 | 119 | 120 | 121 | )} 122 | /> 123 | ); 124 | }; 125 | 126 | renderNotFoundRoute = () => { 127 | const { notFound: NotFound } = this.props; 128 | return } />; 129 | }; 130 | 131 | render() { 132 | const { normalRoutes, authorizedRoutes } = this.props; 133 | return ( 134 | 135 | {map(normalRoutes, (route) => this.renderUnAuthorizedRoute(route))} 136 | {map(authorizedRoutes, (route) => this.renderAuthorizedRoute(route))} 137 | {this.renderNotFoundRoute()} 138 | 139 | ); 140 | } 141 | } 142 | 143 | AclRouter.propTypes = propTypes; 144 | AclRouter.defaultProps = defaultProps; 145 | export default AclRouter; 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-acl-router 2 | 3 | [![npm v](https://img.shields.io/npm/v/react-acl-router.svg)](https://www.npmjs.com/package/react-acl-router) 4 | [![npm dm](https://img.shields.io/npm/dm/react-acl-router.svg)](https://www.npmjs.com/package/react-acl-router) 5 | 6 | Router with Access Control for React Applications. 7 | 8 | ## Installation 9 | 10 | ```bash 11 | yarn add react-acl-router react react-router-dom lodash 12 | ``` 13 | 14 | ## Usage 15 | 16 | ### AclRouter 17 | | Property | Description | Type | Default | 18 | | ------------------ | ---------------------------------------------- | -------------------------------- | ----------------------------- | 19 | | authorities | permissions of current user | OneOfType([string, array, func]) | '' | 20 | | authorizedRoutes | array of routes needs permissions | arrayOf(AuthorizedRoute) | [] | 21 | | authorizedLayout | container of all authorized routes | function | `
{props.children}
` | 22 | | normalRoutes | array of routes don't need permissions | arrayOf(NormalRoute) | [] | 23 | | normalLayout | container of all routes don't need permissions | function | `
{props.children}
` | 24 | | notFound | element to show when route doesn't match | function | `
404
` | 25 | 26 | ### AuthorizedRoute 27 | with all react-router `` supported props except `render` because `react-acl-router` will overwrite the `render` prop. 28 | 29 | | Property | Description | Type | Default | 30 | | ------------ | ---------------------------------------------------------------- | --------------- | -------- | 31 | | path | route's full path | string | - | 32 | | permissions | array of roles which have permission like ['god', 'admin' ] | arrayOf(string) | - | 33 | | component | route's component | function | - | 34 | | unauthorized | unauthorized view component if authorities don't have permission | string | - | 35 | | redirect | redirect path if authorities don't have permission | string | - | 36 | 37 | ### NormalRoute (with react-router Route's all supported props) 38 | with all react-router `` supported props except `render` because `react-acl-router` will overwrite the `render` prop. 39 | 40 | | Property | Description | Type | Default | 41 | | ----------- | ---------------------------------- | --------------- | -------- | 42 | | path | route's full path | string | - | 43 | | redirect | redirect route path to other route | string | - | 44 | | component | route's component | function | - | 45 | 46 | ## Example 47 | ```javascript 48 | import AclRouter from 'react-acl-router'; 49 | import BasicLayout from 'layouts/BasicLayout'; 50 | import NormalLayout from 'layouts/NormalLayout'; 51 | import Login from 'views/login'; 52 | import WorkInProgress from 'views/workInProgress'; 53 | import Unauthorized from 'views/unauthorized'; 54 | 55 | const authorizedRoutes = [{ 56 | path: '/dashboard/analysis/realtime', 57 | exact: true, 58 | permissions: ['admin', 'user'], 59 | redirect: '/login', 60 | component: WorkInProgress, 61 | }, { 62 | path: '/dashboard/analysis/offline', 63 | exact: true, 64 | permissions: ['admin', 'user'], 65 | redirect: '/login', 66 | component: WorkInProgress, 67 | }, { 68 | path: '/dashboard/workplace', 69 | exact: true, 70 | permissions: ['admin', 'user'], 71 | redirect: '/login', 72 | component: WorkInProgress, 73 | }, { 74 | path: '/exception/403', 75 | exact: true, 76 | permissions: ['god'], 77 | component: WorkInProgress, 78 | unauthorized: Unauthorized, 79 | }]; 80 | 81 | const normalRoutes = [{ 82 | path: '/', 83 | exact: true, 84 | redirect: '/dashboard/analysis/realtime', 85 | }, { 86 | path: '/login', 87 | exact: true, 88 | component: Login, 89 | }]; 90 | 91 | const Router = (props) => ( 92 |
Page Not Found
} 100 | /> 101 | ); 102 | 103 | export default Router; 104 | ``` 105 | 106 | ## Notes 107 | * For normal route, `redirect` or `unauthorized` and `component` are exclusive since normally you won't redirect user to another path while you have a valid component to render. --------------------------------------------------------------------------------