kcg-ai-monitoring/frontend/src/features/admin/PermissionsPanel.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

526 lines
22 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Fragment, useEffect, useState, useCallback, useMemo } from 'react';
import {
Loader2, Save, Plus, Trash2, RefreshCw, ChevronRight, ChevronDown,
ExternalLink, Layers,
} from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import {
fetchRoles, fetchPermTree, createRole, deleteRole, updateRolePermissions,
type RoleWithPermissions, type PermTreeNode, type PermEntry,
} from '@/services/adminApi';
import {
resolveSingleRoleEffective, OPERATIONS,
type Operation, type TreeNode, type PermRow,
} from '@/lib/permission/permResolver';
import { useAuth } from '@/app/auth/AuthContext';
import { useSettingsStore } from '@stores/settingsStore';
import { getRoleBadgeStyle, ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles';
import { ColorPicker } from '@shared/components/common/ColorPicker';
import { updateRole as apiUpdateRole } from '@/services/adminApi';
/**
* 트리 기반 권한 관리 패널 (wing 패턴).
*
* - 좌측: 역할 목록
* - 우측: 권한 트리 + R/C/U/D/E 체크박스 매트릭스
*
* 셀 상태 (4가지):
* • explicit-granted (✓ 파랑) - 명시적 Y
* • explicit-denied (— 빨강) - 명시적 N
* • inherited-granted (✓ 연파랑) - 부모로부터 상속
* • forced-denied (회색) - 부모 READcandid가 N → 강제 거부
*
* 클릭 사이클: explicit-granted → explicit-denied → 미지정(상속) → ...
*
* 권한:
* - admin:role-management (READ): 역할 목록 조회
* - admin:role-management (CREATE/DELETE): 역할 생성/삭제
* - admin:permission-management (UPDATE): 권한 매트릭스 갱신
*/
type DraftPerms = Map<string, 'Y' | 'N' | null>; // null = 명시 권한 제거
function makeKey(rsrcCd: string, operCd: string) { return `${rsrcCd}::${operCd}`; }
export function PermissionsPanel() {
const { hasPermission } = useAuth();
const canCreateRole = hasPermission('admin:role-management', 'CREATE');
const canDeleteRole = hasPermission('admin:role-management', 'DELETE');
const canUpdatePerm = hasPermission('admin:permission-management', 'UPDATE');
const [roles, setRoles] = useState<RoleWithPermissions[]>([]);
const [tree, setTree] = useState<PermTreeNode[]>([]);
const [selectedRoleSn, setSelectedRoleSn] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [draftPerms, setDraftPerms] = useState<DraftPerms>(new Map());
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [showCreate, setShowCreate] = useState(false);
const [newRoleCd, setNewRoleCd] = useState('');
const [newRoleNm, setNewRoleNm] = useState('');
const [newRoleColor, setNewRoleColor] = useState<string>(ROLE_DEFAULT_PALETTE[0]);
const [editingColor, setEditingColor] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const [r, t] = await Promise.all([fetchRoles(), fetchPermTree()]);
setRoles(r);
setTree(t);
if (r.length > 0 && selectedRoleSn === null) {
setSelectedRoleSn(r[0].roleSn);
}
// Level 0 노드 자동 펼침
setExpanded(new Set(t.filter((n) => n.rsrcLevel === 0).map((n) => n.rsrcCd)));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown');
} finally {
setLoading(false);
}
}, [selectedRoleSn]);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { load(); /* 최초 1회만 로드 */ }, []);
// 역할 선택 시 draft 초기화
const selectedRole = useMemo(
() => roles.find((r) => r.roleSn === selectedRoleSn) ?? null,
[roles, selectedRoleSn],
);
useEffect(() => {
if (!selectedRole) return;
const m: DraftPerms = new Map();
for (const p of selectedRole.permissions) {
m.set(makeKey(p.rsrcCd, p.operCd), p.grantYn as 'Y' | 'N');
}
setDraftPerms(m);
}, [selectedRole]);
// 트리 → 트리 인덱싱 (parent → children), nav_sort 기반 정렬 (메뉴 순서 일치)
const childrenMap = useMemo(() => {
const m = new Map<string | null, PermTreeNode[]>();
for (const n of tree) {
if (n.useYn !== 'Y') continue;
const arr = m.get(n.parentCd) ?? [];
arr.push(n);
m.set(n.parentCd, arr);
}
// nav_sort > 0 우선 (메뉴 표시 항목), 그 다음 sort_ord — 좌측 메뉴 순서와 일치
for (const [, arr] of m.entries()) {
arr.sort((a, b) => {
const aSort = (a.navSort > 0) ? a.navSort : 10000 + a.sortOrd;
const bSort = (b.navSort > 0) ? b.navSort : 10000 + b.sortOrd;
return aSort - bSort;
});
}
return m;
}, [tree]);
// draft 기반 effective 권한 해석 (PermResolver TS 미러)
const effective = useMemo(() => {
const treeNodes: TreeNode[] = tree.map((n) => ({
rsrcCd: n.rsrcCd, parentCd: n.parentCd, rsrcNm: n.rsrcNm,
rsrcLevel: n.rsrcLevel, sortOrd: n.sortOrd, useYn: n.useYn,
}));
const perms: PermRow[] = [];
draftPerms.forEach((v, k) => {
if (v === 'Y' || v === 'N') {
const [rsrcCd, operCd] = k.split('::');
perms.push({ rsrcCd, operCd, grantYn: v });
}
});
return resolveSingleRoleEffective(treeNodes, perms);
}, [tree, draftPerms]);
const cellState = useCallback((rsrcCd: string, operCd: Operation, parentCd: string | null) => {
const key = makeKey(rsrcCd, operCd);
const explicit = draftPerms.get(key);
// 1) 부모 노드의 effective READ가 거부되면 자식의 모든 작업 강제 거부
let parentReadDenied = false;
if (parentCd) {
const parentEff = effective.get(parentCd);
parentReadDenied = !parentEff || !parentEff.has('READ');
}
if (parentReadDenied) return 'forced-denied';
// 2) 같은 노드의 READ가 effective로 거부되면 C/U/D/E도 강제 거부
// (READ가 안 되면 그 페이지/리소스 자체에 접근 못 하므로 다른 작업 권한도 의미 없음)
if (operCd !== 'READ') {
const ownEff = effective.get(rsrcCd);
const ownReadGranted = ownEff?.has('READ') ?? false;
if (!ownReadGranted) return 'forced-denied';
}
if (explicit === 'Y') return 'explicit-granted';
if (explicit === 'N') return 'explicit-denied';
// 상속 체크
const eff = effective.get(rsrcCd);
if (eff?.has(operCd)) return 'inherited-granted';
return 'inherited-denied';
}, [draftPerms, effective]);
const isDirty = useMemo(() => {
if (!selectedRole) return false;
const original = new Map<string, 'Y' | 'N'>();
for (const p of selectedRole.permissions) {
original.set(makeKey(p.rsrcCd, p.operCd), p.grantYn as 'Y' | 'N');
}
if (original.size !== Array.from(draftPerms.values()).filter((v) => v !== null).length) {
return true;
}
for (const [k, v] of draftPerms) {
if (v === null) {
if (original.has(k)) return true;
} else if (original.get(k) !== v) {
return true;
}
}
return false;
}, [selectedRole, draftPerms]);
// 셀 클릭: explicit Y → explicit N → 미지정(상속) → ...
const handleCellClick = (rsrcCd: string, operCd: Operation) => {
if (!canUpdatePerm) return;
const key = makeKey(rsrcCd, operCd);
setDraftPerms((prev) => {
const next = new Map(prev);
const cur = next.get(key);
if (cur === 'Y') next.set(key, 'N');
else if (cur === 'N') next.set(key, null); // 명시 권한 제거
else next.set(key, 'Y');
return next;
});
};
const handleSave = async () => {
if (!selectedRole || !canUpdatePerm) return;
setSaving(true); setError('');
try {
const original = new Map<string, 'Y' | 'N'>();
for (const p of selectedRole.permissions) {
original.set(makeKey(p.rsrcCd, p.operCd), p.grantYn as 'Y' | 'N');
}
// 변경된 셀만 수집
const changes: PermEntry[] = [];
const allKeys = new Set([...original.keys(), ...draftPerms.keys()]);
for (const k of allKeys) {
const [rsrcCd, operCd] = k.split('::');
const oldVal = original.get(k);
const newVal = draftPerms.get(k);
if (newVal === null && oldVal !== undefined) {
changes.push({ rsrcCd, operCd, grantYn: null });
} else if ((newVal === 'Y' || newVal === 'N') && newVal !== oldVal) {
changes.push({ rsrcCd, operCd, grantYn: newVal });
}
}
if (changes.length === 0) {
setSaving(false);
return;
}
await updateRolePermissions(selectedRole.roleSn, changes);
await load(); // 새로 가져와서 동기화
alert(`권한 ${changes.length}건 갱신되었습니다.`);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown');
} finally {
setSaving(false);
}
};
const handleCreateRole = async () => {
if (!newRoleCd || !newRoleNm) return;
try {
await createRole({ roleCd: newRoleCd, roleNm: newRoleNm, colorHex: newRoleColor });
setShowCreate(false);
setNewRoleCd(''); setNewRoleNm('');
setNewRoleColor(ROLE_DEFAULT_PALETTE[0]);
await load();
} catch (e: unknown) {
alert('생성 실패: ' + (e instanceof Error ? e.message : 'unknown'));
}
};
const handleUpdateColor = async (roleSn: number, hex: string) => {
try {
await apiUpdateRole(roleSn, { colorHex: hex });
await load();
setEditingColor(null);
} catch (e: unknown) {
alert('색상 변경 실패: ' + (e instanceof Error ? e.message : 'unknown'));
}
};
const handleDeleteRole = async () => {
if (!selectedRole) return;
if (selectedRole.builtinYn === 'Y') {
alert('내장 역할은 삭제할 수 없습니다.');
return;
}
if (!confirm(`"${selectedRole.roleNm}" 역할을 삭제하시겠습니까?`)) return;
try {
await deleteRole(selectedRole.roleSn);
setSelectedRoleSn(null);
await load();
} catch (e: unknown) {
alert('삭제 실패: ' + (e instanceof Error ? e.message : 'unknown'));
}
};
const toggleExpand = (rsrcCd: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(rsrcCd)) next.delete(rsrcCd); else next.add(rsrcCd);
return next;
});
};
const renderTreeRow = (node: PermTreeNode, depth: number): React.ReactNode => {
const children = childrenMap.get(node.rsrcCd) ?? [];
const hasChildren = children.length > 0;
const isExpanded = expanded.has(node.rsrcCd);
// DB labels JSONB에서 현재 언어 라벨 사용, 없으면 rsrcNm 폴백
const lang = useSettingsStore.getState().language;
const displayName = node.labels?.[lang] || node.labels?.ko || node.rsrcNm;
return (
<Fragment key={node.rsrcCd}>
<tr className="border-t border-border hover:bg-surface-overlay/30">
<td className="py-1.5 pl-2" style={{ paddingLeft: 8 + depth * 20 }}>
<div className="flex items-center gap-1">
{hasChildren ? (
<button type="button" onClick={() => toggleExpand(node.rsrcCd)}
className="p-0.5 text-hint hover:text-heading">
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button>
) : <span className="w-4" />}
{/* 페이지/패널 구분 아이콘 */}
{node.urlPath
? <span title="별도 페이지"><ExternalLink className="w-3 h-3 text-cyan-500/60 shrink-0" /></span>
: depth > 0 ? <span title="페이지 내 패널"><Layers className="w-3 h-3 text-amber-500/50 shrink-0" /></span> : null
}
<span className="text-[11px] text-heading font-medium">{displayName}</span>
<span className="text-[9px] text-hint font-mono">({node.rsrcCd})</span>
{node.urlPath && <span className="text-[8px] text-cyan-500/70 font-mono">{node.urlPath}</span>}
</div>
</td>
{OPERATIONS.map((op) => {
const state = cellState(node.rsrcCd, op as Operation, node.parentCd);
const cls =
state === 'explicit-granted' ? 'bg-blue-500 text-white border-blue-400 font-bold'
: state === 'inherited-granted' ? 'bg-blue-500/30 text-blue-300 border-blue-500/40'
: state === 'explicit-denied' ? 'bg-red-500/40 text-red-300 border-red-500/50 font-bold'
: state === 'forced-denied' ? 'bg-gray-700/40 text-gray-600 border-gray-700/40 cursor-not-allowed'
: 'bg-surface-overlay text-hint border-border';
const icon =
state === 'explicit-granted' || state === 'inherited-granted' ? '✓'
: state === 'explicit-denied' ? '—'
: state === 'forced-denied' ? '×'
: '·';
return (
<td key={op} className="text-center py-1.5">
<button
type="button"
disabled={!canUpdatePerm || state === 'forced-denied'}
onClick={() => handleCellClick(node.rsrcCd, op as Operation)}
className={`w-7 h-6 rounded border text-[11px] transition-colors ${cls} ${canUpdatePerm && state !== 'forced-denied' ? 'hover:opacity-80 cursor-pointer' : ''}`}
title={`${op} - ${state}`}
>
{icon}
</button>
</td>
);
})}
</tr>
{isExpanded && children.map((c) => renderTreeRow(c, depth + 1))}
</Fragment>
);
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-bold text-heading"> ( RBAC)</h2>
<p className="text-[10px] text-hint mt-0.5">
(Y N )
</p>
</div>
<div className="flex items-center gap-1">
<button type="button" onClick={load}
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
</div>
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
{!loading && (
<div className="grid grid-cols-12 gap-3">
{/* 좌측: 역할 목록 */}
<Card className="col-span-3">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-label font-bold"></div>
<div className="flex items-center gap-1">
{canCreateRole && (
<button type="button" onClick={() => setShowCreate(!showCreate)}
className="p-1 text-hint hover:text-green-400" title="신규 역할">
<Plus className="w-3.5 h-3.5" />
</button>
)}
{canDeleteRole && selectedRole && selectedRole.builtinYn !== 'Y' && (
<button type="button" onClick={handleDeleteRole}
className="p-1 text-hint hover:text-red-400" title="역할 삭제">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{showCreate && (
<div className="mb-2 p-2 bg-surface-overlay rounded space-y-1.5">
<input aria-label="역할 코드" value={newRoleCd} onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
placeholder="ROLE_CD (대문자)"
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
<input aria-label="역할 이름" value={newRoleNm} onChange={(e) => setNewRoleNm(e.target.value)}
placeholder="역할 이름"
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
<ColorPicker label="배지 색상" value={newRoleColor} onChange={setNewRoleColor} />
<div className="flex gap-1 pt-1">
<Button variant="primary" size="sm" onClick={handleCreateRole} disabled={!newRoleCd || !newRoleNm} className="flex-1">
</Button>
<Button variant="secondary" size="sm" onClick={() => setShowCreate(false)} className="flex-1">
</Button>
</div>
</div>
)}
<div className="space-y-1">
{roles.map((r) => {
const selected = r.roleSn === selectedRoleSn;
const isEditingColor = editingColor === String(r.roleSn);
return (
<div
key={r.roleSn}
className={`px-2 py-1.5 rounded border transition-colors ${
selected
? 'bg-blue-600/20 border-blue-500/40 text-heading'
: 'bg-surface-overlay border-border text-muted-foreground hover:text-heading hover:bg-surface-overlay/80'
}`}
>
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setSelectedRoleSn(r.roleSn)}
className="flex items-center gap-1.5 cursor-pointer"
title="역할 선택"
>
<Badge size="sm" style={getRoleBadgeStyle(r.roleCd)}>
{r.roleCd}
</Badge>
</button>
<div className="flex items-center gap-1">
{r.builtinYn === 'Y' && <span className="text-[8px] text-hint">BUILT-IN</span>}
{canUpdatePerm && (
<button
type="button"
onClick={() => setEditingColor(isEditingColor ? null : String(r.roleSn))}
className="text-[8px] text-hint hover:text-blue-400"
title="색상 변경"
>
</button>
)}
</div>
</div>
<button type="button" onClick={() => setSelectedRoleSn(r.roleSn)} className="w-full text-left">
<div className="text-[10px] mt-0.5">{r.roleNm}</div>
<div className="text-[9px] text-hint mt-0.5"> {r.permissions.length}</div>
</button>
{isEditingColor && (
<div className="mt-2 p-2 bg-background rounded border border-border">
<ColorPicker
label="배지 색상"
value={r.colorHex}
onChange={(hex) => handleUpdateColor(r.roleSn, hex)}
/>
</div>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
{/* 우측: 권한 매트릭스 */}
<Card className="col-span-9">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-3">
<div>
<div className="text-xs text-label font-bold">
{selectedRole ? `${selectedRole.roleNm} (${selectedRole.roleCd})` : '역할 선택'}
</div>
<div className="text-[10px] text-hint mt-0.5">
: <span className="text-blue-400"> </span> /
<span className="text-blue-300/80 ml-1"> </span> /
<span className="text-red-400 ml-1"> </span> /
<span className="text-gray-500 ml-1">× </span> /
<span className="text-hint ml-1">· </span>
</div>
</div>
{canUpdatePerm && selectedRole && (
<Button
variant="primary"
size="sm"
onClick={handleSave}
disabled={!isDirty || saving}
icon={saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
>
{isDirty && <span className="text-yellow-300"></span>}
</Button>
)}
</div>
{selectedRole && (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-surface-overlay">
<tr className="border-b border-border">
<th className="text-left py-2 pl-2 text-hint font-medium"></th>
{OPERATIONS.map((op) => (
<th key={op} className="w-16 text-center py-2 text-hint font-medium">{op[0]}</th>
))}
</tr>
</thead>
<tbody>
{(childrenMap.get(null) ?? []).map((root) => renderTreeRow(root, 0))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
)}
</div>
);
}