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; 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; logout: () => Promise; /** 경로 기반 접근 가능 여부 (메뉴/라우트 가드용) */ hasAccess: (path: string) => boolean; /** 트리 기반 권한 체크 (resource + operation) */ hasPermission: (resource: string, operation?: string) => boolean; sessionRemaining: number; } const AuthContext = createContext(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, 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(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 ( {children} ); } export function useAuth() { const ctx = useContext(AuthContext); if (!ctx) throw new Error('useAuth must be inside AuthProvider'); return ctx; }