Skip to content

[Feature] #171 - 자동로그인 기능 구현 #172

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/login/useLoginMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);

Expand Down
52 changes: 27 additions & 25 deletions src/components/navbar/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<string>('/');
const [path, setPath] = useState<string>("/");
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]);
Expand Down Expand Up @@ -70,14 +68,12 @@ const Navbar = () => {
}
};

return (
(path !== '/diaryEditor')
?
return path !== "/diaryEditor" ? (
<>
<Container>
<Left>
<Logo />
{(isLogin) && (
{isLogin && (
<>
<NavStyle to="/">홈</NavStyle>
<NavStyle to={`/profile/${memberId}`}>내 다이어리</NavStyle>
Expand All @@ -89,7 +85,7 @@ const Navbar = () => {
</Left>
<Right>
<SearchBox />
{(isLogin) ? (
{isLogin ? (
<>
<WriteBtn />
<LogoutBtn onClick={handleLogout}>로그아웃</LogoutBtn>
Expand All @@ -105,14 +101,20 @@ const Navbar = () => {
<LoginModal onClose={() => setIsLoginModalOpen(false)} />
)}
</>
:
) : (
<Container>
<Left>
<Logo />
</Left>
<Right>
<TempSaveBtn>임시저장</TempSaveBtn>
<SaveBtn onClick={()=>{setRegister(true)}}>작성하기</SaveBtn>
<SaveBtn
onClick={() => {
setRegister(true);
}}
>
작성하기
</SaveBtn>
</Right>
</Container>
);
Expand All @@ -121,12 +123,12 @@ const Navbar = () => {
const Logo = () => {
return (
<LinkStyle to="/">
<Typography>{"/*"}</Typography>
<Codiary>Codiary</Codiary>
<Typography>*/</Typography>
</LinkStyle>
<Typography>{"/*"}</Typography>
<Codiary>Codiary</Codiary>
<Typography>*/</Typography>
</LinkStyle>
);
}
};

const Container = styled.div`
display: flex;
Expand Down Expand Up @@ -236,7 +238,7 @@ const TempSaveBtn = styled.div`
&:hover {
font-weight: bold;
}
`
`;

const SaveBtn = styled.div`
display: flex;
Expand All @@ -258,6 +260,6 @@ const SaveBtn = styled.div`
&:hover {
font-weight: bold;
}
`
`;

export default Navbar;
43 changes: 38 additions & 5 deletions src/shared/api/instance.ts
Original file line number Diff line number Diff line change
@@ -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",
},
Expand All @@ -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);
72 changes: 50 additions & 22 deletions src/shared/api/interceptor.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -30,28 +37,25 @@ export const handleCheckAndSetToken = (config: InternalAxiosRequestConfig) => {
};

export const handleTokenError = async (error: AxiosError<ErrorResponse>) => {
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);
};
Expand All @@ -72,3 +76,27 @@ export const handleAPIError = (error: AxiosError<ErrorResponse>) => {

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;
};
1 change: 1 addition & 0 deletions src/shared/constant/api.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
51 changes: 0 additions & 51 deletions src/store/LoginStore.js

This file was deleted.

Loading