## 핵심 변경
- 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>
189 lines
6.1 KiB
TypeScript
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;
|
|
}
|