refactor: 로그아웃 및 캐시 정리 로직 개선
- AuthContext 로그아웃 함수를 완전한 캐시 정리 방식으로 개선 - 새로운 logout 유틸리티 추가 (Zustand, sessionStorage, localStorage, 서버 API 통합) - DashboardLayout → AuthenticatedLayout 이름 변경 - masterDataStore 캐시 정리 기능 강화 - protected 라우트 레이아웃 참조 업데이트 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
* Protected Group Error Boundary
|
||||
*
|
||||
* 특징:
|
||||
* - DashboardLayout 자동 적용 (사이드바, 헤더 포함)
|
||||
* - AuthenticatedLayout 자동 적용 (사이드바, 헤더 포함)
|
||||
* - 보호된 경로 내 에러만 포착
|
||||
* - 인증된 사용자를 위한 친근한 에러 UI
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import DashboardLayout from '@/layouts/DashboardLayout';
|
||||
import AuthenticatedLayout from '@/layouts/AuthenticatedLayout';
|
||||
import { RootProvider } from '@/contexts/RootProvider';
|
||||
|
||||
/**
|
||||
@@ -32,7 +32,7 @@ export default function ProtectedLayout({
|
||||
// 🎨 모든 하위 페이지에 공통 레이아웃 및 Context 적용
|
||||
return (
|
||||
<RootProvider>
|
||||
<DashboardLayout>{children}</DashboardLayout>
|
||||
<AuthenticatedLayout>{children}</AuthenticatedLayout>
|
||||
</RootProvider>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import { PageLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
* Protected Group Loading UI
|
||||
*
|
||||
* 특징:
|
||||
* - DashboardLayout 내에서 표시됨 (사이드바, 헤더 유지)
|
||||
* - AuthenticatedLayout 내에서 표시됨 (사이드바, 헤더 유지)
|
||||
* - React Suspense 자동 적용
|
||||
* - 페이지 전환 시 즉각적인 피드백
|
||||
* - 대시보드 스타일로 통일
|
||||
* - 공통 레이아웃 스타일로 통일
|
||||
*/
|
||||
export default function ProtectedLoading() {
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
* Protected Group 404 Not Found Page
|
||||
*
|
||||
* 특징:
|
||||
* - DashboardLayout 자동 적용 (사이드바, 헤더 포함)
|
||||
* - AuthenticatedLayout 자동 적용 (사이드바, 헤더 포함)
|
||||
* - 인증된 사용자만 볼 수 있음
|
||||
* - 보호된 경로 내에서 404 발생 시 표시
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react';
|
||||
import { performFullLogout } from '@/lib/auth/logout';
|
||||
|
||||
// ===== 타입 정의 =====
|
||||
|
||||
@@ -54,7 +55,7 @@ interface AuthContextType {
|
||||
updateUser: (userId: string, updates: Partial<User>) => void;
|
||||
deleteUser: (userId: string) => void;
|
||||
getUserByUserId: (userId: string) => User | undefined;
|
||||
logout: () => void; // ✅ 추가: 로그아웃
|
||||
logout: () => Promise<void>; // ✅ 추가: 로그아웃 (완전한 캐시 정리)
|
||||
clearTenantCache: (tenantId: number) => void; // ✅ 추가: 테넌트 캐시 삭제
|
||||
resetAllData: () => void;
|
||||
}
|
||||
@@ -224,14 +225,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
};
|
||||
|
||||
// ✅ 추가: 로그아웃 함수
|
||||
const logout = () => {
|
||||
if (currentUser?.tenant?.id) {
|
||||
clearTenantCache(currentUser.tenant.id);
|
||||
}
|
||||
// ✅ 추가: 로그아웃 함수 (완전한 캐시 정리)
|
||||
const logout = async () => {
|
||||
console.log('[Auth] Starting logout...');
|
||||
|
||||
// 1. React 상태 초기화 (UI 즉시 반영)
|
||||
setCurrentUser(null);
|
||||
localStorage.removeItem('mes-currentUser');
|
||||
console.log('[Auth] Logged out and cleared tenant cache');
|
||||
|
||||
// 2. 완전한 로그아웃 수행 (Zustand, sessionStorage, localStorage, 서버 API)
|
||||
await performFullLogout({
|
||||
skipServerLogout: false, // 서버 API 호출 (HttpOnly 쿠키 삭제)
|
||||
redirectTo: null, // 리다이렉트는 호출하는 곳에서 처리
|
||||
});
|
||||
|
||||
console.log('[Auth] Logout completed');
|
||||
};
|
||||
|
||||
// Context value
|
||||
|
||||
@@ -35,15 +35,17 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import Sidebar from '@/components/layout/Sidebar';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { deserializeMenuItems } from '@/lib/utils/menuTransform';
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
interface AuthenticatedLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
export default function AuthenticatedLayout({ children }: AuthenticatedLayoutProps) {
|
||||
const { menuItems, activeMenu, setActiveMenu, setMenuItems, sidebarCollapsed, toggleSidebar, _hasHydrated } = useMenuStore();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { logout } = useAuth();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname(); // 현재 경로 추적
|
||||
|
||||
@@ -174,20 +176,18 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
// HttpOnly Cookie 방식: Next.js API Route로 프록시
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
// localStorage 정리
|
||||
localStorage.removeItem('user');
|
||||
// AuthContext의 logout() 호출 (완전한 캐시 정리 수행)
|
||||
// - Zustand 스토어 초기화
|
||||
// - sessionStorage 캐시 삭제 (page_config_*, mes-*)
|
||||
// - localStorage 사용자 데이터 삭제
|
||||
// - 서버 로그아웃 API 호출 (HttpOnly 쿠키 삭제)
|
||||
await logout();
|
||||
|
||||
// 로그인 페이지로 리다이렉트
|
||||
router.push('/login');
|
||||
} catch (error) {
|
||||
console.error('로그아웃 처리 중 오류:', error);
|
||||
// 에러가 나도 로그인 페이지로 이동
|
||||
localStorage.removeItem('user');
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
225
src/lib/auth/logout.ts
Normal file
225
src/lib/auth/logout.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 완전한 로그아웃 처리
|
||||
*
|
||||
* 로그아웃 시 수행하는 작업:
|
||||
* 1. Zustand 스토어 초기화 (메모리 캐시)
|
||||
* 2. sessionStorage 캐시 삭제 (page_config_*, mes-*)
|
||||
* 3. localStorage 사용자 데이터 삭제 (mes-currentUser, mes-users)
|
||||
* 4. 서버 로그아웃 API 호출 (HttpOnly 쿠키 삭제)
|
||||
*
|
||||
* @see claudedocs/security/[PLAN-2025-12-12] tenant-data-isolation-implementation.md
|
||||
*/
|
||||
|
||||
import { useMasterDataStore } from '@/stores/masterDataStore';
|
||||
import { useItemStore } from '@/stores/itemStore';
|
||||
|
||||
// ===== 캐시 삭제 대상 Prefix =====
|
||||
|
||||
const SESSION_STORAGE_PREFIXES = [
|
||||
'page_config_', // masterDataStore 페이지 구성 캐시
|
||||
'mes-', // TenantAwareCache 테넌트 캐시
|
||||
];
|
||||
|
||||
const LOCAL_STORAGE_KEYS = [
|
||||
'mes-currentUser', // 현재 사용자 정보
|
||||
'mes-users', // 사용자 목록
|
||||
];
|
||||
|
||||
const LOCAL_STORAGE_PREFIXES = [
|
||||
'mes-', // 테넌트 캐시 (mes-{tenantId}-*)
|
||||
];
|
||||
|
||||
// ===== 캐시 정리 함수 =====
|
||||
|
||||
/**
|
||||
* sessionStorage에서 우리 앱 캐시만 삭제
|
||||
* - page_config_* (페이지 구성)
|
||||
* - mes-* (테넌트 캐시)
|
||||
*/
|
||||
export function clearSessionStorageCache(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const keysToRemove: string[] = [];
|
||||
|
||||
Object.keys(sessionStorage).forEach((key) => {
|
||||
if (SESSION_STORAGE_PREFIXES.some((prefix) => key.startsWith(prefix))) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
keysToRemove.forEach((key) => {
|
||||
sessionStorage.removeItem(key);
|
||||
console.log(`[Logout] Cleared sessionStorage: ${key}`);
|
||||
});
|
||||
|
||||
if (keysToRemove.length > 0) {
|
||||
console.log(`[Logout] Cleared ${keysToRemove.length} sessionStorage items`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* localStorage에서 사용자 데이터 및 테넌트 캐시 삭제
|
||||
*/
|
||||
export function clearLocalStorageCache(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// 특정 키 삭제
|
||||
LOCAL_STORAGE_KEYS.forEach((key) => {
|
||||
if (localStorage.getItem(key)) {
|
||||
localStorage.removeItem(key);
|
||||
console.log(`[Logout] Cleared localStorage: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Prefix 기반 삭제
|
||||
const keysToRemove: string[] = [];
|
||||
|
||||
Object.keys(localStorage).forEach((key) => {
|
||||
if (LOCAL_STORAGE_PREFIXES.some((prefix) => key.startsWith(prefix))) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
keysToRemove.forEach((key) => {
|
||||
localStorage.removeItem(key);
|
||||
console.log(`[Logout] Cleared localStorage: ${key}`);
|
||||
});
|
||||
|
||||
if (keysToRemove.length > 0) {
|
||||
console.log(`[Logout] Cleared ${keysToRemove.length} localStorage items`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zustand 스토어 초기화
|
||||
*/
|
||||
export function resetZustandStores(): void {
|
||||
try {
|
||||
// masterDataStore 초기화
|
||||
const masterDataStore = useMasterDataStore.getState();
|
||||
masterDataStore.reset();
|
||||
console.log('[Logout] Reset masterDataStore');
|
||||
|
||||
// itemStore 초기화
|
||||
const itemStore = useItemStore.getState();
|
||||
itemStore.reset();
|
||||
console.log('[Logout] Reset itemStore');
|
||||
} catch (error) {
|
||||
console.error('[Logout] Failed to reset Zustand stores:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 로그아웃 API 호출
|
||||
* - HttpOnly 쿠키 삭제 (access_token, refresh_token)
|
||||
*/
|
||||
export async function callLogoutAPI(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[Logout] Server logout failed:', response.status);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('[Logout] Server logout successful');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Logout] Server logout error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 통합 로그아웃 함수 =====
|
||||
|
||||
/**
|
||||
* 완전한 로그아웃 수행
|
||||
*
|
||||
* 실행 순서:
|
||||
* 1. Zustand 스토어 초기화 (즉시 UI 반영)
|
||||
* 2. sessionStorage 캐시 삭제
|
||||
* 3. localStorage 사용자 데이터 삭제
|
||||
* 4. 서버 로그아웃 API 호출
|
||||
*
|
||||
* @param options.skipServerLogout - 서버 로그아웃 생략 여부 (기본: false)
|
||||
* @param options.redirectTo - 로그아웃 후 리다이렉트 경로 (기본: null)
|
||||
* @returns 로그아웃 성공 여부
|
||||
*/
|
||||
export async function performFullLogout(options?: {
|
||||
skipServerLogout?: boolean;
|
||||
redirectTo?: string | null;
|
||||
}): Promise<boolean> {
|
||||
const { skipServerLogout = false, redirectTo = null } = options || {};
|
||||
|
||||
console.log('[Logout] Starting full logout...');
|
||||
|
||||
try {
|
||||
// 1. Zustand 스토어 초기화 (즉시 UI 반영)
|
||||
resetZustandStores();
|
||||
|
||||
// 2. sessionStorage 캐시 삭제
|
||||
clearSessionStorageCache();
|
||||
|
||||
// 3. localStorage 사용자 데이터 삭제
|
||||
clearLocalStorageCache();
|
||||
|
||||
// 4. 서버 로그아웃 API 호출 (HttpOnly 쿠키 삭제)
|
||||
if (!skipServerLogout) {
|
||||
await callLogoutAPI();
|
||||
}
|
||||
|
||||
console.log('[Logout] Full logout completed successfully');
|
||||
|
||||
// 5. 리다이렉트 (선택적)
|
||||
if (redirectTo && typeof window !== 'undefined') {
|
||||
window.location.href = redirectTo;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Logout] Full logout failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 유틸리티 함수 =====
|
||||
|
||||
/**
|
||||
* 현재 캐시 상태 확인 (디버깅용)
|
||||
*/
|
||||
export function debugCacheStatus(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
console.log('[Debug] Server environment - no cache');
|
||||
return;
|
||||
}
|
||||
|
||||
console.group('[Debug] Cache Status');
|
||||
|
||||
// sessionStorage
|
||||
const sessionKeys = Object.keys(sessionStorage).filter((key) =>
|
||||
SESSION_STORAGE_PREFIXES.some((prefix) => key.startsWith(prefix))
|
||||
);
|
||||
console.log('sessionStorage:', sessionKeys);
|
||||
|
||||
// localStorage
|
||||
const localKeys = Object.keys(localStorage).filter(
|
||||
(key) =>
|
||||
LOCAL_STORAGE_KEYS.includes(key) ||
|
||||
LOCAL_STORAGE_PREFIXES.some((prefix) => key.startsWith(prefix))
|
||||
);
|
||||
console.log('localStorage:', localKeys);
|
||||
|
||||
// Zustand
|
||||
const masterDataState = useMasterDataStore.getState();
|
||||
console.log('masterDataStore.pageConfigs:', Object.keys(masterDataState.pageConfigs));
|
||||
|
||||
const itemState = useItemStore.getState();
|
||||
console.log('itemStore.items:', itemState.items.length);
|
||||
|
||||
console.groupEnd();
|
||||
}
|
||||
@@ -19,6 +19,9 @@ import { fetchPageConfigByType, invalidatePageConfigCache } from '@/lib/api/mast
|
||||
interface MasterDataStore {
|
||||
// === State ===
|
||||
|
||||
// 현재 테넌트 ID (캐시 격리용)
|
||||
currentTenantId: number | null;
|
||||
|
||||
// 페이지 구성 캐시 (페이지 타입별)
|
||||
pageConfigs: Record<PageType, PageConfig | null>;
|
||||
|
||||
@@ -36,8 +39,12 @@ interface MasterDataStore {
|
||||
|
||||
// === Actions ===
|
||||
|
||||
// 테넌트 ID 설정 (로그인 시 호출)
|
||||
setCurrentTenantId: (tenantId: number | null) => void;
|
||||
|
||||
// 페이지 구성 가져오기 (하이브리드 로딩)
|
||||
fetchPageConfig: (pageType: PageType) => Promise<PageConfig | null>;
|
||||
// tenantId 파라미터는 선택적 (없으면 currentTenantId 사용)
|
||||
fetchPageConfig: (pageType: PageType, tenantId?: number) => Promise<PageConfig | null>;
|
||||
|
||||
// 페이지 구성 캐시 무효화
|
||||
invalidateConfig: (pageType: PageType) => void;
|
||||
@@ -365,12 +372,19 @@ export const useMasterDataStore = create<MasterDataStore>()(
|
||||
|
||||
// ===== 초기화 =====
|
||||
|
||||
reset: () =>
|
||||
set(
|
||||
initialState,
|
||||
false,
|
||||
'reset'
|
||||
),
|
||||
reset: () => {
|
||||
// 1. 메모리 캐시 초기화
|
||||
set(initialState, false, 'reset');
|
||||
|
||||
// 2. sessionStorage 캐시도 정리
|
||||
if (typeof window !== 'undefined') {
|
||||
const pageTypes: PageType[] = ['item-master', 'quotation', 'sales-order', 'formula', 'pricing'];
|
||||
pageTypes.forEach((pageType) => {
|
||||
removeConfigFromSessionStorage(pageType);
|
||||
});
|
||||
console.log('[masterDataStore] Reset: cleared memory and sessionStorage cache');
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'MasterDataStore',
|
||||
|
||||
Reference in New Issue
Block a user