From 01caa8e89e8a3cfb42a4db52facc7f1d9c8cc318 Mon Sep 17 00:00:00 2001 From: EungBug Date: Tue, 27 Jun 2023 14:07:04 +0900 Subject: [PATCH 1/3] =?UTF-8?q?Feat:=20AccessToken=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/axios.ts | 27 +++++++++++++++++++++++++ src/components/Header.tsx | 32 +++++++++++++++++++----------- src/constants/errors.ts | 10 ++++++++++ src/constants/index.ts | 1 + src/types/CommonError.interface.ts | 4 ++++ src/types/index.ts | 1 + 6 files changed, 63 insertions(+), 12 deletions(-) create mode 100644 src/constants/errors.ts create mode 100644 src/types/CommonError.interface.ts diff --git a/src/api/axios.ts b/src/api/axios.ts index f62d5e2..76d845f 100644 --- a/src/api/axios.ts +++ b/src/api/axios.ts @@ -1,4 +1,6 @@ import axios, { AxiosError, AxiosInstance } from 'axios' +import { CommonError } from 'types/index' +import { networkErrors } from 'constants/index' const authInterceptors = (instance: AxiosInstance): AxiosInstance => { instance.interceptors.request.use( @@ -19,6 +21,31 @@ const authInterceptors = (instance: AxiosInstance): AxiosInstance => { } ) + instance.interceptors.response.use( + response => response, + (error: AxiosError): Promise | void => { + if ( + error.request?.responseURL && + error.request?.responseURL.includes('logout') + ) { + return Promise.reject(networkErrors.EXPIRE_TOKEN) + } + + if (error?.response?.status === 401) { + alert('로그인 세션이 만료되었습니다. 다시 로그인해주세요.') + location.replace('/signin') + // TODO 로그아웃 처리 + } else if (error?.response?.status === 500) { + return Promise.reject(networkErrors.SERVER_ERROR) + } else { + return Promise.reject({ + status: error?.response?.status, + message: error?.response?.data + }) + } + } + ) + return instance } diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 1404248..a3e58cd 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -4,6 +4,7 @@ import { LoginedUserContext, LoginContext, CartContext } from 'contexts/index' import React, { useState, useRef, useEffect, useContext } from 'react' import { logOut } from 'api/signApi' import styles from 'styles/layout/header.module.scss' +import { CommonError } from '@/types' //import { MyPageNav } from 'components/mypage' export const Header: React.FC = () => { @@ -55,19 +56,26 @@ export const Header: React.FC = () => { } }, [searchRef, hideInput]) + const handleLogout = () => { + setUserEmail('') + setUserCart([]) + localStorage.removeItem(import.meta.env.VITE_STORAGE_KEY_ACCESSTOKEN) + setIsLogined(!isLogined) + navigate('/') + } + const logOutId = () => { - // event.preventDefault() // 통합 테스트 때 확인 필요 - logOut().then(isSuccess => { - if (isSuccess) { - setUserEmail('') - setUserCart([]) - localStorage.removeItem(import.meta.env.VITE_STORAGE_KEY_ACCESSTOKEN) - setIsLogined(!isLogined) - navigate('/') - } else { - // 예외처리 - } - }) + logOut() + .then(isSuccess => { + if (isSuccess) { + handleLogout() + } + }) + .catch((error: CommonError) => { + if (error.status === 401) { + handleLogout() + } + }) } const onSearchEnter = (event: React.KeyboardEvent) => { diff --git a/src/constants/errors.ts b/src/constants/errors.ts new file mode 100644 index 0000000..d4a79c1 --- /dev/null +++ b/src/constants/errors.ts @@ -0,0 +1,10 @@ +export const networkErrors = { + EXPIRE_TOKEN: { + status: 401, + message: 'AccessToken 만료' + }, + SERVER_ERROR: { + status: 500, + message: '서버에 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' + } +} diff --git a/src/constants/index.ts b/src/constants/index.ts index b8cd9c6..53ae514 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1 +1,2 @@ export * from 'constants/tag' +export * from 'constants/errors' diff --git a/src/types/CommonError.interface.ts b/src/types/CommonError.interface.ts new file mode 100644 index 0000000..d8c7dfd --- /dev/null +++ b/src/types/CommonError.interface.ts @@ -0,0 +1,4 @@ +export interface CommonError { + status: number // Error status code + message: string // Error message +} diff --git a/src/types/index.ts b/src/types/index.ts index 8eaf89e..2939b40 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -14,3 +14,4 @@ export * from 'types/MyOrderItemProps.type' export * from 'types/Cart.interface' export * from 'types/MyWishItemProps.type' export * from 'types/BankAccounts.interface' +export * from 'types/CommonError.interface' From 7c42944175fc5f64fc7d651a8af3981dc812d6fb Mon Sep 17 00:00:00 2001 From: EungBug Date: Tue, 27 Jun 2023 14:53:53 +0900 Subject: [PATCH 2/3] =?UTF-8?q?Feat:=20Logout=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=A7=8C=EB=A3=8C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/axios.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/api/axios.ts b/src/api/axios.ts index 76d845f..d3f651a 100644 --- a/src/api/axios.ts +++ b/src/api/axios.ts @@ -26,7 +26,8 @@ const authInterceptors = (instance: AxiosInstance): AxiosInstance => { (error: AxiosError): Promise | void => { if ( error.request?.responseURL && - error.request?.responseURL.includes('logout') + error.request?.responseURL.includes('logout') && + error?.response?.status === 401 ) { return Promise.reject(networkErrors.EXPIRE_TOKEN) } From 2365c8d981301f3a3732a52a2204baa856ad001e Mon Sep 17 00:00:00 2001 From: EungBug Date: Tue, 27 Jun 2023 21:24:23 +0900 Subject: [PATCH 3/3] =?UTF-8?q?Feat:=20API=20=EA=B3=B5=ED=86=B5=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/axios.ts | 81 ++----------- src/components/App.tsx | 42 ++++++- src/components/Header.tsx | 2 +- src/components/admin/AdminPrivateRoute.tsx | 46 +++++++- src/components/mypage/MyWishItem.tsx | 1 - src/constants/errors.ts | 6 +- src/hooks/index.ts | 1 + src/hooks/useAxiosInterceptor.ts | 127 +++++++++++++++++++++ src/pages/SignInPage.tsx | 2 +- src/pages/SignUpPage.tsx | 2 +- src/types/CommonError.interface.ts | 3 +- 11 files changed, 229 insertions(+), 84 deletions(-) create mode 100644 src/hooks/useAxiosInterceptor.ts diff --git a/src/api/axios.ts b/src/api/axios.ts index d3f651a..1114b84 100644 --- a/src/api/axios.ts +++ b/src/api/axios.ts @@ -1,81 +1,16 @@ -import axios, { AxiosError, AxiosInstance } from 'axios' -import { CommonError } from 'types/index' -import { networkErrors } from 'constants/index' +import axios, { AxiosInstance } from 'axios' -const authInterceptors = (instance: AxiosInstance): AxiosInstance => { - instance.interceptors.request.use( - config => { - // 로컬스토리지에 저장 되어 있는 AccessToken을 가져온다. - const accessToken = localStorage.getItem( - import.meta.env.VITE_STORAGE_KEY_ACCESSTOKEN - ) - if (config.headers && accessToken) { - // AccessToken이 정상적으로 저장되어 있으면 headers에 Authorization에 값을 추가해준다. - config.headers.Authorization = `Bearer ${accessToken}` - } - // authorization을 추가한 config 반환 - return config - }, - (error: AxiosError): Promise => { - return Promise.reject(error) - } - ) - - instance.interceptors.response.use( - response => response, - (error: AxiosError): Promise | void => { - if ( - error.request?.responseURL && - error.request?.responseURL.includes('logout') && - error?.response?.status === 401 - ) { - return Promise.reject(networkErrors.EXPIRE_TOKEN) - } - - if (error?.response?.status === 401) { - alert('로그인 세션이 만료되었습니다. 다시 로그인해주세요.') - location.replace('/signin') - // TODO 로그아웃 처리 - } else if (error?.response?.status === 500) { - return Promise.reject(networkErrors.SERVER_ERROR) - } else { - return Promise.reject({ - status: error?.response?.status, - message: error?.response?.data - }) - } - } - ) - - return instance -} +axios.defaults.baseURL = import.meta.env.VITE_BASE_URL +axios.defaults.headers.common['apikey'] = import.meta.env.VITE_APIKEY +axios.defaults.headers.common['username'] = import.meta.env.VITE_USERNAME // Authorization 설정이 없는 일반 사용자 API용 Instance -export const baseInstance: AxiosInstance = axios.create({ - baseURL: import.meta.env.VITE_BASE_URL, - headers: { - apikey: import.meta.env.VITE_APIKEY, - username: import.meta.env.VITE_USERNAME - } -}) +export const baseInstance: AxiosInstance = axios.create() + // Authorization 설정이 추가된 로그인한 사용자 API용 Instance -export const authInstance: AxiosInstance = authInterceptors(baseInstance) +export const authInstance: AxiosInstance = baseInstance // 관리자 API용 Instance export const adminInstance: AxiosInstance = axios.create({ - baseURL: import.meta.env.VITE_BASE_URL, - headers: { - apikey: import.meta.env.VITE_APIKEY, - username: import.meta.env.VITE_USERNAME, - masterKey: true - } + headers: { masterKey: true } }) - -//////////////////////////////// -// * Axios Instance 기본 사용법 -// * 1. 필요한 instance import -// * 2. instance.method({API_PATH}, {필요한 경우 Body}) -// adminInstance.get('/products'); -// authInstance.post('/auth/me'); -// baseInstance.post('/auth/login', { email: '', password: ''}); -//////////////////////////////// diff --git a/src/components/App.tsx b/src/components/App.tsx index e39a7aa..783a689 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,6 +1,7 @@ +import { useState } from 'react' import { Outlet } from 'react-router-dom' -import { Header, Badge } from 'components/index' -import { Product } from 'types/index' +import { Header, Badge, Modal } from 'components/index' +import { CommonError, Product, ModalProps } from 'types/index' import { LoginContext, RecentlyContext, @@ -13,6 +14,7 @@ import { useSessionStorage, useCartLocalStorage } from 'hooks/index' +import { useAxiosInterceptor } from 'hooks/index' //App은 Outlet을 통해 슬래시로 페이지 경로 이동시의 최상위 컴포넌트로 설정했습니다 export const App = () => { @@ -28,6 +30,31 @@ export const App = () => { isLogined ) + const [isModalShow, setIsModalShow] = useState(false) + const [modalProps, setModalProps] = useState(null) + + const handleLogout = () => { + setIsLogined(false) + setUserEmail('') + setUserCart([]) + localStorage.removeItem(import.meta.env.VITE_STORAGE_KEY_ACCESSTOKEN) + } + + const handleErrorModal = (error: CommonError) => { + setIsModalShow(true) + setModalProps({ + title: '오류', + content: error.message, + isTwoButton: false, + okButtonText: '확인', + onClickOkButton: () => { + setIsModalShow(false) + } + }) + } + + useAxiosInterceptor(handleLogout, handleErrorModal) + return ( <> @@ -39,6 +66,17 @@ export const App = () => {
+ {isModalShow && modalProps ? ( + + ) : null} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index a3e58cd..3fce03d 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -73,7 +73,7 @@ export const Header: React.FC = () => { }) .catch((error: CommonError) => { if (error.status === 401) { - handleLogout() + navigate('/') } }) } diff --git a/src/components/admin/AdminPrivateRoute.tsx b/src/components/admin/AdminPrivateRoute.tsx index 7c5250b..b297419 100644 --- a/src/components/admin/AdminPrivateRoute.tsx +++ b/src/components/admin/AdminPrivateRoute.tsx @@ -1,7 +1,10 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useState, useContext } from 'react' import { Outlet, useNavigate } from 'react-router-dom' import { checkIsAdmin } from 'api/index' -import { AdminNav } from 'components/index' +import { AdminNav, Modal } from 'components/index' +import { CommonError, ModalProps } from 'types/index' +import { LoginContext, LoginedUserContext, CartContext } from 'contexts/index' +import { useAxiosInterceptor } from 'hooks/index' import styled from 'styles/pages/admin.module.scss' export const AdminPrivateRoute = () => { @@ -33,10 +36,49 @@ export const AdminPrivateRoute = () => { }) }, [moveSignIn, moveHome]) + const { setIsLogined } = useContext(LoginContext) + const { setUserEmail } = useContext(LoginedUserContext) + const { setUserCart } = useContext(CartContext) + const [isModalShow, setIsModalShow] = useState(false) + const [modalProps, setModalProps] = useState(null) + + const handleLogout = () => { + setIsLogined(false) + setUserEmail('') + setUserCart([]) + localStorage.removeItem(import.meta.env.VITE_STORAGE_KEY_ACCESSTOKEN) + } + + const handleErrorModal = (error: CommonError) => { + setIsModalShow(true) + setModalProps({ + title: '오류', + content: error.message, + isTwoButton: false, + okButtonText: '확인', + onClickOkButton: () => { + setIsModalShow(false) + } + }) + } + + useAxiosInterceptor(handleLogout, handleErrorModal) + return isAdmin ? (
+ {isModalShow && modalProps ? ( + + ) : null}
) : null } diff --git a/src/components/mypage/MyWishItem.tsx b/src/components/mypage/MyWishItem.tsx index a5a8eae..5419ffc 100644 --- a/src/components/mypage/MyWishItem.tsx +++ b/src/components/mypage/MyWishItem.tsx @@ -4,7 +4,6 @@ import { calculateDiscountedPrice } from 'utils/index' import styled from 'styles/components/mypage/myWishItem.module.scss' import { Link } from 'react-router-dom' import { WishListContext } from 'contexts/index' -import { on } from 'events' export const MyWishrItem = React.memo( ({ product, isLast, isChecked, onChange }: MyWishItemProps) => { diff --git a/src/constants/errors.ts b/src/constants/errors.ts index d4a79c1..71682cb 100644 --- a/src/constants/errors.ts +++ b/src/constants/errors.ts @@ -1,10 +1,12 @@ export const networkErrors = { EXPIRE_TOKEN: { status: 401, - message: 'AccessToken 만료' + message: 'AccessToken 만료', + isShowModal: false }, SERVER_ERROR: { status: 500, - message: '서버에 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' + message: '서버에 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + isShowModal: true } } diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 06d03d6..6ad8453 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -3,3 +3,4 @@ export * from 'hooks/useOnClickOutside' export * from 'hooks/useLocalStorage' export * from 'hooks/useSessionStorage' export * from 'hooks/useCartLocalStorage' +export * from 'hooks/useAxiosInterceptor' diff --git a/src/hooks/useAxiosInterceptor.ts b/src/hooks/useAxiosInterceptor.ts new file mode 100644 index 0000000..4c010d3 --- /dev/null +++ b/src/hooks/useAxiosInterceptor.ts @@ -0,0 +1,127 @@ +import { useEffect } from 'react' +import { AxiosError, InternalAxiosRequestConfig } from 'axios' +import { baseInstance, adminInstance } from 'api/index' +import { CommonError } from 'types/index' +import { networkErrors } from 'constants/index' + +export const useAxiosInterceptor = ( + handleLogout: () => void, + setErrorModal: (error: CommonError) => void +) => { + const accessToken = localStorage.getItem( + import.meta.env.VITE_STORAGE_KEY_ACCESSTOKEN + ) + + const apiErrorInterceptor = (error: AxiosError): Promise => { + const errorObj = { + status: error?.response?.status, + message: (error?.response?.data as string) ?? '', + isShowModal: false + } + setErrorModal(errorObj) + return Promise.reject(errorObj) + } + + const adminConfig = (config: InternalAxiosRequestConfig) => { + config.headers['masterKey'] = true + return config + } + + const authConfig = (config: InternalAxiosRequestConfig) => { + if (config.headers && accessToken) { + // AccessToken이 정상적으로 저장되어 있으면 headers에 Authorization에 값을 추가해준다. + config.headers.Authorization = `Bearer ${accessToken}` + } + // authorization을 추가한 config 반환 + return config + } + + const responseErrorInterceptor = ( + error: AxiosError + ): Promise | void => { + if (!accessToken) { + if (error?.response?.status === 500) { + return Promise.reject(networkErrors.SERVER_ERROR) + } else { + const errorObj = { + status: error?.response?.status, + message: (error?.response?.data as string) ?? '', + isShowModal: false + } + if (errorObj.isShowModal) { + setErrorModal(errorObj) + } + return Promise.reject(errorObj) + } + } else { + const showModal = !error.request?.responseURL.includes( + '/auth/login', + '/auth/signup' + ) + + if ( + error.request?.responseURL && + error.request?.responseURL.includes('logout') && + error?.response?.status === 401 + ) { + handleLogout() + return Promise.reject(networkErrors.EXPIRE_TOKEN) + } else if (error?.response?.status === 401) { + localStorage.removeItem(import.meta.env.VITE_STORAGE_KEY_ACCESSTOKEN) + alert('로그인 세션이 만료되었습니다. 다시 로그인해주세요.') + location.replace('/signin') + handleLogout() + return + } else if (error?.response?.status === 500) { + setErrorModal(networkErrors.SERVER_ERROR) + return + } else { + const errorObj = { + status: error?.response?.status, + message: (error?.response?.data as string) ?? '', + isShowModal: showModal + } + if (errorObj.isShowModal) { + setErrorModal(errorObj) + } + + return Promise.reject(errorObj) + } + } + } + + const adminRequestInterceptor = adminInstance.interceptors.request.use( + adminConfig, + apiErrorInterceptor + ) + + const requestInterceptor = baseInstance.interceptors.request.use( + authConfig, + apiErrorInterceptor + ) + + const adminResponseInterceptor = adminInstance.interceptors.response.use( + response => response, + responseErrorInterceptor + ) + + const responseInterceptor = baseInstance.interceptors.response.use( + response => response, + responseErrorInterceptor + ) + + useEffect(() => { + return () => { + // interceptor 해제 + baseInstance.interceptors.request.eject(requestInterceptor) + baseInstance.interceptors.response.eject(responseInterceptor) + adminInstance.interceptors.request.eject(adminRequestInterceptor) + adminInstance.interceptors.response.eject(adminResponseInterceptor) + } + }, [ + responseInterceptor, + requestInterceptor, + adminRequestInterceptor, + adminResponseInterceptor + ]) +} diff --git a/src/pages/SignInPage.tsx b/src/pages/SignInPage.tsx index 2ea4dd4..be966c6 100644 --- a/src/pages/SignInPage.tsx +++ b/src/pages/SignInPage.tsx @@ -71,7 +71,7 @@ export const SignInPage = () => { } }, error => { - const errorMessage = error.response.data + const errorMessage = error.message if (errorMessage === '유효한 사용자가 아닙니다.') { setIsModalShow(true) setModalProps({ diff --git a/src/pages/SignUpPage.tsx b/src/pages/SignUpPage.tsx index 42da776..30831af 100644 --- a/src/pages/SignUpPage.tsx +++ b/src/pages/SignUpPage.tsx @@ -67,7 +67,7 @@ export const SignUpPage = () => { }) }, error => { - const errorMessage = error.response.data + const errorMessage = error.message if ( errorMessage === '유효한 이메일이 아닙니다.' || errorMessage === '유효한 사용자 이름이 아닙니다.' || diff --git a/src/types/CommonError.interface.ts b/src/types/CommonError.interface.ts index d8c7dfd..d8d23ee 100644 --- a/src/types/CommonError.interface.ts +++ b/src/types/CommonError.interface.ts @@ -1,4 +1,5 @@ export interface CommonError { - status: number // Error status code + status: number | undefined // Error status code message: string // Error message + isShowModal: boolean }