add authorization

This commit is contained in:
2021-07-27 12:09:07 +03:00
parent ff1f6a8b43
commit 96b571d09e
15 changed files with 382 additions and 41 deletions

View File

@ -7,7 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site created by cool programmers" />
<title>Crypto bot: dev</title>
<title>Crypto bot: Admin</title>
</head>
<body>

View File

@ -0,0 +1,99 @@
import {Button, Card, Form, Input, Layout} from 'antd';
import {useForm} from 'antd/lib/form/Form';
import {AxiosError} from 'axios';
import React, {FC, memo, useCallback, useState} from 'react';
import {createUseStyles} from 'react-jss';
import authServiceApi from '../../../core/infrastructure/AuthServiceAPI';
type LoginModel = {
login: string;
password: string;
};
const useStyles = createUseStyles({
input: {
marginBottom: '16px'
},
layout: {
height: '100%'
},
content: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center'
},
});
const formItemLayout = {
labelCol: {
xs: {span: 24},
sm: {span: 8}
},
wrapperCol: {
xs: {span: 24},
sm: {span: 16}
}
};
const LoginLayout: FC = () => {
const classes = useStyles();
const [disabled, setDisabled] = useState(false);
const [form] = useForm<LoginModel>();
const handleSubmit = useCallback(() => {
const {login, password} = form.getFieldsValue();
setDisabled(true);
authServiceApi.auth(login, password).catch((error: AxiosError) => {
const message = error.response?.data.message;
const errors = message ? [message] : ['Some error'];
const validationParams = {
validating: false,
errors
};
form.setFields([
{...validationParams, name: 'login'},
{...validationParams, name: 'password'}
]);
setDisabled(false);
});
}, [form]);
return (
<Layout className={classes.layout}>
<Layout.Content className={classes.content}>
<Card title="Admin Panel" style={{width: 300}}>
<Form {...formItemLayout} form={form} onFinish={handleSubmit}>
<Form.Item
hasFeedback
className={classes.input}
name="login"
label="Login:"
rules={[{required: true}]}
>
<Input disabled={disabled} />
</Form.Item>
<Form.Item
hasFeedback
className={classes.input}
name="password"
label="Password:"
rules={[{required: true}]}
>
<Input.Password disabled={disabled} />
</Form.Item>
<Form.Item wrapperCol={{offset: 8, span: 16}}>
<Button htmlType="submit" disabled={disabled}>
Sign in
</Button>
</Form.Item>
</Form>
</Card>
</Layout.Content>
</Layout>
);
};
export default memo(LoginLayout);

View File

@ -0,0 +1 @@
export {default} from './LoginLayout';

View File

