diff --git a/src/api/axios.ts b/src/api/axios.ts index f62d5e2..1114b84 100644 --- a/src/api/axios.ts +++ b/src/api/axios.ts @@ -1,53 +1,16 @@ -import axios, { AxiosError, AxiosInstance } from 'axios' +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) - } - ) - - 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 1404248..3fce03d 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) { + navigate('/') + } + }) } const onSearchEnter = (event: React.KeyboardEvent) => { 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 new file mode 100644 index 0000000..71682cb --- /dev/null +++ b/src/constants/errors.ts @@ -0,0 +1,12 @@ +export const networkErrors = { + EXPIRE_TOKEN: { + status: 401, + message: 'AccessToken 만료', + isShowModal: false + }, + SERVER_ERROR: { + status: 500, + message: '서버에 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + isShowModal: true + } +} 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/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 new file mode 100644 index 0000000..d8d23ee --- /dev/null +++ b/src/types/CommonError.interface.ts @@ -0,0 +1,5 @@ +export interface CommonError { + status: number | undefined // Error status code + message: string // Error message + isShowModal: boolean +} 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'