refactor: rendering pages and links by roles

This commit is contained in:
Sergey Yarkov 2021-09-12 19:31:31 +03:00
parent 5a510d77d2
commit 5dae2f5975
12 changed files with 124 additions and 65 deletions

View File

@ -1,6 +1,14 @@
import React from 'react'; import React from 'react';
import { Router, Switch } from 'react-router-dom'; import { Router, Switch } from 'react-router-dom';
/**
* Types
*/
import { UserRoleEnum } from 'enums'; import { UserRoleEnum } from 'enums';
/**
* Hooks
*/
import { useRootStore } from 'hooks/useRootStore'; import { useRootStore } from 'hooks/useRootStore';
/** /**
@ -32,7 +40,12 @@ const App = () => {
<PrivateRoute path='/courses' component={CoursesPage} title='Courses' /> <PrivateRoute path='/courses' component={CoursesPage} title='Courses' />
<PrivateRoute path='/messages' component={MessagesPage} title='Messages' /> <PrivateRoute path='/messages' component={MessagesPage} title='Messages' />
<PrivateRoute path='/profile' component={ProfilePage} title='Profile' /> <PrivateRoute path='/profile' component={ProfilePage} title='Profile' />
<PrivateRoute path='/control' component={ControlPage} title='Control' roles={[UserRoleEnum.ADMIN]} /> <PrivateRoute
path='/control'
component={ControlPage}
title='Control'
roles={[UserRoleEnum.ADMIN, UserRoleEnum.TEACHER]}
/>
<PublicRoute path='/auth' component={AuthPage} /> <PublicRoute path='/auth' component={AuthPage} />
<PublicRoute component={NotFoundPage} /> <PublicRoute component={NotFoundPage} />
</Switch> </Switch>

View File

@ -1,21 +1,25 @@
import React from 'react'; import React, { useEffect } from 'react';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { useErrorHandler } from 'react-error-boundary'; import { useErrorHandler } from 'react-error-boundary';
import { Flex, Box } from '@chakra-ui/react'; import { Flex, Box } from '@chakra-ui/react';
import { useRootStore } from 'hooks/useRootStore'; import { useRootStore } from 'hooks/useRootStore';
import Header from 'components/Header'; import Header from 'components/Header';
import NavDesktop from 'components/Nav/Desktop'; import NavDesktop from 'components/Nav/Desktop';
import { UserRoleEnum } from 'enums';
import { userContainRoles } from 'helpers';
import { Redirect } from 'react-router';
type LayoutPropsType = { roles: UserRoleEnum[] | undefined };
/** /**
*
* Layout component * Layout component
* Сomponent for rendering the current page. * Сomponent for rendering the current page.
*/ */
const Layout: React.FC = ({ children }) => { const Layout: React.FC<LayoutPropsType> = ({ children, roles }) => {
const { userStore } = useRootStore(); const { userStore } = useRootStore();
const handleError = useErrorHandler(); const handleError = useErrorHandler();
React.useEffect(() => { useEffect(() => {
userStore.loadCurrentUserData().catch(error => handleError(error)); userStore.loadCurrentUserData().catch(error => handleError(error));
}, [handleError, userStore]); }, [handleError, userStore]);
@ -42,7 +46,28 @@ const Layout: React.FC = ({ children }) => {
}, },
}} }}
> >
{children} {(() => {
/**
* Check user roles if defined "roles" prop in
* PrivateRoute component and return page
*/
if (roles) {
const { me } = userStore;
if (me === null) return null;
/**
* The user does not have the required roles
*/
if (!userContainRoles(me.roles, roles)) return <Redirect to='/404' />;
return children;
} else {
/**
* Return children if "roles" prop is not defined
*/
return children;
}
})()}
</Box> </Box>
</Flex> </Flex>
</> </>

View File