@ -1,20 +1,42 @@
import {Layout} from 'antd';
import {Button, Layout} from 'antd';
import React, {FC, memo, PropsWithChildren} from 'react';
import {createUseStyles} from 'react-jss';
import authServiceApi from '../../../core/infrastructure/AuthServiceAPI';
import Menu from '../menu';
const useStyles = createUseStyles({
layout: {
height: '100%',
},
container: {
height: '100%',
display: 'flex',
flexDirection: 'column',
},
buttonContainer: {
margin: 'auto 16px 16px 16px',
},
button: {
width: '100%',
}
});
const handleSignOut = () => {
authServiceApi.signOut();
};
const MainLayout: FC<PropsWithChildren<unknown>> = ({children}) => {
const classes = useStyles();
return (
<Layout className={classes.layout}>
<Layout.Sider>
<Menu />
<div className={classes.container}>
<Menu />
<div className={classes.buttonContainer}>
<Button type="primary" className={classes.button} onClick={handleSignOut}>Sign out</Button>
</div>
</div>
</Layout.Sider>
<Layout>
<Layout.Content>

View File

@ -1,6 +1,6 @@
import React, {memo} from 'react';
import React, {FC, Fragment, memo, useEffect} from 'react';
import {Route, Switch} from 'react-router-dom';
import {context} from '@reatom/react';
import {context, useAtom} from '@reatom/react';
import mainPageRouter from '_pages/main/routing';
import usersPageRouter from '_pages/users/routing';
import actionsPageRouter from '_pages/actions/routing';
@ -13,6 +13,9 @@ import jss from 'jss';
import preset from 'jss-preset-default';
import {store} from '../../../core/infrastructure/atom/store';
import ConnectedRouter from '../../../core/blocks/connected-router/ConnectedRouter';
import {authAtom} from '../../../core/infrastructure/atom/authAtom';
import LoginLayout from '../login-layout';
import authServiceApi from '../../../core/infrastructure/AuthServiceAPI';
jss.setup(preset());
@ -33,10 +36,11 @@ const styles = {
jss.createStyleSheet(styles).attach();
const Page: React.FC = () => {
const SecurityRoutes: FC = memo(() => {
const isAuth = useAtom(authAtom);
return (
<context.Provider value={store}>
<ConnectedRouter>
<Fragment>
{isAuth && (
<MainLayout>
<Switch>
{mainPageRouter}
@ -50,6 +54,23 @@ const Page: React.FC = () => {
</Route>
</Switch>
</MainLayout>
)}
{!isAuth && (
<LoginLayout />
)}
</Fragment>
);
});
const Page: FC = () => {
useEffect(() => {
authServiceApi.refresh();
});
return (
<context.Provider value={store}>
<ConnectedRouter>
<SecurityRoutes />
</ConnectedRouter>
</context.Provider>
);

View File

@ -0,0 +1,50 @@
import axios from 'axios';
import {bindedActions as authActions} from './atom/authAtom';
import {tokenAPI} from './TokenAPI';
type AuthResponse = {
access_token: string;
refresh_token: string;
};
const ENDPOINT = 'https://api.crypto-bot.vigdorov.ru/auth';
class AuthServiceApi {
auth = (login: string, password: string) => {
return axios.post<AuthResponse>(`${ENDPOINT}/auth-user`, {
login,
password,
}).then(({data: {access_token, refresh_token}}) => {
tokenAPI.setTokens(access_token, refresh_token);
authActions.changeAuth(true);
});
}
refresh = () => {
const refreshToken = tokenAPI.getRefreshToken();
if (refreshToken) {
return axios.post<AuthResponse>(`${ENDPOINT}/refresh-tokens`, {refresh_token: refreshToken})
.then(({data: {access_token, refresh_token}}) => {
tokenAPI.setTokens(access_token, refresh_token);
authActions.changeAuth(true);
})
.catch(e => {
tokenAPI.clearTokents();
authActions.changeAuth(false);
throw e;
});
}
return Promise.reject(new Error('Не авторизован'));
}
signOut = () => {
tokenAPI.clearTokents();
authActions.changeAuth(false);
}
}
const authServiceApi = new AuthServiceApi();
export default authServiceApi;

View File

@ -1,4 +1,5 @@
import axios, {AxiosRequestConfig} from 'axios';
import {AxiosRequestConfig} from 'axios';
import httpAuthApi from './HttpAuthAPI';
enum Method {
Get = 'get',
@ -16,7 +17,7 @@ type RequestConfig<Q, B> = Omit<AxiosRequestConfig, 'params' | 'data'> & {
};
const requestMiddleware = async <Q, B, R>(config: RequestConfig<Q, B>): Promise<R> => {
const axiosResponse = await axios.request<R>(config);
const axiosResponse = await httpAuthApi.request<R>(config);
// Добавить обработку ошибок
return axiosResponse.data;
};

View File

@ -0,0 +1,92 @@
import axios, {AxiosError, AxiosRequestConfig, AxiosResponse} from 'axios';
import authServiceApi from './AuthServiceAPI';
import {tokenAPI} from './TokenAPI';
type HandlerArgs<T> = {
resolve: (v: AxiosResponse<T>) => void,
reject: (reason?: any) => void,
requestConfig: AxiosRequestConfig
error?: AxiosError<T>,
}
type Handler<T> = (args: HandlerArgs<T>) => void;
class HttpAuthApi {
pendingRequests: HandlerArgs<any>[];
isPendingRefresh: boolean;
constructor() {
this.pendingRequests = [];
this.isPendingRefresh = false;
}
processPendingRequests = (handler: Handler<unknown>) => {
this.isPendingRefresh = false;
while (this.pendingRequests.length) {
const pendingRequest = this.pendingRequests.shift()!;
handler(pendingRequest);
}
}
repeatRequests = () => {
this.processPendingRequests(({resolve, reject, requestConfig}) => {
this.request(requestConfig)
.then(resolve)
.catch(reject);
});
}
rejectRequests = (error: AxiosError) => {
this.processPendingRequests(({reject}) => {
reject(error);
});
}
errorHandling = (
error: AxiosError<unknown>,
resolve: (v: AxiosResponse<any>) => void,
reject: (reason?: any) => void,
requestConfig: AxiosRequestConfig
) => {
if (error.response?.status === 401) {
this.isPendingRefresh = true;
this.pendingRequests.push({resolve, reject, requestConfig});
authServiceApi.refresh()
.then(() => {
this.repeatRequests();
})
.catch((refreshError: AxiosError) => {
this.rejectRequests(refreshError);
tokenAPI.clearTokents();
location.reload();
});
} else {
reject(error);
}
}
addTokenToRequest = (requestConfig: AxiosRequestConfig): AxiosRequestConfig => ({
...requestConfig,
headers: {
...(requestConfig.headers ?? {}),
'Authorization': tokenAPI.getAccessToken(),
},
})
request = <T>(requestConfig: AxiosRequestConfig): Promise<AxiosResponse<T>> => {
return new Promise((resolve, reject) => {
if (this.isPendingRefresh) {
this.pendingRequests.push({resolve, reject, requestConfig});
} else {
axios(this.addTokenToRequest(requestConfig))
.then(response => resolve(response))
.catch(error => this.errorHandling(error, resolve, reject, requestConfig));
}
});
}
}
const httpAuthApi = new HttpAuthApi();
export default httpAuthApi;

View File

@ -0,0 +1,35 @@
export class StorageAPI<T> {
private _init: T;
private _storage: Storage;
private _stateName: string;
constructor(init: T, stateName: string, storageType: 'local' | 'session' = 'local') {
this._init = init;
this._stateName = stateName;
this._storage = storageType === 'local' ? localStorage : sessionStorage;
if (!this._storage.getItem(stateName)) {
this.set(this._init);
}
}
set(updatedState: T) {
this._storage.setItem(this._stateName, JSON.stringify(updatedState));
}
get(): T {
const stringValue = this._storage.getItem(this._stateName) || '';
try {
return JSON.parse(stringValue);
} catch (e) {
return this._init;
}
}
clear() {
this._storage.removeItem(this._stateName);
}
}

View File

@ -0,0 +1,32 @@
import {StorageAPI} from './StorageAPI';
class TokenAPI {
private accessTokenAPI: StorageAPI<string>;
private refreshTokenAPI: StorageAPI<string>;
constructor() {
this.accessTokenAPI = new StorageAPI('', 'access', 'session');
this.refreshTokenAPI = new StorageAPI('', 'refresh', 'local');
}
setTokens(accessToken: string, refreshToken: string) {
this.accessTokenAPI.set(accessToken);
this.refreshTokenAPI.set(refreshToken);
}
getAccessToken() {
return this.accessTokenAPI.get();
}
getRefreshToken() {
return this.refreshTokenAPI.get();
}
clearTokents() {
this.accessTokenAPI.clear();
this.refreshTokenAPI.clear();
}
}
export const tokenAPI = new TokenAPI();

View File

@ -0,0 +1,14 @@
import {declareAction, declareAtom} from '@reatom/core';
import {store} from './store';
const authAction = declareAction<boolean>();
export const authAtom = declareAtom(false, on => [
on(authAction, (_state, payload) => payload),
]);
export const bindedActions = {
changeAuth: (isAuth: boolean) => {
store.dispatch(authAction(isAuth));
},
};

View File

@ -1,7 +0,0 @@
import {declareAction, declareAtom} from '@reatom/core';
export const changeNameAction = declareAction<string>();
export const nameAtom = declareAtom('', on => [
on(changeNameAction, (_state, payload) => payload),
]);

View File

@ -1,21 +0,0 @@
export const makeLocalStorageService = <T>(init: T, stateName: string) => {
if (!localStorage.getItem(stateName)) {
localStorage.setItem(stateName, JSON.stringify(init));
}
return {
set: (updatedState: T) => {
localStorage.setItem(stateName, JSON.stringify(updatedState));
return updatedState;
},
get: (): T => {
const stringValue = localStorage.getItem(stateName) || '';
try {
return JSON.parse(stringValue);
} catch (e) {
return init;
}
},
};
};

View File

@ -16,7 +16,7 @@ describe('jsonParse', () => {
});
it('Должен вернуть undefined для не корректных значений', () => {
expect(jsonParse()).toBeUndefined();
expect(jsonParse(undefined)).toBeUndefined();
expect(jsonParse('')).toBeUndefined();
expect(jsonParse(' ')).toBeUndefined();
expect(jsonParse('{"9')).toBeUndefined();

View File

@ -1,4 +1,6 @@
export const jsonParse = <T>(str?: string, defaultValue?: T): Undefinable<T> => {
export function jsonParse<T>(str: Undefinable<string>, defaultValue: T): T;
export function jsonParse<T>(str: Undefinable<string>, defaultValue?: T): Undefinable<T>;
export function jsonParse<T>(str: Undefinable<string>, defaultValue?: T) {
const trimStr = str?.trim();
try {
const parsedValue = JSON.parse(trimStr ?? '');
@ -7,4 +9,4 @@ export const jsonParse = <T>(str?: string, defaultValue?: T): Undefinable<T> =>
} catch (e) {
return defaultValue;
}
};
}