## 핵심 변경
- 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>
273 lines
7.4 KiB
TypeScript
273 lines
7.4 KiB
TypeScript
/**
|
|
* 관리자 API 클라이언트 (감사 로그, 접근 이력, 로그인 이력, 권한 트리, 역할).
|
|
*/
|
|
|
|
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
|
|
|
export interface PageResponse<T> {
|
|
content: T[];
|
|
totalElements: number;
|
|
totalPages: number;
|
|
number: number;
|
|
size: number;
|
|
}
|
|
|
|
export interface AuditLog {
|
|
auditSn: number;
|
|
userId: string | null;
|
|
userAcnt: string | null;
|
|
actionCd: string;
|
|
resourceType: string | null;
|
|
resourceId: string | null;
|
|
detail: Record<string, unknown> | null;
|
|
ipAddress: string | null;
|
|
result: string | null;
|
|
failReason: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface AccessLog {
|
|
accessSn: number;
|
|
userId: string | null;
|
|
userAcnt: string | null;
|
|
httpMethod: string;
|
|
requestPath: string;
|
|
queryString: string | null;
|
|
statusCode: number;
|
|
durationMs: number;
|
|
ipAddress: string | null;
|
|
userAgent: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface LoginHistory {
|
|
histSn: number;
|
|
userId: string | null;
|
|
userAcnt: string;
|
|
loginDtm: string;
|
|
loginIp: string | null;
|
|
userAgent: string | null;
|
|
result: string;
|
|
failReason: string | null;
|
|
authProvider: string | null;
|
|
}
|
|
|
|
export interface PermTreeNode {
|
|
rsrcCd: string;
|
|
parentCd: string | null;
|
|
rsrcNm: string;
|
|
rsrcDesc: string | null;
|
|
icon: string | null;
|
|
rsrcLevel: number;
|
|
sortOrd: number;
|
|
useYn: string;
|
|
/** V021: 메뉴 SSOT */
|
|
labelKey: string | null;
|
|
urlPath: string | null;
|
|
navSort: number;
|
|
/** V022: DB i18n JSONB {"ko":"...", "en":"..."} */
|
|
labels: Record<string, string>;
|
|
}
|
|
|
|
export interface RoleWithPermissions {
|
|
roleSn: number;
|
|
roleCd: string;
|
|
roleNm: string;
|
|
roleDc: string;
|
|
/** UI 표기 색상 (#RRGGBB). NULL이면 프론트 기본 팔레트에서 결정 */
|
|
colorHex: string | null;
|
|
dfltYn: string;
|
|
builtinYn: string;
|
|
permissions: { permSn: number; roleSn: number; rsrcCd: string; operCd: string; grantYn: string }[];
|
|
}
|
|
|
|
async function apiGet<T>(path: string): Promise<T> {
|
|
const res = await fetch(`${API_BASE}${path}`, { credentials: 'include' });
|
|
if (!res.ok) throw new Error(`API ${res.status}: ${path}`);
|
|
return res.json();
|
|
}
|
|
|
|
export function fetchAuditLogs(page = 0, size = 50) {
|
|
return apiGet<PageResponse<AuditLog>>(`/admin/audit-logs?page=${page}&size=${size}`);
|
|
}
|
|
|
|
export function fetchAccessLogs(page = 0, size = 50) {
|
|
return apiGet<PageResponse<AccessLog>>(`/admin/access-logs?page=${page}&size=${size}`);
|
|
}
|
|
|
|
export function fetchLoginHistory(page = 0, size = 50) {
|
|
return apiGet<PageResponse<LoginHistory>>(`/admin/login-history?page=${page}&size=${size}`);
|
|
}
|
|
|
|
export function fetchPermTree() {
|
|
return apiGet<PermTreeNode[]>('/perm-tree');
|
|
}
|
|
|
|
import { updateRoleColorCache } from '@shared/constants/userRoles';
|
|
|
|
export async function fetchRoles(): Promise<RoleWithPermissions[]> {
|
|
const roles = await apiGet<RoleWithPermissions[]>('/roles');
|
|
// 역할 색상 캐시 즉시 갱신 → 모든 사용처 자동 반영
|
|
updateRoleColorCache(roles);
|
|
return roles;
|
|
}
|
|
|
|
// ─── 역할 CRUD ───────────────────────────────
|
|
export interface RoleCreatePayload {
|
|
roleCd: string;
|
|
roleNm: string;
|
|
roleDc?: string;
|
|
colorHex?: string;
|
|
dfltYn?: string;
|
|
}
|
|
|
|
export interface RoleUpdatePayload {
|
|
roleNm?: string;
|
|
roleDc?: string;
|
|
colorHex?: string;
|
|
dfltYn?: string;
|
|
}
|
|
|
|
async function apiSend<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
method,
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (!res.ok) {
|
|
let msg = `API ${res.status}`;
|
|
try { const b = await res.json(); if (b?.message) msg += `: ${b.message}`; } catch { /* */ }
|
|
throw new Error(msg);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export function createRole(payload: RoleCreatePayload) {
|
|
return apiSend<{ roleSn: number; roleCd: string; roleNm: string }>('POST', '/roles', payload);
|
|
}
|
|
|
|
export function updateRole(roleSn: number, payload: RoleUpdatePayload) {
|
|
return apiSend('PUT', `/roles/${roleSn}`, payload);
|
|
}
|
|
|
|
export function deleteRole(roleSn: number) {
|
|
return apiSend('DELETE', `/roles/${roleSn}`);
|
|
}
|
|
|
|
// ─── 권한 매트릭스 갱신 ────────────────────────
|
|
export interface PermEntry {
|
|
rsrcCd: string;
|
|
operCd: string;
|
|
grantYn: 'Y' | 'N' | null; // null = 명시 권한 제거 (상속 모드)
|
|
}
|
|
|
|
export function updateRolePermissions(roleSn: number, permissions: PermEntry[]) {
|
|
return apiSend<{ ok: boolean; changed: number }>('PUT', `/roles/${roleSn}/permissions`, { permissions });
|
|
}
|
|
|
|
// ─── 사용자 역할 배정 ─────────────────────────
|
|
export function assignUserRoles(userId: string, roleSns: number[]) {
|
|
return apiSend<{ userId: string; roles: string[] }>('PUT', `/admin/users/${userId}/roles`, { roleSns });
|
|
}
|
|
|
|
// ============================================================================
|
|
// 사용자 관리
|
|
// ============================================================================
|
|
|
|
export interface AdminUser {
|
|
userId: string;
|
|
userAcnt: string;
|
|
userNm: string;
|
|
rnkpNm: string | null;
|
|
email: string | null;
|
|
userSttsCd: string;
|
|
authProvider: string;
|
|
failCnt: number;
|
|
lastLoginDtm: string | null;
|
|
createdAt: string;
|
|
roles: string[];
|
|
}
|
|
|
|
export interface UserStats {
|
|
total: number;
|
|
active: number;
|
|
locked: number;
|
|
inactive: number;
|
|
pending: number;
|
|
byStatus: Record<string, number>;
|
|
byProvider: Record<string, number>;
|
|
byRole: Record<string, number>;
|
|
}
|
|
|
|
export function fetchUsers() {
|
|
return apiGet<AdminUser[]>('/admin/users');
|
|
}
|
|
|
|
export function fetchUserStats() {
|
|
return apiGet<UserStats>('/admin/users/stats');
|
|
}
|
|
|
|
export async function unlockUser(userId: string) {
|
|
const res = await fetch(`${API_BASE}/admin/users/${userId}/unlock`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
});
|
|
if (!res.ok) throw new Error(`API ${res.status}: unlock`);
|
|
return res.json();
|
|
}
|
|
|
|
export async function changeUserStatus(userId: string, status: string) {
|
|
const res = await fetch(`${API_BASE}/admin/users/${userId}/status`, {
|
|
method: 'PUT',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ status }),
|
|
});
|
|
if (!res.ok) throw new Error(`API ${res.status}: status`);
|
|
return res.json();
|
|
}
|
|
|
|
// ============================================================================
|
|
// 통계 (대시보드 카드)
|
|
// ============================================================================
|
|
|
|
export interface AuditStats {
|
|
total: number;
|
|
last24h: number;
|
|
failed24h: number;
|
|
byAction: { action: string; count: number }[];
|
|
hourly24: { hour: string; count: number }[];
|
|
}
|
|
|
|
export interface AccessStats {
|
|
total: number;
|
|
last24h: number;
|
|
error4xx: number;
|
|
error5xx: number;
|
|
avgDurationMs: number;
|
|
topPaths: { path: string; count: number; avg_ms: number }[];
|
|
}
|
|
|
|
export interface LoginStats {
|
|
total: number;
|
|
success24h: number;
|
|
failed24h: number;
|
|
locked24h: number;
|
|
successRate: number;
|
|
byUser: { user_acnt: string; count: number }[];
|
|
daily7d: { day: string; success: number; failed: number; locked: number }[];
|
|
}
|
|
|
|
export function fetchAuditStats() {
|
|
return apiGet<AuditStats>('/admin/stats/audit');
|
|
}
|
|
|
|
export function fetchAccessStats() {
|
|
return apiGet<AccessStats>('/admin/stats/access');
|
|
}
|
|
|
|
export function fetchLoginStats() {
|
|
return apiGet<LoginStats>('/admin/stats/login');
|
|
}
|