From 4f4ce937b62fb4b5ccd0d51be635a8c5d85c9fb4 Mon Sep 17 00:00:00 2001 From: kwonjeong Date: Tue, 18 Feb 2025 00:30:25 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[Feat]=20#171=20-=20loginStore=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EB=A7=8C=EB=A3=8C=20=EC=8B=9C=20=EC=9E=AC=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/store/LoginStore.js | 51 ----------------------------- src/store/LoginStore.ts | 71 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 51 deletions(-) delete mode 100644 src/store/LoginStore.js create mode 100644 src/store/LoginStore.ts diff --git a/src/store/LoginStore.js b/src/store/LoginStore.js deleted file mode 100644 index 804ed1f..0000000 --- a/src/store/LoginStore.js +++ /dev/null @@ -1,51 +0,0 @@ -import { create } from "zustand"; -import { persist } from "zustand/middleware"; -import { ACCESS_TOKEN_KEY, GRANT_TYPE } from "@/shared/constant/api"; -import { axiosInstance } from "@/shared/api/instance"; - -export const useLoginStore = create( - persist( - (set, get) => ({ - isLogin: false, - email: null, - nickname: null, - memberId: null, - isLoading: true, - - // 로그인 설정 - setLogin: (memberId, email, nickname) => { - set({ isLogin: true, memberId, email, nickname }); - console.log("로그인 성공:", { memberId, email, nickname }); - }, - - // 로그아웃 처리 - setLogout: () => { - set({ - isLogin: false, - email: null, - nickname: null, - memberId: null, - }); - console.log("로그아웃됨"); - }, - - // 로그인 상태 초기화 - initializeLoginState: async () => { - const accessToken = localStorage.getItem(ACCESS_TOKEN_KEY); - const isLoggedIn = !!accessToken; // 토큰이 존재하면 로그인 상태로 설정 - - if (isLoggedIn) { - const grantType = localStorage.getItem(GRANT_TYPE); - axiosInstance.defaults.headers.Authorization = `${grantType} ${accessToken}`; - } - set({ isLoading: false, isLogin: isLoggedIn }); - console.log( - `로그인 상태 복구: ${isLoggedIn ? "로그인됨" : "로그아웃됨"}` - ); - }, - }), - { - name: "userInfoStorage", - } - ) -); diff --git a/src/store/LoginStore.ts b/src/store/LoginStore.ts new file mode 100644 index 0000000..da49da6 --- /dev/null +++ b/src/store/LoginStore.ts @@ -0,0 +1,71 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { ACCESS_TOKEN_KEY, GRANT_TYPE, MEMBER_ID } from "@/shared/constant/api"; +import { axiosInstance } from "@/shared/api/instance"; + +// 상태 타입 정의 +interface LoginState { + isLogin: boolean; + email: string | null; + nickname: string | null; + memberId: number | null; + isLoading: boolean; + setLogin: (memberId: number, email: string, nickname: string) => void; + setLogout: () => void; + initializeLoginState: () => Promise; +} + +export const useLoginStore = create()( + persist( + (set) => ({ + isLogin: false, + email: null, + nickname: null, + memberId: null, + isLoading: true, + + // 로그인 설정 + setLogin: (memberId, email, nickname) => { + console.log("🔵 setLogin 호출됨", { memberId, email, nickname }); + set({ isLogin: true, memberId, email, nickname }); + }, + + // 로그아웃 설정 + setLogout: () => { + console.log("🔴 setLogout 호출됨"); + set({ isLogin: false, email: null, nickname: null, memberId: null }); + + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(GRANT_TYPE); + localStorage.removeItem(MEMBER_ID); + }, + + // 로그인 상태 초기화 + initializeLoginState: async () => { + const accessToken = localStorage.getItem(ACCESS_TOKEN_KEY); + const memberId = localStorage.getItem(MEMBER_ID); + const isLoggedIn = !!accessToken; // 토큰이 존재하면 로그인 상태로 설정 + + if (isLoggedIn) { + const grantType = localStorage.getItem(GRANT_TYPE); + axiosInstance.defaults.headers.Authorization = `${grantType} ${accessToken}`; + } + + set({ + isLoading: false, + isLogin: isLoggedIn, + memberId: memberId ? Number(memberId) : null, // String → Number 변환 추가 + }); + + console.log( + `로그인 상태 복구: ${ + isLoggedIn ? `로그인됨 (memberId: ${memberId})` : "로그아웃됨" + }` + ); + }, + }), + { + name: "userInfoStorage", + } + ) +); From 488f82dd7fe7ea19b34ab15d142aec8257ba20e1 Mon Sep 17 00:00:00 2001 From: kwonjeong Date: Tue, 18 Feb 2025 00:30:53 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[Feat]=20#171=20-=20access=20token=20?= =?UTF-8?q?=EB=A7=8C=EB=A3=8C=20=EC=8B=9C=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/api/instance.ts | 43 ++++++++++++++++++--- src/shared/api/interceptor.ts | 72 ++++++++++++++++++++++++----------- 2 files changed, 88 insertions(+), 27 deletions(-) diff --git a/src/shared/api/instance.ts b/src/shared/api/instance.ts index 56ac0ba..cc83a0d 100644 --- a/src/shared/api/instance.ts +++ b/src/shared/api/instance.ts @@ -1,15 +1,19 @@ import axios from "axios"; - import { handleAPIError, handleCheckAndSetToken, handleTokenError, + refreshAccessToken, } from "./interceptor"; +import { + ACCESS_TOKEN_KEY, + REFRESH_TOKEN_KEY, + GRANT_TYPE, +} from "@/shared/constant/api"; export const axiosInstance = axios.create({ baseURL: `${process.env.REACT_APP_BASE_URL}/api/v2`, withCredentials: true, - headers: { "Content-Type": "application/json", }, @@ -18,14 +22,43 @@ export const axiosInstance = axios.create({ export const axiosPublicInstance = axios.create({ baseURL: `${process.env.REACT_APP_BASE_URL}/api/v2`, withCredentials: true, - headers: { "Content-Type": "application/json", }, }); axiosInstance.interceptors.request.use(handleCheckAndSetToken); +axiosInstance.interceptors.request.use((res) => res, handleAPIError); +axiosInstance.interceptors.request.use((res) => res, handleTokenError); + +// ✅ 응답 인터셉터: Access Token 만료 시 자동 갱신 + +axiosInstance.interceptors.response.use( + (response) => response, + async (error) => { + const { response, config } = error; + + if (response?.status === 401) { + try { + const newAccessToken = await refreshAccessToken(); // 🔄 새로운 Access Token 요청 + config.headers.Authorization = `${localStorage.getItem( + GRANT_TYPE + )} ${newAccessToken}`; + return axiosInstance(config); // 🔄 실패한 요청 재시도 + } catch (refreshError) { + console.error("Refresh Token 만료, 재로그인이 필요합니다."); + alert("로그인이 만료되었습니다. 다시 로그인해주세요."); + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem(GRANT_TYPE); + window.location.href = "/login"; // 🚀 로그인 페이지로 이동 + return Promise.reject(refreshError); + } + } -axiosInstance.interceptors.response.use((res) => res, handleTokenError); + return Promise.reject(error); + } +); -axiosInstance.interceptors.response.use((res) => res, handleAPIError); +// ✅ 일반적인 API 에러 핸들링 +axiosInstance.interceptors.response.use((response) => response, handleAPIError); diff --git a/src/shared/api/interceptor.ts b/src/shared/api/interceptor.ts index 44fa19c..bafde1c 100644 --- a/src/shared/api/interceptor.ts +++ b/src/shared/api/interceptor.ts @@ -1,12 +1,19 @@ import * as Sentry from "@sentry/react"; +import axios from "axios"; import { AxiosError, InternalAxiosRequestConfig } from "axios"; import { HTTPError } from "./HTTPError"; import { getReissuedToken } from "./auth/index"; import { axiosInstance } from "./instance"; -import { ACCESS_TOKEN_KEY, HTTP_STATUS_CODE } from "../constant/api"; +import { + ACCESS_TOKEN_KEY, + REFRESH_TOKEN_KEY, + HTTP_STATUS_CODE, + GRANT_TYPE, +} from "../constant/api"; import { PATH } from "../constant/path"; +import { useLoginStore } from "@/store/LoginStore"; interface ErrorResponse { success?: boolean; @@ -30,28 +37,25 @@ export const handleCheckAndSetToken = (config: InternalAxiosRequestConfig) => { }; export const handleTokenError = async (error: AxiosError) => { - const originRequest = error.config; - - if (!error.response || !originRequest) - throw new Error("에러가 발생했습니다."); - - const { status } = error.response; - - if (status === HTTP_STATUS_CODE.UNAUTHORIZED) { - try { - const { data } = await getReissuedToken(); - - localStorage.setItem(ACCESS_TOKEN_KEY, data.accessToken); - originRequest.data.headers.Authorization = `Bearer ${data.accessToken}`; - - return axiosInstance(originRequest); - } catch (error) { - localStorage.removeItem(ACCESS_TOKEN_KEY); - window.location.replace(PATH.LANDING); // 나중에 로그인 분리하고 변경 - - throw new Error("토큰 갱신에 실패하였습니다."); + axiosInstance.interceptors.response.use( + (res) => res, + async (error) => { + if (error.response?.status === HTTP_STATUS_CODE.UNAUTHORIZED) { + try { + console.log("🔄 토큰 갱신 시도 중..."); + const newAccessToken = await refreshAccessToken(); + error.config.headers.Authorization = `Bearer ${newAccessToken}`; + return axiosInstance(error.config); + } catch (err) { + console.log("❌ 토큰 갱신 실패, 로그아웃 처리"); + if (useLoginStore.getState().isLogin) { + useLoginStore.getState().setLogout(); + } + } + } + return Promise.reject(error); } - } + ); return Promise.reject(error); }; @@ -72,3 +76,27 @@ export const handleAPIError = (error: AxiosError) => { throw new HTTPError(status, data.message); }; + +export const refreshAccessToken = async () => { + const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY); + + if (!refreshToken) { + throw new Error("Refresh Token이 없습니다."); + } + + const response = await axios.post( + `${process.env.REACT_APP_BASE_URL}/api/v2/auth/refresh`, + { + refresh_token: refreshToken, + } + ); + + const newAccessToken = response.data.token_info.access_token; + const newGrantType = response.data.token_info.grant_type; + + // ✅ 새로운 토큰 저장 + localStorage.setItem(ACCESS_TOKEN_KEY, newAccessToken); + localStorage.setItem(GRANT_TYPE, newGrantType); + + return newAccessToken; +}; From f9bdbc75228969eb071db1d97812aac1fee660ef Mon Sep 17 00:00:00 2001 From: kwonjeong Date: Tue, 18 Feb 2025 00:31:16 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[Add]=20#171=20-=20member=20ID=20instance?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/login/useLoginMutation.ts | 2 ++ src/shared/constant/api.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/src/components/login/useLoginMutation.ts b/src/components/login/useLoginMutation.ts index c23191e..f02650a 100644 --- a/src/components/login/useLoginMutation.ts +++ b/src/components/login/useLoginMutation.ts @@ -6,6 +6,7 @@ import { ACCESS_TOKEN_KEY, GRANT_TYPE, HTTP_STATUS_CODE, + MEMBER_ID, REFRESH_TOKEN_KEY, } from "@/shared/constant/api"; import { PostLoginErrorResponse } from "@/shared/api/signin/type"; @@ -24,6 +25,7 @@ export const useLoginMutation = (callbacks?: { onSuccess?: () => void }) => { localStorage.setItem(ACCESS_TOKEN_KEY, accessToken); localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); localStorage.setItem(GRANT_TYPE, grantType); + localStorage.setItem(MEMBER_ID, String(data.member_id)); axiosInstance.defaults.headers.Authorization = `${grantType} ${accessToken}`; setLogin(data.member_id, data.email, data.nickname); diff --git a/src/shared/constant/api.ts b/src/shared/constant/api.ts index 7d9a274..f7ea706 100644 --- a/src/shared/constant/api.ts +++ b/src/shared/constant/api.ts @@ -1,6 +1,7 @@ export const ACCESS_TOKEN_KEY = "ACCESS_TOKEN" as const; export const REFRESH_TOKEN_KEY = "REFRESH_TOKEN" as const; export const GRANT_TYPE = "GRANT_TYPE" as const; +export const MEMBER_ID = "MEMBER_ID" as const; export const HTTP_STATUS_CODE = { SUCCESS: 200, From 8123b43e7285be612f1b75eaaee1e3d8cb456b13 Mon Sep 17 00:00:00 2001 From: kwonjeong Date: Tue, 18 Feb 2025 00:31:35 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[Fix]=20#171=20-=20=EB=84=A4=EB=B9=84?= =?UTF-8?q?=EB=B0=94=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/navbar/Navbar.tsx | 52 +++++++++++++++++--------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/src/components/navbar/Navbar.tsx b/src/components/navbar/Navbar.tsx index 2373253..0ea32de 100644 --- a/src/components/navbar/Navbar.tsx +++ b/src/components/navbar/Navbar.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { Link, NavLink, useNavigate,useLocation } from "react-router-dom"; +import { Link, NavLink, useNavigate, useLocation } from "react-router-dom"; import styled from "styled-components"; import * as Color from "../../common/Color"; @@ -9,35 +9,33 @@ import SearchBox from "./SearchBox"; import WriteBtn from "./WriteBtn"; import { LoginModal } from "../login/LoginModal"; -import { useEditorStore} from "../../store/EditorStore"; +import { useEditorStore } from "../../store/EditorStore"; import { useLoginStore } from "../../store/LoginStore"; import { ACCESS_TOKEN_KEY, GRANT_TYPE, + MEMBER_ID, REFRESH_TOKEN_KEY, } from "@/shared/constant/api"; import { postLogout } from "@/shared/api/logout"; +import { userInfo } from "os"; const Navbar = () => { const { setRegister } = useEditorStore(); const navigate = useNavigate(); const location = useLocation(); - const [path, setPath] = useState('/'); + const [path, setPath] = useState("/"); useEffect(() => { setPath(location.pathname); - }, [location.pathname]) + }, [location.pathname]); const { isLogin, setLogin, setLogout, memberId } = useLoginStore(); const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); useEffect(() => { - const token = sessionStorage.getItem("accessToken"); - if (token) { - const memberId = sessionStorage.getItem("memberId"); - const email = sessionStorage.getItem("email"); - const nickname = sessionStorage.getItem("nickname"); - setLogin(memberId, email, nickname); - } else { + const token = localStorage.getItem(ACCESS_TOKEN_KEY); + + if (!token) { setLogout(); } }, [setLogin]); @@ -70,14 +68,12 @@ const Navbar = () => { } }; - return ( - (path !== '/diaryEditor') - ? + return path !== "/diaryEditor" ? ( <> - {(isLogin) && ( + {isLogin && ( <> 내 다이어리 @@ -89,7 +85,7 @@ const Navbar = () => { - {(isLogin) ? ( + {isLogin ? ( <> 로그아웃 @@ -105,14 +101,20 @@ const Navbar = () => { setIsLoginModalOpen(false)} /> )} - : + ) : ( 임시저장 - {setRegister(true)}}>작성하기 + { + setRegister(true); + }} + > + 작성하기 + ); @@ -121,12 +123,12 @@ const Navbar = () => { const Logo = () => { return ( - {"/*"} - Codiary - */ - + {"/*"} + Codiary + */ + ); -} +}; const Container = styled.div` display: flex; @@ -236,7 +238,7 @@ const TempSaveBtn = styled.div` &:hover { font-weight: bold; } -` +`; const SaveBtn = styled.div` display: flex; @@ -258,6 +260,6 @@ const SaveBtn = styled.div` &:hover { font-weight: bold; } -` +`; export default Navbar;