Skip to content
Merged
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
12 changes: 10 additions & 2 deletions src/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -32,8 +33,15 @@ const Router = () => {
return (
<Routes>
<Route path="/" element={<LoginPage />} />
<Route path="/map" element={<MapPage />} />
<Route path="/manage" element={<ManagePage />}>

<Route
path="/manage"
element={
<ProtectedRoute>
<ManagePage />
</ProtectedRoute>
}
>
<Route index element={<Home />} />
<Route path="home" element={<Home />} />
<Route path="slope">
Expand Down
54 changes: 54 additions & 0 deletions src/apis/Auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
},
};
75 changes: 72 additions & 3 deletions src/apis/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
);
60 changes: 60 additions & 0 deletions src/components/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
fontSize: '18px',
}}
>
로딩 중...
</div>
);
}

// 인증된 경우에만 자식 컴포넌트 렌더링
// authAPI.checkAuth()가 false면 이미 리다이렉트되었으므로 여기까지 오지 않음
return authAPI.checkAuth() ? <>{children}</> : null;
};

export default ProtectedRoute;
2 changes: 1 addition & 1 deletion src/pages/LoginPage/components/Join.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
52 changes: 49 additions & 3 deletions src/pages/LoginPage/components/Login.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -10,18 +19,41 @@ const Login = () => {
const [loginForm, setLoginForm] = useState<LoginFormType>({
phone: '',
password: '',
rememberPhone: false,
});
const [isPhoneValid, setIsPhoneValid] = useState<boolean>(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
);
const joinMutation = useMutation({
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');
Expand Down Expand Up @@ -97,6 +129,20 @@ const Login = () => {
onChange={handleChange}
onKeyDown={handleKeyDown}
/>

<CheckboxWrapper>
<HiddenCheckbox
checked={loginForm.rememberPhone}
onChange={(e) =>
setLoginForm((prev) => ({
...prev,
rememberPhone: e.target.checked,
}))
}
/>
<Checkbox />
<CheckboxLabel>아이디 저장</CheckboxLabel>
</CheckboxWrapper>
<LoginButton onClick={onSubmit}>로그인</LoginButton>
</InputWrapper>
</>
Expand Down
48 changes: 0 additions & 48 deletions src/pages/LoginPage/components/Style.tsx

This file was deleted.

Loading
Loading