@ -3,7 +3,6 @@ import { observer } from 'mobx-react';
import { MdExitToApp } from 'react-icons/md'; import { MdExitToApp } from 'react-icons/md';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useRootStore } from 'hooks/useRootStore'; import { useRootStore } from 'hooks/useRootStore';
import { Avatar } from '@chakra-ui/avatar'; import { Avatar } from '@chakra-ui/avatar';
import { Flex, Box, Text } from '@chakra-ui/layout'; import { Flex, Box, Text } from '@chakra-ui/layout';
import { Drawer, DrawerOverlay, DrawerContent, DrawerBody } from '@chakra-ui/modal'; import { Drawer, DrawerOverlay, DrawerContent, DrawerBody } from '@chakra-ui/modal';
@ -31,31 +30,37 @@ const NavMobile: React.FC = () => {
<NavList links={config.links} onCloseDrawer={onClose} /> <NavList links={config.links} onCloseDrawer={onClose} />
</Flex> </Flex>
{userStore.me ? ( {userStore.me ? (
<Box <Box pb='10px'>
minHeight='60px' <Box
padding='10px' minHeight='60px'
borderRadius='md' padding='10px'
marginTop='auto' borderRadius='md'
backgroundColor={colorMode === 'dark' ? 'gray.700' : 'gray.100'} marginTop='auto'
> backgroundColor={colorMode === 'dark' ? 'gray.700' : 'gray.100'}
<Flex alignItems='center'> >
<Avatar size='sm' name={`${userStore.me.first_name} ${userStore.me.last_name}`} marginRight={3} /> <Flex alignItems='center'>
<Flex flexDirection='column'> <Avatar size='sm' name={`${userStore.me.first_name} ${userStore.me.last_name}`} marginRight={3} />
<Text as='span' mr={2} lineHeight='1.2'> <Flex flexDirection='column'>
{`${userStore.me.first_name} ${userStore.me.last_name}`} <Text as='span' mr={2} lineHeight='1.2'>
</Text> {`${userStore.me.first_name} ${userStore.me.last_name}`}
<Link to={`/profile`} style={{ fontSize: '13px', textDecoration: 'underline' }} onClick={onClose}> </Text>
View profile <Link
</Link> to={`/profile`}
style={{ fontSize: '13px', textDecoration: 'underline' }}
onClick={onClose}
>
View profile
</Link>
</Flex>
<IconButton
onClick={logout}
aria-label='Logout'
backgroundColor={colorMode === 'dark' ? 'gray.600' : 'gray.200'}
marginLeft='auto'
icon={<MdExitToApp />}
/>
</Flex> </Flex>
<IconButton </Box>
onClick={logout}
aria-label='Logout'
backgroundColor={colorMode === 'dark' ? 'gray.600' : 'gray.200'}
marginLeft='auto'
icon={<MdExitToApp />}
/>
</Flex>
</Box> </Box>
) : ( ) : (
<Skeleton <Skeleton

View File

@ -1,8 +1,9 @@
import React from 'react'; import React from 'react';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { Box, useColorMode } from '@chakra-ui/react'; import { Box, useColorMode } from '@chakra-ui/react';
import type { LinkType } from 'interfaces'; import type { LinkType } from 'types';
import { useRootStore } from 'hooks/useRootStore'; import { useRootStore } from 'hooks/useRootStore';
import { userContainRoles } from 'helpers';
type NavLinkPropsType = { type NavLinkPropsType = {
link: LinkType; link: LinkType;
@ -13,11 +14,16 @@ type NavLinkPropsType = {
* Returns the link component for navigation. * Returns the link component for navigation.
*/ */
const NavLink: React.FC<NavLinkPropsType> = ({ link, onClickLink }) => { const NavLink: React.FC<NavLinkPropsType> = ({ link, onClickLink }) => {
const { uiStore } = useRootStore(); const { uiStore, userStore } = useRootStore();
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
const onClick = () => onClickLink(link); const onClick = () => onClickLink(link);
if (!link.public && link.roles !== undefined) {
const { me } = userStore;
if (me === null) return null;
if (!userContainRoles(me.roles, link.roles)) return null;
}
return ( return (
<Box <Box
as='a' as='a'

View File

@ -1,10 +1,7 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { useRootStore } from 'hooks/useRootStore'; import { LinkType } from 'types';
import { userHasRoles } from 'helpers';
import { UserRoleEnum } from 'enums';
import { LinkType } from 'interfaces';
import NavLink from './NavLink'; import NavLink from './NavLink';
type NavListPropsType = { type NavListPropsType = {
@ -16,7 +13,6 @@ type NavListPropsType = {
* Render a navigation list of links * Render a navigation list of links
*/ */
const NavList: React.FC<NavListPropsType> = ({ links, onCloseDrawer }) => { const NavList: React.FC<NavListPropsType> = ({ links, onCloseDrawer }) => {
const { userStore } = useRootStore();
const history = useHistory(); const history = useHistory();
const onClickLink = useCallback( const onClickLink = useCallback(
(link: LinkType) => { (link: LinkType) => {
@ -30,16 +26,9 @@ const NavList: React.FC<NavListPropsType> = ({ links, onCloseDrawer }) => {
return ( return (
<> <>
{links.map(link => { {links.map(link => (
if (link.public) { <NavLink link={link} key={link.location} onClickLink={onClickLink} />
return <NavLink link={link} key={link.location} onClickLink={onClickLink} />; ))}
} else {
if (userStore.me && userHasRoles(userStore.me.roles, [UserRoleEnum.ADMIN])) {
return <NavLink link={link} key={link.location} onClickLink={onClickLink} />;
}
}
return null;
})}
</> </>
); );
}; };

View File

@ -21,13 +21,13 @@ const PrivateRoute: React.FC<IPrivateRouteProps> = ({ component: Component, titl
render={props => render={props =>
authStore.isLoggedIn ? ( authStore.isLoggedIn ? (
<ErrorBoundary FallbackComponent={ErrorFallback}> <ErrorBoundary FallbackComponent={ErrorFallback}>
<Layout> <Layout roles={roles}>
<Helmet> <Helmet>
<title> <title>
{title} • {config.metaData.appName}{' '} {title} • {config.metaData.appName}{' '}
</title> </title>
</Helmet> </Helmet>
<Component {...props} title={title} roles={roles} /> <Component {...props} title={title} />
</Layout> </Layout>
</ErrorBoundary> </ErrorBoundary>
) : ( ) : (

View File

@ -1,3 +1,4 @@
import { UserRoleEnum } from 'enums';
import { MdAccountCircle, MdBuild, MdCollectionsBookmark, MdHome, MdMessage } from 'react-icons/md'; import { MdAccountCircle, MdBuild, MdCollectionsBookmark, MdHome, MdMessage } from 'react-icons/md';
import { IAppConfig } from './interfaces'; import { IAppConfig } from './interfaces';
@ -7,11 +8,17 @@ const config: IAppConfig = {
appDescription: 'Learn management system', appDescription: 'Learn management system',
}, },
links: [ links: [
{ location: '/', title: 'Home', public: true, icon: MdHome }, { location: '/', title: 'Main', public: true, icon: MdHome },
{ location: '/courses', title: 'Courses', public: true, icon: MdCollectionsBookmark }, { location: '/courses', title: 'Courses', public: true, icon: MdCollectionsBookmark },
{ location: '/messages', title: 'Messages', public: true, icon: MdMessage }, { location: '/messages', title: 'Messages', public: true, icon: MdMessage },
{ location: '/profile', title: 'Profile', public: true, icon: MdAccountCircle }, { location: '/profile', title: 'Profile', public: true, icon: MdAccountCircle },
{ location: '/control', title: 'Control', public: false, icon: MdBuild }, {
location: '/control',
title: 'Control',
public: false,
roles: [UserRoleEnum.ADMIN, UserRoleEnum.TEACHER],
icon: MdBuild,
},
], ],
}; };

View File

@ -13,6 +13,17 @@ export function userHasRoles(userRoles: IUserRole[], roles: UserRoleEnum[]): boo
return true; return true;
} }
/**
* Return true if user roles contain any one
* of roles in second param
*
* @param userRoles Current user roles
* @param roles Roles to check
*/
export function userContainRoles(userRoles: IUserRole[], roles: UserRoleEnum[]): boolean {
return userRoles.map(r => r.slug).some(r => roles.includes(r));
}
/** /**
* Clean object values * Clean object values
* *

View File

@ -119,12 +119,14 @@ export interface IUserResult extends IDataResult {
export interface IPageProps extends RouteComponentProps { export interface IPageProps extends RouteComponentProps {
title?: string; title?: string;
roles?: UserRoleEnum[];
} }
export interface IPrivateRouteProps extends RouteProps { export interface IPrivateRouteProps extends RouteProps {
component: React.FC<IPageProps>; component: React.FC<IPageProps>;
title?: string; title?: string;
/**
* This field means who can access the page
*/
roles?: UserRoleEnum[]; roles?: UserRoleEnum[];
} }

View File

@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import Helmet from 'react-helmet'; import Helmet from 'react-helmet';
import { MdError } from 'react-icons/md'; import { MdError } from 'react-icons/md';
import { Flex, Box, Heading, Text, useToast } from '@chakra-ui/react'; import { Flex, Box, Heading, Text, useToast, Link } from '@chakra-ui/react';
import { Link as ReactRouterLink } from 'react-router-dom';
const NotFoundPage: React.FC = () => { const NotFoundPage: React.FC = () => {
const toast = useToast(); const toast = useToast();
@ -23,6 +24,9 @@ const NotFoundPage: React.FC = () => {
Page not found Page not found
</Heading> </Heading>
<Text mt='5'>Sorry the page you are looking for could not be found.</Text> <Text mt='5'>Sorry the page you are looking for could not be found.</Text>
<Link as={ReactRouterLink} to='/' color='blue.500'>
Return to main page
</Link>
</Box> </Box>
</> </>
); );

View File

@ -3,20 +3,11 @@ import { Box, Heading, Breadcrumb, BreadcrumbItem, BreadcrumbLink, Grid } from '
import { IPageProps } from 'interfaces'; import { IPageProps } from 'interfaces';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { useRootStore } from 'hooks/useRootStore';
import { userHasRoles } from 'helpers';
import { Redirect } from 'react-router-dom';
/** /**
* Control page * Control page
*/ */
const ControlPage: React.FC<IPageProps> = ({ title, roles }) => { const ControlPage: React.FC<IPageProps> = ({ title }) => {
const { userStore } = useRootStore();
if (userStore.me && roles && !userHasRoles(userStore.me.roles, roles)) {
return <Redirect to='/404' />;
}
return ( return (
<> <>
<Breadcrumb fontWeight='medium' fontSize='sm'> <Breadcrumb fontWeight='medium' fontSize='sm'>

View File

@ -1,9 +1,15 @@
import { UserRoleEnum } from 'enums';
import { IconType } from 'react-icons/lib'; import { IconType } from 'react-icons/lib';
export type LinkType = { export type LinkType = {
location: string; location: string;
title: string; title: string;
public: boolean; public: boolean;
/**
* Render link if user roles includes roles
* in config
*/
roles?: UserRoleEnum[];
icon: IconType; icon: IconType;
}; };