From df3740f0d8856467952119769fd3f18636a62fa3 Mon Sep 17 00:00:00 2001 From: Nate Clark Date: Sun, 17 May 2020 11:29:17 -0400 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E2=9C=A8=20Add=20user=20management?= =?UTF-8?q?=20page=20and=20respective=20nav=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ✨ Add `Can` shared component to restrict by permission - 🐛 Set initial user context from jwt if available - 🐛 Fix how react-refresh is ran during tests - 🐛 Fix write state warning on logout - ✨ Add user management link in side nav and placeholder page - ✨ Add user management link to mobile hamburger menu [ch17][ch18] --- packages/common/src/types/permission/index.js | 3 +- .../common/src/types/permission/typedef.js | 5 +- packages/common/src/types/userRole/index.js | 3 +- packages/common/src/types/userRole/typedef.js | 6 +- packages/ui/craco.config.js | 4 +- packages/ui/package.json | 2 +- packages/ui/src/i18n/en/app.js | 1 + packages/ui/src/i18n/en/users.js | 3 + .../Header/UserMenu/Navigation/index.js | 38 +++++- .../Header/UserMenu/Navigation/index.spec.js | 109 ++++++++++++++++++ .../__snapshots__/index.spec.js.snap | 6 + .../components/App/Layout/Navigation/index.js | 38 ++++-- .../App/Layout/Navigation/index.spec.js | 96 +++++++++++++++ .../components/App/Providers/Auth/index.js | 3 +- packages/ui/src/modules/app/routes/index.js | 4 +- .../ui/src/modules/logout/components/index.js | 7 +- .../Routing/__snapshots__/index.spec.js.snap | 10 ++ .../user/components/Routing/index.spec.js | 18 +++ .../List/__snapshots__/index.spec.js.snap | 11 ++ .../modules/users/components/List/index.js | 25 ++++ .../users/components/List/index.spec.js | 18 +++ .../Routing/__snapshots__/index.spec.js.snap | 7 ++ .../modules/users/components/Routing/index.js | 12 ++ .../users/components/Routing/index.spec.js | 18 +++ .../ui/src/modules/users/components/index.js | 14 +++ packages/ui/src/modules/users/index.js | 11 ++ .../src/shared/AuthRoute/__mocks__/index.js | 5 + packages/ui/src/shared/Can/index.js | 15 +++ packages/ui/src/shared/Can/index.spec.js | 47 ++++++++ packages/ui/src/types/jwt/selectors.js | 9 +- packages/ui/src/types/route/examples.js | 2 + packages/ui/src/types/route/typedef.js | 12 ++ 32 files changed, 535 insertions(+), 27 deletions(-) create mode 100644 packages/ui/src/i18n/en/users.js create mode 100644 packages/ui/src/modules/user/components/Routing/__snapshots__/index.spec.js.snap create mode 100644 packages/ui/src/modules/user/components/Routing/index.spec.js create mode 100644 packages/ui/src/modules/users/components/List/__snapshots__/index.spec.js.snap create mode 100644 packages/ui/src/modules/users/components/List/index.js create mode 100644 packages/ui/src/modules/users/components/List/index.spec.js create mode 100644 packages/ui/src/modules/users/components/Routing/__snapshots__/index.spec.js.snap create mode 100644 packages/ui/src/modules/users/components/Routing/index.js create mode 100644 packages/ui/src/modules/users/components/Routing/index.spec.js create mode 100644 packages/ui/src/modules/users/components/index.js create mode 100644 packages/ui/src/modules/users/index.js create mode 100644 packages/ui/src/shared/AuthRoute/__mocks__/index.js create mode 100644 packages/ui/src/shared/Can/index.js create mode 100644 packages/ui/src/shared/Can/index.spec.js diff --git a/packages/common/src/types/permission/index.js b/packages/common/src/types/permission/index.js index 528ebf27..f3d23900 100644 --- a/packages/common/src/types/permission/index.js +++ b/packages/common/src/types/permission/index.js @@ -1,9 +1,10 @@ import { hasPermission } from './selectors'; -import { Enumeration } from './typedef'; +import { Enumeration, values } from './typedef'; const Permission = { ...Enumeration, hasPermission, + values, }; export { Permission }; diff --git a/packages/common/src/types/permission/typedef.js b/packages/common/src/types/permission/typedef.js index 75830bcb..2511fbb8 100644 --- a/packages/common/src/types/permission/typedef.js +++ b/packages/common/src/types/permission/typedef.js @@ -1,3 +1,4 @@ +import { values as ramdaValues } from 'ramda'; import { UserRole } from 'types/userRole'; const { ADMIN } = UserRole; @@ -10,4 +11,6 @@ const Access = { USERS_MANAGE: [ADMIN], }; -export { Access, Enumeration }; +const values = ramdaValues(Enumeration); + +export { Access, Enumeration, values }; diff --git a/packages/common/src/types/userRole/index.js b/packages/common/src/types/userRole/index.js index a15c81b9..61271eda 100644 --- a/packages/common/src/types/userRole/index.js +++ b/packages/common/src/types/userRole/index.js @@ -1,7 +1,8 @@ -import { Enumeration } from './typedef'; +import { Enumeration, values } from './typedef'; const UserRole = { ...Enumeration, + values, }; export { UserRole }; diff --git a/packages/common/src/types/userRole/typedef.js b/packages/common/src/types/userRole/typedef.js index a8ae8c60..fc007000 100644 --- a/packages/common/src/types/userRole/typedef.js +++ b/packages/common/src/types/userRole/typedef.js @@ -1,6 +1,10 @@ +import { values as ramdaValues } from 'ramda'; + const Enumeration = { ADMIN: 'ADMIN', USER: 'USER', }; -export { Enumeration }; +const values = ramdaValues(Enumeration); + +export { Enumeration, values }; diff --git a/packages/ui/craco.config.js b/packages/ui/craco.config.js index 534b8eb9..09cd8fc4 100644 --- a/packages/ui/craco.config.js +++ b/packages/ui/craco.config.js @@ -6,7 +6,7 @@ module.exports = { plugins: [ 'add-react-displayname', ['babel-plugin-styled-components', { ssr: false }], - ...whenDev(() => ['react-refresh/babel'], []), + ...whenDev(() => [['react-refresh/babel', { skipEnvCheck: true }]], []), ...whenProd(() => ['babel-plugin-jsx-remove-data-test-id'], []), ], }, @@ -48,7 +48,7 @@ module.exports = { }, plugins: [ ...(webpackConfig.plugins || []), - ...whenDev(() => [new ReactRefreshPlugin()], []), + ...whenDev(() => [new ReactRefreshPlugin({ overlay: false })], []), ], }), }, diff --git a/packages/ui/package.json b/packages/ui/package.json index 00d5dae4..39ff12f8 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -18,7 +18,7 @@ "start:pre": "(cat ../../.ini/certs/cert.pem ../../.ini/certs/key.pem > ../../node_modules/webpack-dev-server/ssl/server.pem) || :", "start:prod": "yarn run -s serve -l 3000 --ssl-cert ../../.ini/certs/cert.pem --ssl-key ../../.ini/certs/key.pem", "start:prod:heroku": "yarn run -s serve", - "test": "craco test --passWithNoTests", + "test": "NODE_ENV=test craco test --passWithNoTests", "test:ci": "JEST_JUNIT_OUTPUT_DIR=../../.tmp/test-results/jest/ui yarn -s test --ci --runInBand --reporters=default --reporters=jest-junit", "test:watch": "craco test --passWithNoTests --color" }, diff --git a/packages/ui/src/i18n/en/app.js b/packages/ui/src/i18n/en/app.js index 92483fde..0e909eda 100644 --- a/packages/ui/src/i18n/en/app.js +++ b/packages/ui/src/i18n/en/app.js @@ -12,6 +12,7 @@ export const app = { registration: 'Sign Up', root: 'Home', testPage: 'Test Page', + userManagement: 'Users', }, openNavigation: 'Open navigation', tapHereOrSwipeToClose: 'Tap here or swipe to close', diff --git a/packages/ui/src/i18n/en/users.js b/packages/ui/src/i18n/en/users.js new file mode 100644 index 00000000..9038f562 --- /dev/null +++ b/packages/ui/src/i18n/en/users.js @@ -0,0 +1,3 @@ +export const users = { + users: 'Users', +}; diff --git a/packages/ui/src/modules/app/components/App/Layout/Header/UserMenu/Navigation/index.js b/packages/ui/src/modules/app/components/App/Layout/Header/UserMenu/Navigation/index.js index 16137074..c792325a 100644 --- a/packages/ui/src/modules/app/components/App/Layout/Header/UserMenu/Navigation/index.js +++ b/packages/ui/src/modules/app/components/App/Layout/Header/UserMenu/Navigation/index.js @@ -1,3 +1,4 @@ +import { Permission } from '@boilerplate-monorepo/common'; import { A11y } from '@boilerplate-monorepo/ui-common'; import { func } from 'prop-types'; import React, { useCallback, useState } from 'react'; @@ -16,7 +17,6 @@ import { styles as themeStyles } from './theme'; const { Role } = A11y; const { LINK } = Context; -const routes = Route.filterToNavigation(Route.values); const TOUCH_HANDLE_DISTANCE = 20; const StyledNav = styled.nav` @@ -47,26 +47,52 @@ const ButtonContainer = styled.div` white-space: nowrap; `; +const toNavLink = (authContext, onClick) => (route) => { + const { isAuthenticated, role } = authContext; + const { isAuthenticationRequired, requiredPermission } = route; + + if (isAuthenticationRequired && !isAuthenticated) { + return null; + } + + if ( + requiredPermission && + !Permission.hasPermission(role, requiredPermission) + ) { + return null; + } + + return ( + + ); +}; + const InnerSideBar = ({ onClose, t }) => { const commonT = useTranslate({ component: 'common', namespace: 'common', }); - const { isAuthenticated } = useAuth(); + const authContext = useAuth(); + const { isAuthenticated } = authContext; + const authRoute = isAuthenticated ? Route.LOGOUT : Route.LOGIN; + const routes = Route.filterToNavigation(Route.values); const authDisplayText = isAuthenticated ? commonT('logout') : commonT('login'); - const authRoute = isAuthenticated ? Route.LOGOUT : Route.LOGIN; return ( - {routes.map((route) => ( - - ))} + {routes.map(toNavLink(authContext, onClose))} {isAuthenticated && ( ', () => { expect(container.firstChild).toMatchSnapshot(); }); + + const authRouteName = 'AUTH_ROUTE_NAME'; + const isAuthenticationRequired = true; + const role = UserRole.USER; + + const navRoutes = [ + Route.example({ isAuthenticationRequired, name: authRouteName }), + ]; + + describe('when user is authenticated', () => { + const isAuthenticated = true; + + let useAuth = null; + + beforeEach(() => { + useAuth = td.replace(UseAuthHook, 'useAuth'); + + td.when(useAuth()).thenReturn({ isAuthenticated, role }); + td.replace(Route, 'values', navRoutes); + }); + + test('renders the user account link', () => { + const { getByTestId } = renderComponent(); + + expect(getByTestId(Route.USER_ACCOUNT.name)).not.toBeNull(); + }); + + describe('and the nav link requires permission', () => { + const permissionRouteName = 'PERMISSION_ROUTE_NAME'; + + const permissionRoutes = [ + Route.example({ + isAuthenticationRequired, + name: permissionRouteName, + requiredPermission: Permission.USERS_MANAGE, + }), + ]; + + beforeEach(() => { + td.replace(Route, 'values', permissionRoutes); + }); + + describe('and user has permission', () => { + const adminRole = UserRole.ADMIN; + + beforeEach(() => { + td.when(useAuth()).thenReturn({ isAuthenticated, role: adminRole }); + }); + + test('renders the permission nav link', () => { + const { getByTestId } = renderComponent(); + + expect(getByTestId(permissionRouteName)).not.toBeNull(); + }); + }); + + describe('and user DOES NOT have permission', () => { + beforeEach(() => { + td.when(useAuth()).thenReturn({ isAuthenticated, role }); + }); + + test('does not renders the permission nav link', () => { + const { queryByTestId } = renderComponent(); + + expect(queryByTestId(permissionRouteName)).toBeNull(); + }); + }); + }); + + describe('and the nav link DOES NOT require permission', () => { + test('renders the authenticated nav link', () => { + td.when(useAuth()).thenReturn({ isAuthenticated, role }); + + const { getByTestId } = renderComponent(); + + expect(getByTestId(authRouteName)).not.toBeNull(); + }); + }); + }); + + describe('when user is NOT authenticated', () => { + const isAuthenticated = false; + + let useAuth = null; + + beforeEach(() => { + useAuth = td.replace(UseAuthHook, 'useAuth'); + + td.when(useAuth()).thenReturn({ isAuthenticated, role: null }); + td.replace(Route, 'values', navRoutes); + }); + + test('DOES NOT render the user account link', () => { + const { queryByTestId } = renderComponent(); + + expect(queryByTestId(Route.USER_ACCOUNT.name)).toBeNull(); + }); + + test('does not render the authenticated nav link', () => { + td.when(useAuth()).thenReturn({ isAuthenticated, role }); + + const { queryByTestId } = renderComponent(); + + expect(queryByTestId(authRouteName)).toBeNull(); + }); + }); }); diff --git a/packages/ui/src/modules/app/components/App/Layout/Navigation/__snapshots__/index.spec.js.snap b/packages/ui/src/modules/app/components/App/Layout/Navigation/__snapshots__/index.spec.js.snap index 40cb1b00..26922f7d 100644 --- a/packages/ui/src/modules/app/components/App/Layout/Navigation/__snapshots__/index.spec.js.snap +++ b/packages/ui/src/modules/app/components/App/Layout/Navigation/__snapshots__/index.spec.js.snap @@ -9,10 +9,16 @@ exports[` renders properly 1`] = ` class="Navigation__NavLinks-hlefot-1 blIUvd" > + diff --git a/packages/ui/src/modules/app/components/App/Layout/Navigation/index.js b/packages/ui/src/modules/app/components/App/Layout/Navigation/index.js index b37fd346..74a598e0 100644 --- a/packages/ui/src/modules/app/components/App/Layout/Navigation/index.js +++ b/packages/ui/src/modules/app/components/App/Layout/Navigation/index.js @@ -1,5 +1,7 @@ +import { Permission } from '@boilerplate-monorepo/common'; import { A11y, SkipToDestination } from '@boilerplate-monorepo/ui-common'; import React from 'react'; +import { useAuth } from 'shared/useAuth'; import styled from 'styled-components/macro'; import { CustomProperty } from 'types/customProperties'; import { GridTemplateArea } from 'types/gridTemplateArea'; @@ -20,10 +22,26 @@ const Container = styled.nav` width: max-content; `; -const toNavLink = (route, index) => { +// eslint-disable-next-line complexity +const toNavLink = (authContext) => (route, index) => { const id = index === 0 ? SkipToDestination.NAVIGATION : undefined; + const { isAuthenticated, role } = authContext; + const { isAuthenticationRequired, requiredPermission } = route; - return ; + if (isAuthenticationRequired && !isAuthenticated) { + return null; + } + + if ( + requiredPermission && + !Permission.hasPermission(role, requiredPermission) + ) { + return null; + } + + return ( + + ); }; const NavLinks = styled.div` @@ -32,11 +50,15 @@ const NavLinks = styled.div` grid-area: ${GridTemplateArea.NAV_LINK}; `; -const Navigation = () => ( - - {navRoutes.map(toNavLink)} - - -); +const Navigation = () => { + const authContext = useAuth(); + + return ( + + {navRoutes.map(toNavLink(authContext))} + + + ); +}; export { Navigation }; diff --git a/packages/ui/src/modules/app/components/App/Layout/Navigation/index.spec.js b/packages/ui/src/modules/app/components/App/Layout/Navigation/index.spec.js index 212c918f..e90ae97f 100644 --- a/packages/ui/src/modules/app/components/App/Layout/Navigation/index.spec.js +++ b/packages/ui/src/modules/app/components/App/Layout/Navigation/index.spec.js @@ -1,5 +1,9 @@ +import { UserRole, Permission } from '@boilerplate-monorepo/common'; import React from 'react'; +import * as UseAuthHook from 'shared/useAuth'; import { render } from 'testHelpers'; +import { Route } from 'types/route'; +import * as NavRoutes from '../../../../routes'; import { Navigation } from '.'; jest.mock('shared/useAuth'); @@ -21,4 +25,96 @@ describe('', () => { expect(container.firstChild).toMatchSnapshot(); }); + + const authRouteName = 'AUTH_ROUTE_NAME'; + const isAuthenticationRequired = true; + const role = UserRole.USER; + + const navRoutes = [ + Route.example({ isAuthenticationRequired, name: authRouteName }), + ]; + + describe('when user is authenticated', () => { + const isAuthenticated = true; + + let useAuth = null; + + beforeEach(() => { + useAuth = td.replace(UseAuthHook, 'useAuth'); + + td.replace(NavRoutes, 'navRoutes', navRoutes); + }); + + describe('and the nav link requires permission', () => { + const permissionRouteName = 'PERMISSION_ROUTE_NAME'; + + const permissionRoutes = [ + Route.example({ + isAuthenticationRequired, + name: permissionRouteName, + requiredPermission: Permission.USERS_MANAGE, + }), + ]; + + beforeEach(() => { + td.replace(NavRoutes, 'navRoutes', permissionRoutes); + }); + + describe('and user has permission', () => { + const adminRole = UserRole.ADMIN; + + beforeEach(() => { + td.when(useAuth()).thenReturn({ isAuthenticated, role: adminRole }); + }); + + test('renders the permission nav link', () => { + const { getByTestId } = renderComponent(); + + expect(getByTestId(permissionRouteName)).not.toBeNull(); + }); + }); + + describe('and user DOES NOT have permission', () => { + beforeEach(() => { + td.when(useAuth()).thenReturn({ isAuthenticated, role }); + }); + + test('does not renders the permission nav link', () => { + const { queryByTestId } = renderComponent(); + + expect(queryByTestId(permissionRouteName)).toBeNull(); + }); + }); + }); + + describe('and the nav link DOES NOT require permission', () => { + test('renders the authenticated nav link', () => { + td.when(useAuth()).thenReturn({ isAuthenticated, role }); + + const { getByTestId } = renderComponent(); + + expect(getByTestId(authRouteName)).not.toBeNull(); + }); + }); + }); + + describe('when user is NOT authenticated', () => { + const isAuthenticated = false; + + let useAuth = null; + + beforeEach(() => { + useAuth = td.replace(UseAuthHook, 'useAuth'); + + td.replace(NavRoutes, 'navRoutes', navRoutes); + }); + + test('does not render the authenticated nav link', () => { + td.when(useAuth()).thenReturn({ isAuthenticated, role }); + + const { queryByTestId } = renderComponent(); + + expect(queryByTestId(authRouteName)).toBeNull(); + }); + }); }); diff --git a/packages/ui/src/modules/app/components/App/Providers/Auth/index.js b/packages/ui/src/modules/app/components/App/Providers/Auth/index.js index d60f78c9..af7feae8 100644 --- a/packages/ui/src/modules/app/components/App/Providers/Auth/index.js +++ b/packages/ui/src/modules/app/components/App/Providers/Auth/index.js @@ -4,6 +4,7 @@ import { node } from 'prop-types'; import React, { useCallback, useState } from 'react'; import { useUserLogout } from 'shared/graphql'; import { AccessToken } from 'types/accessToken'; +import { Jwt } from 'types/jwt'; import { Provider } from 'types/provider'; const Auth = ({ children }) => { @@ -12,7 +13,7 @@ const Auth = ({ children }) => { ); const client = useApolloClient(); - const [user, setUser] = useState(null); + const [user, setUser] = useState(Jwt.decode(AccessToken.read())); const [mutate] = useUserLogout(); const logout = useCallback(async () => { diff --git a/packages/ui/src/modules/app/routes/index.js b/packages/ui/src/modules/app/routes/index.js index 9474499d..ca9a217f 100644 --- a/packages/ui/src/modules/app/routes/index.js +++ b/packages/ui/src/modules/app/routes/index.js @@ -8,6 +8,7 @@ import { NotFound } from 'modules/notFound'; import { Signup } from 'modules/signup'; import { TestPage } from 'modules/testPage'; import { User } from 'modules/user'; +import { Users } from 'modules/users'; const componentMap = { about: About, @@ -19,10 +20,11 @@ const componentMap = { root: Dashboard, signup: Signup, testPage: TestPage, + userManagement: Users, }; const addComponent = (route) => { - const component = defaultTo(Dashboard, componentMap[route.name]); + const component = defaultTo(NotFound, componentMap[route.name]); return mergeRight(route, { component }); }; diff --git a/packages/ui/src/modules/logout/components/index.js b/packages/ui/src/modules/logout/components/index.js index d1e2f269..5c3d75e8 100644 --- a/packages/ui/src/modules/logout/components/index.js +++ b/packages/ui/src/modules/logout/components/index.js @@ -7,9 +7,10 @@ const Logout = () => { const history = useHistory(); const { logout } = useAuth(); - useTimeout(() => history.push(Route.LOGIN.path), 1); - - logout(); + useTimeout(() => { + logout(); + history.push(Route.LOGIN.path); + }, 1); return null; }; diff --git a/packages/ui/src/modules/user/components/Routing/__snapshots__/index.spec.js.snap b/packages/ui/src/modules/user/components/Routing/__snapshots__/index.spec.js.snap new file mode 100644 index 00000000..901da3e2 --- /dev/null +++ b/packages/ui/src/modules/user/components/Routing/__snapshots__/index.spec.js.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders properly 1`] = ` + + + + +`; diff --git a/packages/ui/src/modules/user/components/Routing/index.spec.js b/packages/ui/src/modules/user/components/Routing/index.spec.js new file mode 100644 index 00000000..b7d4e8da --- /dev/null +++ b/packages/ui/src/modules/user/components/Routing/index.spec.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { render } from 'testHelpers'; +import { Routing } from '.'; + +jest.mock('shared/AuthRoute'); + +describe('', () => { + const defaultProps = {}; + + const renderComponent = (overrides) => + render(); + + test('renders properly', () => { + const { container } = renderComponent(); + + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/packages/ui/src/modules/users/components/List/__snapshots__/index.spec.js.snap b/packages/ui/src/modules/users/components/List/__snapshots__/index.spec.js.snap new file mode 100644 index 00000000..2d8db12e --- /dev/null +++ b/packages/ui/src/modules/users/components/List/__snapshots__/index.spec.js.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders properly 1`] = ` + + + +`; diff --git a/packages/ui/src/modules/users/components/List/index.js b/packages/ui/src/modules/users/components/List/index.js new file mode 100644 index 00000000..8ba7c16c --- /dev/null +++ b/packages/ui/src/modules/users/components/List/index.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { Body, Breadcrumb, Breadcrumbs, Header } from 'shared/Content'; +import { useTranslate } from 'shared/useTranslate'; +import { Route } from 'types/route'; + +const List = () => { + const t = useTranslate({ + component: 'list', + namespace: 'users', + }); + + return ( + <> + + + +
+ +

coming soon...

+ + + ); +}; + +export { List }; diff --git a/packages/ui/src/modules/users/components/List/index.spec.js b/packages/ui/src/modules/users/components/List/index.spec.js new file mode 100644 index 00000000..7f79bf06 --- /dev/null +++ b/packages/ui/src/modules/users/components/List/index.spec.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { render } from 'testHelpers'; +import { List } from '.'; + +jest.mock('shared/Content'); + +describe('', () => { + const defaultProps = {}; + + const renderComponent = (overrides) => + render(); + + test('renders properly', () => { + const { container } = renderComponent(); + + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/packages/ui/src/modules/users/components/Routing/__snapshots__/index.spec.js.snap b/packages/ui/src/modules/users/components/Routing/__snapshots__/index.spec.js.snap new file mode 100644 index 00000000..836d140a --- /dev/null +++ b/packages/ui/src/modules/users/components/Routing/__snapshots__/index.spec.js.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders properly 1`] = ` + + + +`; diff --git a/packages/ui/src/modules/users/components/Routing/index.js b/packages/ui/src/modules/users/components/Routing/index.js new file mode 100644 index 00000000..6707f298 --- /dev/null +++ b/packages/ui/src/modules/users/components/Routing/index.js @@ -0,0 +1,12 @@ +import React from 'react'; +import { Switch } from 'react-router-dom'; +import { AuthRoute } from 'shared/AuthRoute'; +import { List } from '../List'; + +const Routing = () => ( + + + +); + +export { Routing }; diff --git a/packages/ui/src/modules/users/components/Routing/index.spec.js b/packages/ui/src/modules/users/components/Routing/index.spec.js new file mode 100644 index 00000000..b7d4e8da --- /dev/null +++ b/packages/ui/src/modules/users/components/Routing/index.spec.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { render } from 'testHelpers'; +import { Routing } from '.'; + +jest.mock('shared/AuthRoute'); + +describe('', () => { + const defaultProps = {}; + + const renderComponent = (overrides) => + render(); + + test('renders properly', () => { + const { container } = renderComponent(); + + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/packages/ui/src/modules/users/components/index.js b/packages/ui/src/modules/users/components/index.js new file mode 100644 index 00000000..02670993 --- /dev/null +++ b/packages/ui/src/modules/users/components/index.js @@ -0,0 +1,14 @@ +import React from 'react'; +import { Content } from 'shared/Content'; +import { Page } from 'shared/Page'; +import { Routing } from './Routing'; + +const Users = () => ( + + + + + +); + +export { Users }; diff --git a/packages/ui/src/modules/users/index.js b/packages/ui/src/modules/users/index.js new file mode 100644 index 00000000..57a236d9 --- /dev/null +++ b/packages/ui/src/modules/users/index.js @@ -0,0 +1,11 @@ +import { Utils } from '@boilerplate-monorepo/common'; +import { lazy } from 'react'; + +const Lazy = lazy(() => + import( + /* webpackChunkName: "users" */ + './components' + ).then(Utils.renameKeys({ Users: 'default' })) +); + +export { Lazy as Users }; diff --git a/packages/ui/src/shared/AuthRoute/__mocks__/index.js b/packages/ui/src/shared/AuthRoute/__mocks__/index.js new file mode 100644 index 00000000..a9fc09c3 --- /dev/null +++ b/packages/ui/src/shared/AuthRoute/__mocks__/index.js @@ -0,0 +1,5 @@ +import React from 'react'; + +const AuthRoute = (props) => ; + +export { AuthRoute }; diff --git a/packages/ui/src/shared/Can/index.js b/packages/ui/src/shared/Can/index.js new file mode 100644 index 00000000..577f639f --- /dev/null +++ b/packages/ui/src/shared/Can/index.js @@ -0,0 +1,15 @@ +import { Permission, UserRole } from '@boilerplate-monorepo/common'; +import { oneOf } from 'prop-types'; + +const Can = ({ children, permission, role }) => { + if (!Permission.hasPermission(role, permission)) return null; + + return children; +}; + +Can.propTypes = { + permission: oneOf(Permission.values).isRequired, + role: oneOf(UserRole.values).isRequired, +}; + +export { Can }; diff --git a/packages/ui/src/shared/Can/index.spec.js b/packages/ui/src/shared/Can/index.spec.js new file mode 100644 index 00000000..56e4ef4e --- /dev/null +++ b/packages/ui/src/shared/Can/index.spec.js @@ -0,0 +1,47 @@ +import { Permission, UserRole } from '@boilerplate-monorepo/common'; +import React from 'react'; +import { render } from 'testHelpers'; +import { Can } from '.'; + +describe('', () => { + const permission = Permission.USERS_MANAGE; + const role = UserRole.ADMIN; + + const defaultProps = { + permission, + role, + }; + + const renderComponent = (overrides) => + render(); + + describe('when a role has a permission', () => { + beforeEach(() => { + const hasPermission = td.replace(Permission, 'hasPermission'); + + td.when(hasPermission(role, permission)).thenReturn(true); + }); + + test('renders children', () => { + const children = ; + const { getByTestId } = renderComponent({ children }); + + expect(getByTestId('child')).not.toBeNull(); + }); + }); + + describe('when a role DOES NOT have permission', () => { + beforeEach(() => { + const hasPermission = td.replace(Permission, 'hasPermission'); + + td.when(hasPermission(role, permission)).thenReturn(false); + }); + + test('renders nothing', () => { + const children = ; + const { queryByTestId } = renderComponent({ children }); + + expect(queryByTestId('child')).toBeNull(); + }); + }); +}); diff --git a/packages/ui/src/types/jwt/selectors.js b/packages/ui/src/types/jwt/selectors.js index e8df66cc..aaef911f 100644 --- a/packages/ui/src/types/jwt/selectors.js +++ b/packages/ui/src/types/jwt/selectors.js @@ -1,5 +1,12 @@ import jwtDecode from 'jwt-decode'; -const decode = (token) => jwtDecode(token); +const decode = (token) => { + try { + return jwtDecode(token); + } catch (error) { + // Do nothing + return null; + } +}; export { decode }; diff --git a/packages/ui/src/types/route/examples.js b/packages/ui/src/types/route/examples.js index ec2ac3dd..8b97c7bf 100644 --- a/packages/ui/src/types/route/examples.js +++ b/packages/ui/src/types/route/examples.js @@ -5,8 +5,10 @@ const example = (overrides) => ({ [SORT_PROP_NAME]: 1, exact: true, icon: MdDashboard, + isAuthenticationRequired: false, name: 'dashboard', path: '/', + requiredPermission: null, ...overrides, }); diff --git a/packages/ui/src/types/route/typedef.js b/packages/ui/src/types/route/typedef.js index bd536327..84cba136 100644 --- a/packages/ui/src/types/route/typedef.js +++ b/packages/ui/src/types/route/typedef.js @@ -1,3 +1,4 @@ +import { Permission } from '@boilerplate-monorepo/common'; import { config } from 'config'; import { shape, string } from 'prop-types'; import { values as ramdaValues } from 'ramda'; @@ -7,6 +8,7 @@ import { FaSignInAlt, FaSignOutAlt, FaUserAlt, + FaUsersCog, } from 'react-icons/fa'; import { GiFireBowl } from 'react-icons/gi'; import { MdDashboard, MdPersonAdd } from 'react-icons/md'; @@ -65,6 +67,7 @@ export const Enumeration = { }, USER_ACCOUNT: { icon: FaUserAlt, + isAuthenticationRequired: true, name: 'account', path: '/account', }, @@ -113,6 +116,15 @@ export const Enumeration = { name: 'accountSecurity', path: '/account/security', }, + USER_MANAGEMENT: { + [SORT_PROP_NAME]: 20, + exact, + icon: FaUsersCog, + isAuthenticationRequired: true, + name: 'userManagement', + path: '/users', + requiredPermission: Permission.USERS_MANAGE, + }, }; export const propTypes = shape({