Files
sam-react-prod/src/lib/utils/menuRefresh.ts
유병철 55e0791e16 refactor(WEB): Server Action 공통화 및 보안 강화
- executeServerAction 공통 유틸 도입으로 actions.ts 대폭 간소화 (50+개 파일)
- sanitize 유틸 추가 (XSS 방지)
- middleware CSP 헤더 추가 및 Open Redirect 방지
- 프록시 라우트 로깅 개발환경 한정으로 변경
- 프로덕션 불필요 console.log 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:14:06 +09:00

199 lines
5.3 KiB
TypeScript

/**
* 메뉴 동적 갱신 유틸리티
*
* 관리자가 게시판/메뉴 추가 시 재로그인 없이 메뉴를 갱신합니다.
*
* @see claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md
*/
import { transformApiMenusToMenuItems, deserializeMenuItems } from './menuTransform';
import { useMenuStore } from '@/store/menuStore';
import type { SerializableMenuItem } from '@/store/menuStore';
/**
* 메뉴 해시 생성 (변경 감지용)
* 메뉴 ID들을 정렬하여 해시 생성
*/
function generateMenuHash(menus: SerializableMenuItem[]): string {
const collectIds = (items: SerializableMenuItem[]): string[] => {
return items.flatMap(item => [
item.id,
...(item.children ? collectIds(item.children) : [])
]);
};
return collectIds(menus).sort().join(',');
}
/**
* 현재 저장된 메뉴 해시 가져오기
*/
export function getCurrentMenuHash(): string {
if (typeof window === 'undefined') return '';
try {
const userData = localStorage.getItem('user');
if (!userData) return '';
const parsed = JSON.parse(userData);
if (!parsed.menu || !Array.isArray(parsed.menu)) return '';
return generateMenuHash(parsed.menu);
} catch {
return '';
}
}
/**
* 메뉴 갱신 결과 타입
*/
interface RefreshMenuResult {
success: boolean;
updated: boolean; // 실제로 메뉴가 변경되었는지
sessionExpired?: boolean; // 세션 만료 여부 (401 응답)
error?: string;
}
/**
* 메뉴 갱신 함수
*
* 1. API에서 새 메뉴 받아오기
* 2. 기존 메뉴와 비교 (해시)
* 3. 변경 시 localStorage + Zustand 업데이트
*
* @returns 갱신 결과
*/
export async function refreshMenus(): Promise<RefreshMenuResult> {
try {
// 1. 현재 메뉴 해시 저장
const currentHash = getCurrentMenuHash();
// 2. API에서 새 메뉴 받아오기
const response = await fetch('/api/proxy/menus', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
// 401 인증 오류 → 세션 만료로 판단
if (response.status === 401) {
console.log('[Menu] 401 응답 - 세션 만료');
return { success: false, updated: false, sessionExpired: true };
}
return {
success: false,
updated: false,
error: `API 오류: ${response.status}`
};
}
const data = await response.json();
if (!data.menus || !Array.isArray(data.menus)) {
return {
success: false,
updated: false,
error: '메뉴 데이터 형식 오류'
};
}
// 3. 메뉴 변환
const transformedMenus = transformApiMenusToMenuItems(data.menus);
const newHash = generateMenuHash(transformedMenus);
// 4. 변경 없으면 업데이트 스킵
if (currentHash === newHash) {
return { success: true, updated: false };
}
// 5. localStorage 업데이트 (새로고침 대응)
const userData = localStorage.getItem('user');
if (userData) {
try {
const parsed = JSON.parse(userData);
parsed.menu = transformedMenus;
localStorage.setItem('user', JSON.stringify(parsed));
} catch {
// localStorage 데이터 손상 시 무시
}
}
// 6. Zustand 스토어 업데이트 (UI 즉시 반영)
const { setMenuItems } = useMenuStore.getState();
setMenuItems(deserializeMenuItems(transformedMenus));
console.log('[Menu] 메뉴 갱신 완료 - 변경 감지됨');
return { success: true, updated: true };
} catch (error) {
console.error('[Menu] 메뉴 갱신 실패:', error);
return {
success: false,
updated: false,
error: error instanceof Error ? error.message : '알 수 없는 오류'
};
}
}
/**
* 메뉴 강제 갱신 (비교 없이 무조건 갱신)
*/
export async function forceRefreshMenus(): Promise<RefreshMenuResult> {
try {
const response = await fetch('/api/proxy/menus', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
return {
success: false,
updated: false,
error: `API 오류: ${response.status}`
};
}
const data = await response.json();
if (!data.menus || !Array.isArray(data.menus)) {
return {
success: false,
updated: false,
error: '메뉴 데이터 형식 오류'
};
}
const transformedMenus = transformApiMenusToMenuItems(data.menus);
// localStorage 업데이트
const userData = localStorage.getItem('user');
if (userData) {
try {
const parsed = JSON.parse(userData);
parsed.menu = transformedMenus;
localStorage.setItem('user', JSON.stringify(parsed));
} catch {
// localStorage 데이터 손상 시 무시
}
}
// Zustand 스토어 업데이트
const { setMenuItems } = useMenuStore.getState();
setMenuItems(deserializeMenuItems(transformedMenus));
console.log('[Menu] 메뉴 강제 갱신 완료');
return { success: true, updated: true };
} catch (error) {
console.error('[Menu] 메뉴 강제 갱신 실패:', error);
return {
success: false,
updated: false,
error: error instanceof Error ? error.message : '알 수 없는 오류'
};
}
}