diff --git a/src/App.tsx b/src/App.tsx index 56f09f7..d9411a2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import React, { Suspense, useEffect } from 'react' -import { HashRouter, Route, Routes, Navigate } from 'react-router-dom' +import { HashRouter, Route, Routes, Navigate, useLocation } from 'react-router-dom' import { useSelector } from 'react-redux' import { RootState } from 'src/store' @@ -21,11 +21,22 @@ const ProtectedRoute = React.lazy(() => import('src/routes/ProtectedRoute')) import { useAuth } from 'src/hooks/useAuth' +const RouteWatcher = () => { + const { checkAuth } = useAuth() + const location = useLocation() + + useEffect(() => { + checkAuth(location.key) + }, [location.key, checkAuth]) + + return null +} + const App = () => { const { isColorModeSet, setColorMode } = useColorModes('coreui-free-react-admin-template-theme') const storedTheme = useSelector((state: RootState) => state.theme) const { state: authState } = useAuth() - const { isAuthenticated, loading: authLoading } = authState + const { isAuthenticated, loading: authLoading, isInitialized } = authState useEffect(() => { const urlParams = new URLSearchParams(window.location.href.split('?')[1]) @@ -41,7 +52,8 @@ const App = () => { setColorMode(storedTheme) }, []) // eslint-disable-line react-hooks/exhaustive-deps - if (authLoading) { + // 초기 로딩 (앱 최초 실행 시에만 전역 스피너 표시) + if (!isInitialized) { return (
@@ -51,6 +63,7 @@ const App = () => { return ( + diff --git a/src/axios/authService.ts b/src/axios/authService.ts index eab3e62..28f862d 100644 --- a/src/axios/authService.ts +++ b/src/axios/authService.ts @@ -104,12 +104,9 @@ export const getUserFromToken = (token: string): DecodedToken | null => { }; // Access Token 갱신 API -export const renewAccessToken = async (refreshToken: string): Promise => { - // Refresh Token을 Header에 담아 전송 (Authorization: Bearer ) - const response = await axios.post('/auth/renewAccessToken', null, { - headers: { - 'Authorization': `Bearer ${refreshToken}` - } - }); +export const renewAccessToken = async (refreshToken?: string): Promise => { + // Refresh Token이 있으면 Header에 담고, 없으면 쿠키(HttpOnly)에 의존 + const headers = refreshToken ? { 'Authorization': `Bearer ${refreshToken}` } : {}; + const response = await axios.post('/auth/renewAccessToken', null, { headers }); return response.data; }; diff --git a/src/components/AppContent.tsx b/src/components/AppContent.tsx index 76b0be1..c09252c 100644 --- a/src/components/AppContent.tsx +++ b/src/components/AppContent.tsx @@ -1,29 +1,43 @@ import React, { Suspense } from 'react' -import { Navigate, Route, Routes } from 'react-router-dom' +import { Navigate, Route, Routes, useLocation } from 'react-router-dom' import { CContainer, CSpinner } from '@coreui/react' // routes config import routes from 'src/routes/routes' +import { useAuth } from 'src/hooks/useAuth' const AppContent = () => { + const { state } = useAuth() + const { loading: authLoading, verifiedKey } = state + const location = useLocation() + + // 현재 라우트 키가 인증된 키와 다르면 로딩 상태로 간주 (화면이 그려지기 전 차단) + const isVerifying = authLoading || (verifiedKey !== location.key) + return ( }> - - {routes.map((route, idx) => { - return ( - route.element && ( - } - /> + {isVerifying ? ( +
+ +
+ ) : ( + + {routes.map((route, idx) => { + return ( + route.element && ( + } + /> + ) ) - ) - })} - } /> - } /> - + })} + } /> + } /> +
+ )}
) diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 40ebdd2..b62d91a 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useReducer, useEffect } from 'react'; +import React, { createContext, useReducer, useEffect, useCallback } from 'react'; import { isTokenValid, getAccessTokenFromCookie, getUserFromToken, getRefreshTokenFromCookie, renewAccessToken, logout as apiLogout } from 'src/axios/authService'; @@ -13,6 +13,8 @@ interface AuthState { isAuthenticated: boolean; member: Member | null; loading: boolean; + isInitialized: boolean; + verifiedKey: string | null; error: string | null; } @@ -23,13 +25,15 @@ type AuthAction = | { type: 'AUTH_ERROR'; payload: string } | { type: 'CLEAR_ERROR' } | { type: 'SET_LOADING' } - | { type: 'MEMBER_LOADED'; payload: Member }; + | { type: 'MEMBER_LOADED'; payload: Member; key?: string }; // 초기 상태 const initialState: AuthState = { isAuthenticated: false, member: null, loading: true, + isInitialized: false, + verifiedKey: null, error: null }; @@ -39,6 +43,7 @@ export interface AuthContextType { dispatch: React.Dispatch; login: (token: string, member: Member) => void; logout: () => void; + checkAuth: (key?: string) => Promise; } // Context 생성 @@ -46,7 +51,8 @@ export const AuthContext = createContext({ state: initialState, dispatch: () => null, login: () => null, - logout: () => null + logout: () => null, + checkAuth: async () => { } }); // 리듀서 함수 @@ -59,6 +65,7 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => { isAuthenticated: true, member: action.payload.member, loading: false, + isInitialized: true, error: null }; case 'LOGOUT': @@ -71,6 +78,7 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => { isAuthenticated: false, member: null, loading: false, + isInitialized: true, error: null }; case 'AUTH_ERROR': @@ -83,6 +91,7 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => { isAuthenticated: false, member: null, loading: false, + isInitialized: true, error: action.payload }; case 'CLEAR_ERROR': @@ -100,7 +109,9 @@ const authReducer = (state: AuthState, action: AuthAction): AuthState => { ...state, isAuthenticated: true, member: action.payload, - loading: false + loading: false, + isInitialized: true, + verifiedKey: (action as any).key || state.verifiedKey }; default: return state; @@ -112,15 +123,15 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children const [state, dispatch] = useReducer(authReducer, initialState); // 로그인 함수 - const login = (token: string, member: Member) => { + const login = useCallback((token: string, member: Member) => { dispatch({ type: 'LOGIN_SUCCESS', payload: { token, member } }); - }; + }, [dispatch]); // 로그아웃 함수 - const logout = async () => { + const logout = useCallback(async () => { try { await apiLogout(); } catch (error) { @@ -128,66 +139,89 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children } finally { dispatch({ type: 'LOGOUT' }); } - }; + }, [dispatch]); + + // 인증 상태 확인 함수 + const checkAuth = useCallback(async (key?: string) => { + const lsToken = localStorage.getItem('accessToken') || localStorage.getItem('access_token'); + const cookieToken = getAccessTokenFromCookie(); + + let token = null; + + // 1. 쿠키에 유효한 토큰이 있는 경우를 최상순위로 신뢰 + if (cookieToken && isTokenValid(cookieToken)) { + token = cookieToken; + } + // 2. 쿠키가 없는데 로컬 스토리지에만 토큰이 있는 경우 -> "불완전한 상태"로 보고 갱신 시도 + else if (lsToken && isTokenValid(lsToken)) { + // LS에 유효한 토큰이 있다면 일단 이를 믿고 화면 전환을 허용 (깜빡임 방지) + token = lsToken; + } + + // 2. 만약 유효한 토큰이 물리적으로 하나도 없다면 "차단형 로딩" 후 갱신 시도 + if (!token) { + dispatch({ type: 'SET_LOADING' }); + + const refreshToken = localStorage.getItem('refreshToken') || localStorage.getItem('refresh_token') || getRefreshTokenFromCookie(); + try { + const response = await renewAccessToken(refreshToken || undefined); + if (response.resultCode === '200') { + token = response.resultData || getAccessTokenFromCookie(); + if (token) localStorage.setItem('accessToken', token); + } + } catch (error) { + // Ignore silent failure + } + } + // 3. 토큰은 있지만 쿠키가 없는 경우 등 "불완전한 상태"라면 백그라운드에서 조용히 갱신 + else if (!cookieToken || !isTokenValid(cookieToken)) { + const refreshToken = localStorage.getItem('refreshToken') || localStorage.getItem('refresh_token') || getRefreshTokenFromCookie(); + + // 갱신을 호출하지만 'SET_LOADING'은 하지 않음 (사용자는 모름) + renewAccessToken(refreshToken || undefined).then(response => { + if (response.resultCode === '200') { + const newToken = response.resultData || getAccessTokenFromCookie(); + if (newToken) { + localStorage.setItem('accessToken', newToken); + } + } + }).catch(() => { + // Ignore silent failure + }); + } + + // 4. 모든 시도 후에도 토큰이 없으면 로그아웃 + if (!token || !isTokenValid(token)) { + dispatch({ type: 'LOGOUT' }); + return; + } + + // 5. 최종 인증 상태 업데이트 + try { + const decodedToken = getUserFromToken(token); + if (decodedToken) { + const member: Member = { + memberId: decodedToken.memberId, + memberName: decodedToken.memberName + }; + + // key를 함께 전달하여 이 라우트의 검증이 완료되었음을 알림 + dispatch({ type: 'MEMBER_LOADED', payload: member, key } as any); + } else { + dispatch({ type: 'AUTH_ERROR', payload: 'Invalid token structure' }); + } + } catch (error) { + dispatch({ type: 'AUTH_ERROR', payload: 'Authentication sync failed' }); + } + }, [dispatch]); // 초기 인증 상태 확인 useEffect(() => { - const loadUser = async () => { - // localStorage 또는 Cookie에서 토큰 확인 - let token = localStorage.getItem('accessToken') || localStorage.getItem('access_token') || getAccessTokenFromCookie(); - - if (!token || !isTokenValid(token)) { - // Access Token이 없거나 만료된 경우 Refresh Token 확인 - let refreshToken = localStorage.getItem('refreshToken') || localStorage.getItem('refresh_token') || getRefreshTokenFromCookie(); - - // refreshToken으로 Access Token 갱신 시도 - try { - const response = await renewAccessToken(refreshToken || ''); - if (response.resultCode === '200') { - // 갱신 성공 시 새로운 Access Token 가져오기 - const newToken = response.resultData || getAccessTokenFromCookie() || localStorage.getItem('accessToken'); - if (newToken && isTokenValid(newToken)) { - token = newToken; - // 갱신된 토큰을 localStorage에 즉시 저장 - localStorage.setItem('accessToken', token); - } - } - } catch (error) { - // 갱신 실패 무시 - } - } - - if (!token || !isTokenValid(token)) { - dispatch({ type: 'LOGOUT' }); - return; - } - - try { - // 토큰이 존재하고 유효한 경우, localStorage와 동기화 (갱신 등으로 변경되었을 수 있음) - if (token && localStorage.getItem('accessToken') !== token) { - localStorage.setItem('accessToken', token); - } - - const decodedToken = getUserFromToken(token!); // 위에서 유효성 검사 완료됨 - if (decodedToken) { - const member: Member = { - memberId: decodedToken.memberId, - memberName: decodedToken.memberName - }; - dispatch({ type: 'MEMBER_LOADED', payload: member }); - } else { - dispatch({ type: 'AUTH_ERROR', payload: 'Invalid token' }); - } - } catch (error) { - dispatch({ type: 'AUTH_ERROR', payload: 'Authentication failed' }); - } - }; - - loadUser(); - }, []); + checkAuth('initial'); + }, [checkAuth]); return ( - + {children} ); diff --git a/src/views/pages/login/Login.tsx b/src/views/pages/login/Login.tsx index f3215f0..bacd24b 100644 --- a/src/views/pages/login/Login.tsx +++ b/src/views/pages/login/Login.tsx @@ -80,8 +80,8 @@ const Login = () => { setMemberId(e.target.value)} /> @@ -99,27 +99,21 @@ const Login = () => { /> - - + + {loading ? 'Logging in...' : 'Login'} - - - Forgot password? - - - +

Sign up

- Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod - tempor incididunt ut labore et dolore magna aliqua. + 신규 사용자의 경우, 가입신청 후 권한을 부여받아야 접속 가능합니다.