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 { Input } from '@shared/components/ui/input'; 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'; import { useTranslation } from 'react-i18next'; /** * 트리 기반 권한 관리 패널 (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; // null = 명시 권한 제거 function makeKey(rsrcCd: string, operCd: string) { return `${rsrcCd}::${operCd}`; } export function PermissionsPanel() { const { t: tc } = useTranslation('common'); 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([]); const [tree, setTree] = useState([]); const [selectedRoleSn, setSelectedRoleSn] = useState(null); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [draftPerms, setDraftPerms] = useState(new Map()); const [expanded, setExpanded] = useState>(new Set()); const [showCreate, setShowCreate] = useState(false); const [newRoleCd, setNewRoleCd] = useState(''); const [newRoleNm, setNewRoleNm] = useState(''); const [newRoleColor, setNewRoleColor] = useState(ROLE_DEFAULT_PALETTE[0]); const [editingColor, setEditingColor] = useState(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(); 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(); 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(); 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(`${tc('success.permissionUpdated')} (${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(tc('error.createFailed', { msg: 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(tc('error.updateFailed', { msg: e instanceof Error ? e.message : 'unknown' })); } }; const handleDeleteRole = async () => { if (!selectedRole) return; if (selectedRole.builtinYn === 'Y') { alert(tc('message.builtinRoleCannotDelete')); return; } if (!confirm(`"${selectedRole.roleNm}" ${tc('dialog.deleteRole')}`)) return; try { await deleteRole(selectedRole.roleSn); setSelectedRoleSn(null); await load(); } catch (e: unknown) { alert(tc('error.deleteFailed', { msg: 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 (
{hasChildren ? ( ) : } {/* 페이지/패널 구분 아이콘 */} {node.urlPath ? : depth > 0 ? : null } {displayName} ({node.rsrcCd}) {node.urlPath && {node.urlPath}}
{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 ( ); })} {isExpanded && children.map((c) => renderTreeRow(c, depth + 1))}
); }; return (

권한 관리 (트리 RBAC)

좌측 역할 선택 → 우측 트리 매트릭스에서 셀 클릭 (Y → N → 상속) → 저장

{error &&
{tc('error.errorPrefix', { msg: error })}
} {loading &&
} {!loading && (
{/* 좌측: 역할 목록 */}
역할
{canCreateRole && (
{showCreate && (
setNewRoleCd(e.target.value.toUpperCase())} placeholder="ROLE_CD (대문자)" /> setNewRoleNm(e.target.value)} placeholder={tc('aria.roleName')} />
)}
{roles.map((r) => { const selected = r.roleSn === selectedRoleSn; const isEditingColor = editingColor === String(r.roleSn); return (
{r.builtinYn === 'Y' && BUILT-IN} {canUpdatePerm && ( )}
{isEditingColor && (
handleUpdateColor(r.roleSn, hex)} />
)}
); })}
{/* 우측: 권한 매트릭스 */}
{selectedRole ? `${selectedRole.roleNm} (${selectedRole.roleCd})` : '역할 선택'}
셀 의미: ✓ 명시 허용 / ✓ 상속 허용 / — 명시 거부 / × 강제 거부 / · 미지정
{canUpdatePerm && selectedRole && ( )}
{selectedRole && (
{OPERATIONS.map((op) => ( ))} {(childrenMap.get(null) ?? []).map((root) => renderTreeRow(root, 0))}
리소스{op[0]}
)}
)}
); }