kcg-ai-monitoring/frontend/src/app/auth/AuthContext.tsx
htlee 6fe7a7daf4 feat: 메뉴 DB SSOT 구조화 — auth_perm_tree 기반 메뉴·권한·i18n 통합
## 핵심 변경
- auth_perm_tree를 메뉴 SSOT로 확장 (V020~V024)
  - url_path, label_key, component_key, nav_group, nav_sub_group, nav_sort 컬럼
  - labels JSONB (다국어: {"ko":"...", "en":"..."})
- 보이지 않는 도메인 그룹 8개 삭제 (surveillance, detection, risk-assessment 등)
  - 권한 트리 = 메뉴 트리 완전 동기화
  - 그룹 레벨 권한 → 개별 자식 권한으로 확장 후 그룹 삭제
- 패널 노드 parent_cd를 실제 소속 페이지로 수정
  (어구식별→어구탐지, 전역제외→후보제외, 역할관리→권한관리)
- vessel:vessel-detail 권한 노드 제거 (드릴다운 전용, 인증만 체크)

## 백엔드
- MenuConfigService: auth_perm_tree에서 menuConfig DTO 생성
- /api/auth/me 응답에 menuConfig 포함 (로그인 시 프리로드)
- @RequirePermission 12곳 수정 (삭제된 그룹명 → 구체적 자식 리소스)
- Caffeine 캐시 menuConfig 추가

## 프론트엔드
- NAV_ENTRIES 하드코딩 제거 → menuStore(Zustand) 동적 렌더링
- PATH_TO_RESOURCE 하드코딩 제거 → DB 기반 longest-match
- App.tsx 36개 정적 import/33개 Route → DynamicRoutes + componentRegistry
- PermissionsPanel: DB labels JSONB 기반 표시명 + 페이지/패널 아이콘 구분
- DB migration README.md 전면 재작성 (V001~V024, 49테이블, 149인덱스)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:54:04 +09:00

189 lines
6.1 KiB
TypeScript

import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser } from '@/services/authApi';
import { useMenuStore } from '@stores/menuStore';
/*
* SFR-01: 시스템 로그인 및 권한 관리
* - 백엔드 JWT 쿠키 기반 인증
* - 트리 기반 RBAC (백엔드의 auth_perm_tree + auth_perm)
* - 다중 역할 + 부모 fallback (예: detection:gear-detection 미존재 시 detection 검사)
* - 세션 타임아웃: 30분 미사용 시 자동 로그아웃
* - 로그인 이력 + 감사로그는 백엔드 DB(kcgaidb)에 기록
*/
// ─── RBAC 역할 정의 ─────────────────────
export type UserRole = 'ADMIN' | 'OPERATOR' | 'ANALYST' | 'FIELD' | 'VIEWER';
export interface AuthUser {
id: string;
/** 로그인 ID */
account: string;
name: string;
rank: string;
org: string;
/** 다중 역할 (백엔드는 배열 반환) */
roles: UserRole[];
/** 1차 역할 (기존 코드 호환) */
role: UserRole;
/** 권한 트리: rsrcCd → operations[] */
permissions: Record<string, string[]>;
authMethod: 'password' | 'gpki' | 'sso';
loginAt: string;
}
// ─── 세션 타임아웃 (30분) ──────────────────
const SESSION_TIMEOUT = 30 * 60 * 1000;
interface AuthContextType {
user: AuthUser | null;
loading: boolean;
/** ID/PW 로그인 (백엔드 호출) */
login: (account: string, password: string) => Promise<void>;
logout: () => Promise<void>;
/** 경로 기반 접근 가능 여부 (메뉴/라우트 가드용) */
hasAccess: (path: string) => boolean;
/** 트리 기반 권한 체크 (resource + operation) */
hasPermission: (resource: string, operation?: string) => boolean;
sessionRemaining: number;
}
const AuthContext = createContext<AuthContextType | null>(null);
function backendToAuthUser(b: BackendUser): AuthUser {
const primaryRole = (b.roles[0] ?? 'VIEWER') as UserRole;
return {
id: b.id,
account: b.account,
name: b.name,
rank: b.rank ?? '',
org: '', // 향후 백엔드에서 org_sn/org_nm 추가 시 채움
roles: b.roles as UserRole[],
role: primaryRole,
permissions: b.permissions,
authMethod: (b.authProvider?.toLowerCase() as AuthUser['authMethod']) ?? 'password',
loginAt: new Date().toISOString().replace('T', ' ').slice(0, 19),
};
}
/**
* 트리 기반 권한 체크 (부모 fallback 지원).
* "detection:gear-detection"이 직접 등록되지 않았으면 "detection" 부모를 검사.
*/
function checkPermission(perms: Record<string, string[]>, resource: string, operation: string): boolean {
const ops = perms[resource];
if (ops && ops.includes(operation)) return true;
// 부모 fallback
const colonIdx = resource.indexOf(':');
if (colonIdx > 0) {
const parent = resource.substring(0, colonIdx);
const parentOps = perms[parent];
return !!parentOps && parentOps.includes(operation);
}
return false;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
const [lastActivity, setLastActivity] = useState(Date.now());
const [sessionRemaining, setSessionRemaining] = useState(SESSION_TIMEOUT / 1000);
// 초기 세션 복원: /api/auth/me 호출
useEffect(() => {
let alive = true;
fetchMe()
.then((b) => {
if (alive && b) {
setUser(backendToAuthUser(b));
if (b.menuConfig) useMenuStore.getState().setMenuConfig(b.menuConfig);
}
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, []);
// 사용자 활동 감지 → 세션 갱신
const resetActivity = useCallback(() => {
setLastActivity(Date.now());
}, []);
useEffect(() => {
if (!user) return;
const events = ['mousedown', 'keydown', 'scroll', 'touchstart'];
events.forEach((e) => window.addEventListener(e, resetActivity));
return () => events.forEach((e) => window.removeEventListener(e, resetActivity));
}, [user, resetActivity]);
// 세션 타임아웃 체크
useEffect(() => {
if (!user) return;
const interval = setInterval(() => {
const elapsed = Date.now() - lastActivity;
const remaining = Math.max(0, Math.floor((SESSION_TIMEOUT - elapsed) / 1000));
setSessionRemaining(remaining);
if (elapsed >= SESSION_TIMEOUT) {
logoutApi().catch(() => undefined);
setUser(null);
}
}, 1000);
return () => clearInterval(interval);
}, [user, lastActivity]);
const login = useCallback(async (account: string, password: string) => {
try {
const b = await loginApi(account, password);
setUser(backendToAuthUser(b));
if (b.menuConfig) useMenuStore.getState().setMenuConfig(b.menuConfig);
setLastActivity(Date.now());
} catch (e) {
if (e instanceof LoginError) throw e;
throw new LoginError('NETWORK_ERROR');
}
}, []);
const logout = useCallback(async () => {
try {
await logoutApi();
} finally {
setUser(null);
useMenuStore.getState().clear();
}
}, []);
const hasPermission = useCallback(
(resource: string, operation: string = 'READ') => {
if (!user) return false;
return checkPermission(user.permissions, resource, operation);
},
[user],
);
const hasAccess = useCallback(
(path: string) => {
if (!user) return false;
// DB menu_config 기반 longest-match (PATH_TO_RESOURCE 대체)
const resource = useMenuStore.getState().getResourceForPath(path);
if (!resource) return true;
return hasPermission(resource, 'READ');
},
[user, hasPermission],
);
return (
<AuthContext.Provider value={{ user, loading, login, logout, hasAccess, hasPermission, sessionRemaining }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be inside AuthProvider');
return ctx;
}