feat: setup auth

This commit is contained in:
Sergey Yarkov 2021-08-22 23:53:42 +03:00
parent 05b79a0875
commit 8ef0d4f8f2
18 changed files with 412 additions and 90 deletions

View File

@ -1,21 +1,43 @@
import * as React from 'react'; import React from 'react';
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'; import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
import PrivateRoute from './components/PrivateRoute/PrivateRoute'; import PrivateRoute from './components/PrivateRoute/PrivateRoute';
import { ChakraProvider, theme } from '@chakra-ui/react'; import { useRootStore } from './hooks/useRootStore';
import { observer } from 'mobx-react';
/** /**
* Pages * Pages
*/ */
import MainPage from './pages/main'; import MainPage from './pages/main';
import AuthPage from './pages/auth'; import AuthPage from './pages/auth';
import NotFoundPage from './pages/404';
import { useToast } from '@chakra-ui/react';
import { autorun } from 'mobx';
export const App = () => ( const App = () => {
<ChakraProvider theme={theme}> const rootStore = useRootStore();
const toast = useToast();
React.useEffect(() => {
autorun(() => {
if (rootStore.networkApiError !== undefined) {
toast({
title: `Network error while connecting to the server.`,
description: 'Try your request later.',
status: 'error',
});
}
});
});
return (
<Router> <Router>
<Switch> <Switch>
<PrivateRoute exact path='/' component={MainPage} title='Main' /> <PrivateRoute exact path='/' component={MainPage} title='Main' />
<Route path='/auth' component={AuthPage} /> <Route path='/auth' component={AuthPage} />
<Route component={NotFoundPage} />
</Switch> </Switch>
</Router> </Router>
</ChakraProvider>
); );
};
export default observer(App);

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React from 'react';
import { MdAccountCircle, MdVpnKey } from 'react-icons/md'; import { MdAccountCircle, MdVpnKey } from 'react-icons/md';
import { import {
FormControl, FormControl,
@ -12,44 +12,39 @@ import {
import { useRootStore } from '../../hooks/useRootStore'; import { useRootStore } from '../../hooks/useRootStore';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { SubmitHandler, useForm } from 'react-hook-form';
type AuthFormState = { type AuthInputs = {
login: string; login: string;
password: string; password: string;
}; };
const AuthForm: React.FC = () => { const AuthForm: React.FC = () => {
const [authFormState, setAuthFormState] = useState<AuthFormState>({ const { register, reset, handleSubmit } = useForm<AuthInputs>();
login: '',
password: '',
});
const { authStore } = useRootStore(); const { authStore } = useRootStore();
const history = useHistory(); const history = useHistory();
const handleLoginForm = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const { login, password } = authFormState; const onSubmit: SubmitHandler<AuthInputs> = async (data) => {
await authStore.login(login, password); try {
await authStore.login(data.login, data.password);
history.push('/'); history.push('/');
} catch (error) {
console.log('error while log in ');
} finally {
reset();
}
}; };
return ( return (
<form onSubmit={handleLoginForm}> <form onSubmit={handleSubmit(onSubmit)}>
<FormControl isRequired={true}> <FormControl isRequired={true}>
<FormLabel>Login</FormLabel> <FormLabel>Login</FormLabel>
<InputGroup> <InputGroup>
<InputLeftElement children={<Icon as={MdAccountCircle} />} /> <InputLeftElement children={<Icon as={MdAccountCircle} />} />
<Input <Input
type='text' type='text'
name='login'
placeholder='email@mail.com' placeholder='email@mail.com'
value={authFormState.login} {...register('login', { required: true })}
onChange={(e) =>
setAuthFormState({
...authFormState,
login: e.target.value,
})
}
/> />
</InputGroup> </InputGroup>
</FormControl> </FormControl>
@ -59,20 +54,14 @@ const AuthForm: React.FC = () => {
<InputLeftElement children={<Icon as={MdVpnKey} />} /> <InputLeftElement children={<Icon as={MdVpnKey} />} />
<Input <Input
type='password' type='password'
name='password'
placeholder='********' placeholder='********'
value={authFormState.password} {...register('password', { required: true })}
onChange={(e) =>
setAuthFormState({
...authFormState,
password: e.target.value,
})
}
/> />
</InputGroup> </InputGroup>
</FormControl> </FormControl>
<Button <Button
loadingText='Logging in...' loadingText='Logging in...'
isLoading={authStore.loading}
type='submit' type='submit'
colorScheme='blue' colorScheme='blue'
variant='outline' variant='outline'
@ -81,7 +70,7 @@ const AuthForm: React.FC = () => {
Log in Log in
</Button> </Button>
</form> </form>
) );
} };
export default observer(AuthForm) export default observer(AuthForm);

View File

@ -0,0 +1,21 @@
import React from 'react';
import { Flex, Box, Text, Heading } from '@chakra-ui/react';
import { MdSchool } from 'react-icons/md';
const AppName: React.FC = () => {
return (
<Flex alignItems='center'>
<Box mr='4'>
<MdSchool fill='blue' size='36px' />
</Box>
<Box lineHeight='1'>
<Heading as='p' fontSize='2xl' color='gray.700'>
App name
</Heading>
<Text as='small'>App description</Text>
</Box>
</Flex>
);
};
export default AppName;

View File

@ -0,0 +1,37 @@
import React from 'react';
import { Flex, Box } from '@chakra-ui/react';
import AppName from './AppName';
import UserInfo from './User/UserInfo';
import UserNotifications from './User/UserNotifications';
const Header: React.FC = () => {
return (
<Box
as='header'
background='#fff'
position='fixed'
top={0}
left={0}
right={0}
borderBottomWidth={1}
width='100%'
height='4rem'
zIndex={4}>
<Flex
justifyContent='space-between'
width='100%'
height='100%'
alignItems='center'
paddingLeft='1.5rem'
paddingRight='1.5rem'>
<AppName />
<Flex>
<UserNotifications />
<UserInfo />
</Flex>
</Flex>
</Box>
);
};
export default Header;

View File

@ -0,0 +1,67 @@
import React from 'react';
import {
Menu,
MenuGroup,
MenuList,
MenuDivider,
MenuButton,
MenuItem,
Button,
Flex,
Box,
Avatar,
Text,
Link,
} from '@chakra-ui/react';
import { MdSettings, MdExitToApp } from 'react-icons/md';
import { observer } from 'mobx-react';
import { useRootStore } from '../../../hooks/useRootStore';
import { useHistory } from 'react-router-dom';
const UserInfo: React.FC = () => {
const { authStore } = useRootStore();
const history = useHistory();
const onLogoutHandler = async (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
try {
await authStore.logout();
history.push('/auth');
} catch (error) {
console.error(error);
}
};
return (
<Flex alignItems='center'>
<Menu>
<MenuButton as={Button} pr={6}>
<Flex alignItems='center'>
<Avatar size='sm' name='user name' marginRight={3} />
<Text as='span' mr={2}>
Username
</Text>
</Flex>
</MenuButton>
<MenuList mr='1rem'>
<MenuGroup title='User fullname'>
<MenuDivider />
<Link to='/profile'>
<MenuItem>
<Box as={MdSettings} mr={2} />
My profile
</MenuItem>
</Link>
<MenuItem onClick={onLogoutHandler}>
<Box as={MdExitToApp} mr={2} />
Logout
</MenuItem>
</MenuGroup>
</MenuList>
</Menu>
</Flex>
);
};
export default observer(UserInfo);

View File

@ -0,0 +1,31 @@
import React from 'react';
import { Box } from '@chakra-ui/layout';
import { Button } from '@chakra-ui/button';
import { MdNotifications } from 'react-icons/md';
const UserNotifications: React.FC = () => {
return (
<Button
marginRight={3}
variant='solid'
outline='none'
border='none'
_after={{
content: '"2"',
display: 'block',
position: 'absolute',
top: '8px',
right: '10px',
background: '#E53E3E',
color: '#fff',
fontSize: '11px',
width: '15px',
height: '15px',
borderRadius: '100%',
}}>
<Box as={MdNotifications} boxSize='21px' />
</Button>
);
};
export default UserNotifications;

View File

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { Flex, Box } from '@chakra-ui/react'; import { Flex, Box } from '@chakra-ui/react';
import Header from '../Header/Header';
/** /**
* *
@ -8,6 +9,8 @@ import { Flex, Box } from '@chakra-ui/react';
*/ */
const Layout: React.FC = ({ children }) => { const Layout: React.FC = ({ children }) => {
return ( return (
<>
<Header />
<Flex minH='100vh' flexDirection='column'> <Flex minH='100vh' flexDirection='column'>
<Box pl='18rem' mt='4rem' mb='4rem' flex='1 0 auto'> <Box pl='18rem' mt='4rem' mb='4rem' flex='1 0 auto'>
<Box <Box
@ -23,6 +26,7 @@ const Layout: React.FC = ({ children }) => {
</Box> </Box>
</Box> </Box>
</Flex> </Flex>
</>
); );
}; };

View File

@ -1,7 +1,7 @@
import { ColorModeScript } from '@chakra-ui/react'; import { ChakraProvider, ColorModeScript, theme } from '@chakra-ui/react';
import * as React from 'react'; import * as React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { App } from './App'; import App from './App';
import { RootStoreProvider } from './providers'; import { RootStoreProvider } from './providers';
import reportWebVitals from './reportWebVitals'; import reportWebVitals from './reportWebVitals';
@ -9,7 +9,9 @@ ReactDOM.render(
<> <>
<ColorModeScript /> <ColorModeScript />
<RootStoreProvider> <RootStoreProvider>
<ChakraProvider theme={theme}>
<App /> <App />
</ChakraProvider>
</RootStoreProvider> </RootStoreProvider>
</>, </>,
document.getElementById('root') document.getElementById('root')

26
src/pages/404.tsx Normal file
View File

@ -0,0 +1,26 @@
import React from 'react';
import Helmet from 'react-helmet';
import { Link } from 'react-router-dom';
import { Box, Heading, Button } from '@chakra-ui/react';
const NotFoundPage: React.FC = () => {
return (
<>
<Helmet>
<title>Error 404</title>
</Helmet>
<Box textAlign='center' mt={40}>
<Heading display='block' as='h1' fontSize='6xl'>
Error 404
</Heading>
<Link to='/'>
<Button variant='link' colorScheme='blue'>
Return to main page
</Button>
</Link>
</Box>
</>
);
};
export default NotFoundPage;

View File

@ -1,11 +1,8 @@
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import React from 'react';
import {
Flex,
Box,
} from '@chakra-ui/react';
import { MdSchool } from "react-icons/md";
import Helmet from 'react-helmet'; import Helmet from 'react-helmet';
import React, { useEffect } from 'react';
import { Flex, Box } from '@chakra-ui/react';
import { MdSchool } from 'react-icons/md';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { useRootStore } from '../hooks/useRootStore'; import { useRootStore } from '../hooks/useRootStore';
import { IPageProps } from '../interfaces'; import { IPageProps } from '../interfaces';
@ -19,19 +16,24 @@ const AuthPage: React.FC<IPageProps> = () => {
const { authStore } = useRootStore(); const { authStore } = useRootStore();
const history = useHistory(); const history = useHistory();
useEffect(() => {
if (authStore.isLoggedIn) { if (authStore.isLoggedIn) {
history.push('/'); history.push('/');
} }
});
return ( return (
<> <>
<Helmet> <Helmet>
<title>Auth</title> <title>Auth</title>
</Helmet> </Helmet>
<Flex minHeight='100vh' align='center' justifyContent='center'> <Flex
minHeight='100vh'
align='center'
justifyContent='center'
padding='5'>
<Box <Box
p={8} p={8}
my={40}
width='full' width='full'
maxWidth='450px' maxWidth='450px'
borderWidth={1} borderWidth={1}
@ -41,7 +43,7 @@ const AuthPage: React.FC<IPageProps> = () => {
<MdSchool fill='blue' size='64px' /> <MdSchool fill='blue' size='64px' />
</Flex> </Flex>
</Box> </Box>
<Box my='30px'> <Box>
<AuthForm /> <AuthForm />
</Box> </Box>
</Box> </Box>

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect } from 'react';
import { import {
Box, Box,
Heading, Heading,
@ -9,11 +9,22 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { IPageProps } from '../interfaces'; import { IPageProps } from '../interfaces';
import { useRootStore } from '../hooks/useRootStore';
import { useHistory } from 'react-router-dom';
/** /**
* Main page * Main page
*/ */
const MainPage: React.FC<IPageProps> = ({ title }) => { const MainPage: React.FC<IPageProps> = ({ title }) => {
const { authStore } = useRootStore();
const history = useHistory();
useEffect(() => {
if (!authStore.isLoggedIn) {
history.push('/auth');
}
});
return ( return (
<> <>
<Breadcrumb fontWeight='medium' fontSize='sm'> <Breadcrumb fontWeight='medium' fontSize='sm'>

View File

@ -2,12 +2,13 @@ import React, { ReactNode } from 'react';
import { StoreContext } from '../contexts'; import { StoreContext } from '../contexts';
import RootStore from '../stores/RootStore'; import RootStore from '../stores/RootStore';
let store: RootStore;
/** /**
* Root store provider * Root store provider
*/ */
export const RootStoreProvider = ({ children }: { children: ReactNode }) => { export const RootStoreProvider = ({ children }: { children: ReactNode }) => {
const root = store ?? new RootStore(); const store = new RootStore();
return <StoreContext.Provider value={root}>{children}</StoreContext.Provider>;
return (
<StoreContext.Provider value={store}>{children}</StoreContext.Provider>
);
}; };

View File

@ -20,15 +20,29 @@ export default class ApiService {
* Response * Response
*/ */
this.api.interceptors.response.use(undefined, (error) => { this.api.interceptors.response.use(undefined, (error) => {
if (error.response) {
/** /**
* Unauthorized * Internal Server Error 500
*/ */
if ( if (error.response.status === 500) {
error.response.status === 401 || }
error.response.data.message === '401 Unauthorized'
) { /**
* Unauthorized Error 401
*/
if (error.response.status === 401) {
if (this.root.authStore.isLoggedIn) {
this.root.authStore.setIsLoggedIn(false); this.root.authStore.setIsLoggedIn(false);
} }
}
/* Clear error state from root */
if (this.root.networkApiError !== undefined) {
this.root.setNetworkApiError(undefined);
}
} else {
this.root.setNetworkApiError(error);
}
return Promise.reject(error); return Promise.reject(error);
}); });
} }

View File

@ -1,7 +1,7 @@
import ApiService from './ApiService';
import { IDataResult } from '../interfaces'; import { IDataResult } from '../interfaces';
import { AxiosInstance } from 'axios';
interface ILoginResult extends IDataResult { export interface ILoginResult extends IDataResult {
data: { data: {
token: string; token: string;
type: string; type: string;
@ -10,21 +10,39 @@ interface ILoginResult extends IDataResult {
} }
export default class AuthService { export default class AuthService {
public apiService: ApiService; public api: AxiosInstance;
constructor(apiService: ApiService) { constructor(api: AxiosInstance) {
this.apiService = apiService; this.api = api;
} }
/**
* Create a new token
*
* @param login User login
* @param password User password
* @returns Login result
*/
public async requestLogin( public async requestLogin(
login: string, login: string,
password: string password: string
): Promise<ILoginResult> { ): Promise<ILoginResult> {
const result = await this.apiService.api.post('v1/auth/login', { const result = await this.api.post('v1/auth/login', {
login, login,
password, password,
}); });
return result.data; return result.data;
} }
/**
* Revoke token
*
* @returns Logout result
*/
public async requestLogout(): Promise<any> {
const result = await this.api.post('v1/auth/logout');
return result.data;
}
} }

View File

@ -0,0 +1,15 @@
import { AxiosInstance } from 'axios';
export default class UserService {
public api: AxiosInstance;
constructor(api: AxiosInstance) {
this.api = api;
}
public async fetchMe(): Promise<any> {
const result = await this.api.get('v1/me');
return result.data;
}
}

View File

@ -9,6 +9,8 @@ export default class AuthStore {
public isLoggedIn: boolean = !!Cookies.get('logged_in'); public isLoggedIn: boolean = !!Cookies.get('logged_in');
public loading: boolean = false;
constructor(root: RootStore) { constructor(root: RootStore) {
this.root = root; this.root = root;
makeAutoObservable(this); makeAutoObservable(this);
@ -19,17 +21,44 @@ export default class AuthStore {
this.isLoggedIn = value; this.isLoggedIn = value;
} }
public setLoading(value: boolean) {
this.loading = value;
}
public setToken(token: string) { public setToken(token: string) {
Cookies.set('logged_in', 'true'); Cookies.set('logged_in', 'true');
this.token = token; this.token = token;
} }
public async login(login: string, password: string) { public async login(login: string, password: string) {
try {
this.setLoading(true);
const result = await this.root.authService.requestLogin(login, password); const result = await this.root.authService.requestLogin(login, password);
this.setIsLoggedIn(true); this.setIsLoggedIn(true);
this.setToken(result.data.token); this.setToken(result.data.token);
return result;
} catch (error) {
console.error(error);
throw error;
} finally {
this.setLoading(false);
}
} }
public logout() {} public async logout() {
try {
this.setLoading(true);
const result = await this.root.authService.requestLogout();
this.setIsLoggedIn(false);
return result;
} catch (error) {
console.error(error);
throw error;
} finally {
this.setLoading(false);
}
}
} }

View File

@ -3,10 +3,16 @@ import AuthStore from './AuthStore';
import ApiService from '../services/ApiService'; import ApiService from '../services/ApiService';
import AuthService from '../services/AuthService'; import AuthService from '../services/AuthService';
import UserStore from './UserStore';
import { makeAutoObservable } from 'mobx';
export default class RootStore { export default class RootStore {
public networkApiError: any;
public authStore: AuthStore; public authStore: AuthStore;
public userStore: UserStore;
public apiService: ApiService; public apiService: ApiService;
public authService: AuthService; public authService: AuthService;
@ -16,6 +22,7 @@ export default class RootStore {
* Stores * Stores
*/ */
this.authStore = new AuthStore(this); this.authStore = new AuthStore(this);
this.userStore = new UserStore(this);
/** /**
* Services * Services
@ -23,7 +30,13 @@ export default class RootStore {
this.apiService = new ApiService(this, { this.apiService = new ApiService(this, {
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
responseType: 'json', responseType: 'json',
withCredentials: true,
}); });
this.authService = new AuthService(this.apiService); this.authService = new AuthService(this.apiService.api);
makeAutoObservable(this);
}
public setNetworkApiError(error: any) {
this.networkApiError = error;
} }
} }

20
src/stores/UserStore.ts Normal file
View File

@ -0,0 +1,20 @@
import { makeAutoObservable } from 'mobx';
import RootStore from './RootStore';
export interface IUser {
id: string;
first_name: string;
last_name: string;
email: string;
}
export default class UserStore {
public root: RootStore;
public user: IUser | null = null;
constructor(root: RootStore) {
this.root = root;
makeAutoObservable(this);
}
}