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.
+ 신규 사용자의 경우, 가입신청 후 권한을 부여받아야 접속 가능합니다.