From 5ee3a8aa88fec9d8306384ac79299777c3f3cf27 Mon Sep 17 00:00:00 2001 From: KimDoHyun Date: Sat, 7 Jun 2025 19:03:50 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20refresh=20tocken=EC=B6=94=EA=B0=80?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20api=20intercepter=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20auth=20api=EB=B3=80=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/Auth.tsx | 54 ++++++++++++++++++++++++++++++++++ src/apis/api.tsx | 75 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 126 insertions(+), 3 deletions(-) diff --git a/src/apis/Auth.tsx b/src/apis/Auth.tsx index 3c8bcfa..fe66523 100644 --- a/src/apis/Auth.tsx +++ b/src/apis/Auth.tsx @@ -9,19 +9,73 @@ export interface JoinFormType { export interface LoginFormType { phone: string; password: string; + rememberPhone?: boolean; } export const authAPI = { login: async (data: LoginFormType) => { const response = await api.post('auth/login', data); // console.log(response.data); + localStorage.setItem('accessToken', response.data.accessToken); + localStorage.setItem('refreshToken', response.data.refreshToken); + localStorage.setItem('token', response.data.token); localStorage.setItem('_id', response.data.user.id); localStorage.setItem('isAdmin', response.data.user.isAdmin); + if (data.rememberPhone) { + localStorage.setItem('rememberedPhone', data.phone); + localStorage.setItem('rememberPhoneChecked', 'true'); + } else { + localStorage.removeItem('rememberedPhone'); + localStorage.removeItem('rememberPhoneChecked'); + } return response.data; }, join: async (data: JoinFormType) => { const response = await api.post('auth/register', data); return response.data; }, + logout: async () => { + try { + // 서버에 로그아웃 요청 (Refresh Token 무효화) + await api.post('auth/logout'); + } catch (error) { + console.error('Logout error:', error); + } finally { + // 로컬 데이터 삭제 (아이디 저장 기능은 유지) + const rememberedPhone = localStorage.getItem('rememberedPhone'); + const rememberPhoneChecked = localStorage.getItem('rememberPhoneChecked'); + + localStorage.clear(); + + // 아이디 저장이 체크되어 있었다면 복원 + if (rememberPhoneChecked === 'true' && rememberedPhone) { + localStorage.setItem('rememberedPhone', rememberedPhone); + localStorage.setItem('rememberPhoneChecked', 'true'); + } + } + }, + + // 토큰 유효성 검사 + checkAuth: () => { + const accessToken = localStorage.getItem('accessToken'); + const refreshToken = localStorage.getItem('refreshToken'); + return !!(accessToken && refreshToken); + }, + + // 저장된 아이디 가져오기 + getRememberedPhone: () => { + return { + phone: localStorage.getItem('rememberedPhone') || '', + checked: localStorage.getItem('rememberPhoneChecked') === 'true', + }; + }, + + // 현재 사용자 정보 가져오기 + getCurrentUser: () => { + return { + id: localStorage.getItem('_id'), + isAdmin: localStorage.getItem('isAdmin') === 'true', + }; + }, }; diff --git a/src/apis/api.tsx b/src/apis/api.tsx index 28cbdb6..2aeafeb 100644 --- a/src/apis/api.tsx +++ b/src/apis/api.tsx @@ -8,10 +8,79 @@ export const api = axios.create({ withCredentials: true, }); +// Request interceptor api.interceptors.request.use((config) => { - const token = localStorage.getItem('token'); - if (token) { - config.headers.Authorization = `Bearer ${token}`; + const accessToken = localStorage.getItem('accessToken'); // 'token' → 'accessToken'으로 변경 + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; } return config; }); + +// Response interceptor +api.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + // 401 에러이고, 재시도하지 않은 요청인 경우 + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + try { + const refreshToken = localStorage.getItem('refreshToken'); + if (!refreshToken) { + throw new Error('No refresh token available'); + } + + // Refresh Token으로 새로운 Access Token 요청 + const response = await axios.post( + `${import.meta.env.VITE_SERVER_ADDRESS}/auth/refresh`, + {}, + { + headers: { + Authorization: `Bearer ${refreshToken}`, + 'Content-Type': 'application/json', + }, + } + ); + + // 새로운 토큰들 저장 + const { accessToken, refreshToken: newRefreshToken } = response.data; + localStorage.setItem('accessToken', accessToken); + + if (newRefreshToken) { + localStorage.setItem('refreshToken', newRefreshToken); + } + + // 원래 요청에 새로운 Access Token 적용하여 재시도 + originalRequest.headers.Authorization = `Bearer ${accessToken}`; + return api(originalRequest); + } catch (refreshError) { + // Refresh Token도 만료되었거나 오류 발생 + console.error('Token refresh failed:', refreshError); + + // 모든 토큰 정보 삭제 (아이디 저장 기능은 유지) + const rememberedPhone = localStorage.getItem('rememberedPhone'); + const rememberPhoneChecked = localStorage.getItem( + 'rememberPhoneChecked' + ); + + localStorage.clear(); + + // 아이디 저장이 체크되어 있었다면 복원 + if (rememberPhoneChecked === 'true' && rememberedPhone) { + localStorage.setItem('rememberedPhone', rememberedPhone); + localStorage.setItem('rememberPhoneChecked', 'true'); + } + + // 로그인 페이지로 리다이렉트 + window.location.href = '/login'; + + return Promise.reject(refreshError); + } + } + + return Promise.reject(error); + } +); From 0823a4516d378bc572f9318088d814ceedddf63a Mon Sep 17 00:00:00 2001 From: KimDoHyun Date: Sat, 7 Jun 2025 19:04:08 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Router.tsx | 12 +++++-- src/components/ProtectedRoute.tsx | 60 +++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 src/components/ProtectedRoute.tsx diff --git a/src/Router.tsx b/src/Router.tsx index d16290a..466a5cf 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -11,6 +11,7 @@ import UserModi from './pages/ManagePage/User/pages/UserModi'; import Home from './pages/ManagePage/Home'; import SteepSlopeDup from './pages/ManagePage/StepSlope/StepSlopeOutlier/SteepSlopeDup'; import SteepSlopeEmpty from './pages/ManagePage/StepSlope/StepSlopeOutlier/SteepSlopeEmpty'; +import ProtectedRoute from './components/ProtectedRoute'; const Router = () => { const location = useLocation(); @@ -32,8 +33,15 @@ const Router = () => { return ( } /> - } /> - }> + + + + + } + > } /> } /> diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..76eb8ff --- /dev/null +++ b/src/components/ProtectedRoute.tsx @@ -0,0 +1,60 @@ +// components/ProtectedRoute.tsx +import { useEffect, useState, ReactNode } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { authAPI } from '../apis/Auth'; + +interface ProtectedRouteProps { + children: ReactNode; +} + +const ProtectedRoute = ({ children }: ProtectedRouteProps) => { + const [isAuthChecking, setIsAuthChecking] = useState(true); + const navigate = useNavigate(); + const location = useLocation(); + + useEffect(() => { + const checkAuth = async () => { + try { + const authStatus = authAPI.checkAuth(); + + if (!authStatus) { + // 인증되지 않은 경우 로그인 페이지로 리다이렉트 + navigate('/', { + replace: true, + state: { from: location.pathname }, // 로그인 후 돌아갈 경로 저장 + }); + } + } catch (error) { + console.error('Auth check failed:', error); + navigate('/', { replace: true }); + } finally { + setIsAuthChecking(false); + } + }; + + checkAuth(); + }, [navigate, location.pathname]); + + // 인증 체크 중일 때 로딩 화면 + if (isAuthChecking) { + return ( +
+ 로딩 중... +
+ ); + } + + // 인증된 경우에만 자식 컴포넌트 렌더링 + // authAPI.checkAuth()가 false면 이미 리다이렉트되었으므로 여기까지 오지 않음 + return authAPI.checkAuth() ? <>{children} : null; +}; + +export default ProtectedRoute; From f4fcfb4c3bf7e53f3cab43dfe12133c5f919fabd Mon Sep 17 00:00:00 2001 From: KimDoHyun Date: Sat, 7 Jun 2025 19:04:40 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EC=95=84=EC=9D=B4=EB=94=94=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=ED=8C=8C=EC=9D=BC=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/LoginPage/components/Join.tsx | 2 +- src/pages/LoginPage/components/Login.tsx | 52 ++++++++- src/pages/LoginPage/components/Style.tsx | 48 -------- .../LoginPage/components/commonStyle.tsx | 108 ++++++++++++++++++ 4 files changed, 158 insertions(+), 52 deletions(-) delete mode 100644 src/pages/LoginPage/components/Style.tsx create mode 100644 src/pages/LoginPage/components/commonStyle.tsx diff --git a/src/pages/LoginPage/components/Join.tsx b/src/pages/LoginPage/components/Join.tsx index 19d4eca..7e977a5 100644 --- a/src/pages/LoginPage/components/Join.tsx +++ b/src/pages/LoginPage/components/Join.tsx @@ -1,4 +1,4 @@ -import { ErrorText, Input, InputWrapper, LoginButton } from './Style'; +import { ErrorText, Input, InputWrapper, LoginButton } from './commonStyle'; import { useState } from 'react'; import { useMutation } from '@tanstack/react-query'; import { authAPI, JoinFormType } from '../../../apis/Auth'; diff --git a/src/pages/LoginPage/components/Login.tsx b/src/pages/LoginPage/components/Login.tsx index 9435702..43a6d13 100644 --- a/src/pages/LoginPage/components/Login.tsx +++ b/src/pages/LoginPage/components/Login.tsx @@ -1,6 +1,15 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { ErrorText, Input, InputWrapper, LoginButton } from './Style'; +import { + Checkbox, + CheckboxLabel, + CheckboxWrapper, + ErrorText, + HiddenCheckbox, + Input, + InputWrapper, + LoginButton, +} from './commonStyle'; import { useMutation } from '@tanstack/react-query'; import { authAPI, LoginFormType } from '../../../apis/Auth'; import { useNotificationStore } from '../../../hooks/notificationStore'; @@ -10,8 +19,27 @@ const Login = () => { const [loginForm, setLoginForm] = useState({ phone: '', password: '', + rememberPhone: false, }); const [isPhoneValid, setIsPhoneValid] = useState(true); + + //로그인이 되어있으면 이동 + useEffect(() => { + if (authAPI.checkAuth()) { + nav('/manage/map'); + } + }, []); + //아이디 저장이 되어있는 경우 + useEffect(() => { + const remembered = authAPI.getRememberedPhone(); + setLoginForm((prev) => ({ + ...prev, + phone: remembered.phone, + rememberPhone: remembered.checked, + })); + }, []); + + // 알림함수 구현 const showNotification = useNotificationStore( (state) => state.showNotification ); @@ -19,9 +47,13 @@ const Login = () => { mutationFn: (data: LoginFormType) => authAPI.login(data), onSuccess: () => { showNotification('로그인 성공!', { severity: 'success' }); + + // 아이디 저장이 체크되어 있으면 전화번호는 유지 + const shouldRememberPhone = loginForm.rememberPhone; setLoginForm({ - phone: '', + phone: shouldRememberPhone ? loginForm.phone : '', password: '', + rememberPhone: shouldRememberPhone, }); nav('/manage/map'); @@ -97,6 +129,20 @@ const Login = () => { onChange={handleChange} onKeyDown={handleKeyDown} /> + + + + setLoginForm((prev) => ({ + ...prev, + rememberPhone: e.target.checked, + })) + } + /> + + 아이디 저장 + 로그인 diff --git a/src/pages/LoginPage/components/Style.tsx b/src/pages/LoginPage/components/Style.tsx deleted file mode 100644 index f2da9c1..0000000 --- a/src/pages/LoginPage/components/Style.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import styled from 'styled-components'; - -export const InputWrapper = styled.div` - width: 100%; - display: flex; - flex-direction: column; - gap: 12px; -`; - -export const Input = styled.input` - width: 100%; - padding: 18px 10px; - border: 0px; - color: ${({ theme }) => theme.colors.grey[700]}; - background-color: ${({ theme }) => theme.colors.grey[200]}; - - border-radius: ${({ theme }) => theme.borderRadius.md}; - font-size: ${({ theme }) => theme.fonts.sizes.ms}; - font-weight: ${({ theme }) => theme.fonts.weights.medium}; -`; - -export const LoginButton = styled.button` - width: 100%; - padding: 12px; - background-color: ${({ theme }) => theme.colors.primary}; - color: ${({ theme }) => theme.colors.white}; - border: none; - border-radius: ${({ theme }) => theme.borderRadius.md}; - - font-size: ${({ theme }) => theme.fonts.sizes.mm}; - font-weight: ${({ theme }) => theme.fonts.weights.bold}; - - margin-top: 8px; - - &:hover { - background-color: ${({ theme }) => theme.colors.primaryDark}; - } - - &:active { - transform: scale(0.98); - } -`; - -export const ErrorText = styled.div` - font-size: ${({ theme }) => theme.fonts.sizes.cl}; - color: ${({ theme }) => theme.colors.error}; - font-weight: ${({ theme }) => theme.fonts.weights.bold}; -`; diff --git a/src/pages/LoginPage/components/commonStyle.tsx b/src/pages/LoginPage/components/commonStyle.tsx new file mode 100644 index 0000000..0277804 --- /dev/null +++ b/src/pages/LoginPage/components/commonStyle.tsx @@ -0,0 +1,108 @@ +import styled from 'styled-components'; + +export const InputWrapper = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 12px; +`; + +export const Input = styled.input` + width: 100%; + padding: 18px 10px; + border: 0px; + color: ${({ theme }) => theme.colors.grey[700]}; + background-color: ${({ theme }) => theme.colors.grey[200]}; + + border-radius: ${({ theme }) => theme.borderRadius.md}; + font-size: ${({ theme }) => theme.fonts.sizes.ms}; + font-weight: ${({ theme }) => theme.fonts.weights.medium}; +`; + +export const LoginButton = styled.button` + width: 100%; + padding: 12px; + background-color: ${({ theme }) => theme.colors.primary}; + color: ${({ theme }) => theme.colors.white}; + border: none; + border-radius: ${({ theme }) => theme.borderRadius.md}; + + font-size: ${({ theme }) => theme.fonts.sizes.mm}; + font-weight: ${({ theme }) => theme.fonts.weights.bold}; + + margin-top: 8px; + + &:hover { + background-color: ${({ theme }) => theme.colors.primaryDark}; + } + + &:active { + transform: scale(0.98); + } +`; + +export const ErrorText = styled.div` + font-size: ${({ theme }) => theme.fonts.sizes.cl}; + color: ${({ theme }) => theme.colors.error}; + font-weight: ${({ theme }) => theme.fonts.weights.bold}; +`; + +export const CheckboxWrapper = styled.label` + display: flex; + align-items: center; + padding: 5px 5px; + cursor: pointer; + user-select: none; +`; + +export const HiddenCheckbox = styled.input.attrs({ type: 'checkbox' })` + position: absolute; + opacity: 0; + cursor: pointer; +`; + +export const Checkbox = styled.div` + width: 18px; + height: 18px; + border: 2px solid ${({ theme }) => theme.colors.grey[400]}; + border-radius: 3px; + margin-right: 8px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + + ${HiddenCheckbox}:checked + & { + background-color: ${({ theme }) => theme.colors.primary}; + border-color: ${({ theme }) => theme.colors.primary}; + } + + ${HiddenCheckbox}:focus + & { + box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primary}33; + } + + &::after { + content: ''; + width: 5px; + height: 9px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + opacity: 0; + transition: opacity 0.2s ease; + } + + ${HiddenCheckbox}:checked + &::after { + opacity: 1; + } +`; + +export const CheckboxLabel = styled.span` + font-size: ${({ theme }) => theme.fonts.sizes.ms}; + font-weight: ${({ theme }) => theme.fonts.weights.bold}; + color: ${({ theme }) => theme.colors.grey[700]}; + + ${CheckboxWrapper}:hover & { + color: ${({ theme }) => theme.colors.primary}; + } +`; From 9b578c938ae45b457fb1dc60a4df0583ef523f9e Mon Sep 17 00:00:00 2001 From: KimDoHyun Date: Sat, 7 Jun 2025 19:05:13 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20api=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/MapPage/components/map/LeftModal.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/MapPage/components/map/LeftModal.tsx b/src/pages/MapPage/components/map/LeftModal.tsx index f359a16..02b4441 100644 --- a/src/pages/MapPage/components/map/LeftModal.tsx +++ b/src/pages/MapPage/components/map/LeftModal.tsx @@ -7,6 +7,7 @@ import TermsofUseModal from './TermsofUseModal'; import ArrowBackIcon from '@mui/icons-material/ArrowBackIosNewRounded'; import { useNotificationStore } from '../../../../hooks/notificationStore'; import { LeftModalProps } from '../../interface'; +import { authAPI } from '../../../../apis/Auth'; const LeftModal = ({ isOpen, onClose }: LeftModalProps) => { const [animationOpen, setAnimationOpen] = useState(false); @@ -40,10 +41,9 @@ const LeftModal = ({ isOpen, onClose }: LeftModalProps) => { setSelectedMenu(menu); }; - const handleLogout = () => { - localStorage.removeItem('token'); - localStorage.removeItem('_id'); - localStorage.removeItem('isAdmin'); + const handleLogout = async () => { + await authAPI.logout(); // finally 블록에서 항상 토큰 삭제됨 + onClose(); showNotification('로그아웃 되었습니다.', { severity: 'success' }); window.location.href = '/';