import { useState, useEffect, useCallback, useRef } from 'react'; import { fetchUsers, fetchRoles, fetchPermTree, updatePermissionsApi, createRoleApi, updateRoleApi, deleteRoleApi, updateRoleDefaultApi, assignRolesApi, type UserListItem, type RoleWithPermissions, type PermTreeNode, } from '@common/services/authApi'; import { getRoleColor } from './adminConstants'; // ─── 오퍼레이션 코드 ───────────────────────────────── const OPER_CODES = ['READ', 'CREATE', 'UPDATE', 'DELETE'] as const; type OperCode = (typeof OPER_CODES)[number]; const OPER_LABELS: Record = { READ: 'R', CREATE: 'C', UPDATE: 'U', DELETE: 'D' }; const OPER_FULL_LABELS: Record = { READ: '조회', CREATE: '생성', UPDATE: '수정', DELETE: '삭제', }; // ─── 권한 상태 타입 ───────────────────────────────────── type PermState = 'explicit-granted' | 'inherited-granted' | 'explicit-denied' | 'forced-denied'; // ─── 키 유틸 ────────────────────────────────────────── function makeKey(rsrc: string, oper: string): string { return `${rsrc}::${oper}`; } // ─── 유틸: 플랫 노드 목록 추출 (트리 DFS) ───────────── function flattenTree(nodes: PermTreeNode[]): PermTreeNode[] { const result: PermTreeNode[] = []; function walk(list: PermTreeNode[]) { for (const n of list) { result.push(n); if (n.children.length > 0) walk(n.children); } } walk(nodes); return result; } // ─── 유틸: 권한 상태 계산 (오퍼레이션별) ────────────── function resolvePermStateForOper( code: string, parentCode: string | null, operCd: string, explicitPerms: Map, cache: Map, ): PermState { const key = makeKey(code, operCd); const cached = cache.get(key); if (cached) return cached; const explicit = explicitPerms.get(key); if (parentCode === null) { const state: PermState = explicit === true ? 'explicit-granted' : explicit === false ? 'explicit-denied' : 'explicit-denied'; cache.set(key, state); return state; } // 부모 READ 확인 (접근 게이트) const parentReadKey = makeKey(parentCode, 'READ'); const parentReadState = cache.get(parentReadKey); if (parentReadState === 'explicit-denied' || parentReadState === 'forced-denied') { cache.set(key, 'forced-denied'); return 'forced-denied'; } if (explicit === true) { cache.set(key, 'explicit-granted'); return 'explicit-granted'; } if (explicit === false) { cache.set(key, 'explicit-denied'); return 'explicit-denied'; } // 부모의 같은 오퍼레이션 상속 const parentOperKey = makeKey(parentCode, operCd); const parentOperState = cache.get(parentOperKey); if (parentOperState === 'explicit-granted' || parentOperState === 'inherited-granted') { cache.set(key, 'inherited-granted'); return 'inherited-granted'; } if (parentOperState === 'forced-denied') { cache.set(key, 'forced-denied'); return 'forced-denied'; } cache.set(key, 'explicit-denied'); return 'explicit-denied'; } function buildEffectiveStates( flatNodes: PermTreeNode[], explicitPerms: Map, ): Map { const cache = new Map(); for (const node of flatNodes) { // READ 먼저 (CUD는 READ에 의존) resolvePermStateForOper(node.code, node.parentCode, 'READ', explicitPerms, cache); for (const oper of OPER_CODES) { if (oper === 'READ') continue; resolvePermStateForOper(node.code, node.parentCode, oper, explicitPerms, cache); } } return cache; } // ─── 체크박스 셀 컴포넌트 ──────────────────────────── interface PermCellProps { state: PermState; onToggle: () => void; label?: string; readOnly?: boolean; } function PermCell({ state, onToggle, label, readOnly = false }: PermCellProps) { const isDisabled = state === 'forced-denied' || readOnly; const baseClasses = 'w-5 h-5 rounded border text-caption font-bold transition-all flex items-center justify-center'; let classes: string; let icon: string; switch (state) { case 'explicit-granted': classes = readOnly ? `${baseClasses} bg-[rgba(6,182,212,0.2)] border-color-accent text-color-accent cursor-default` : `${baseClasses} bg-[rgba(6,182,212,0.2)] border-color-accent text-color-accent cursor-pointer hover:bg-[rgba(6,182,212,0.3)]`; icon = '✓'; break; case 'inherited-granted': classes = readOnly ? `${baseClasses} bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] cursor-default` : `${baseClasses} bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] cursor-pointer hover:border-color-accent`; icon = '✓'; break; case 'explicit-denied': classes = readOnly ? `${baseClasses} bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-red-400 cursor-default` : `${baseClasses} bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-red-400 cursor-pointer hover:border-red-400`; icon = '—'; break; case 'forced-denied': classes = `${baseClasses} bg-bg-elevated border-stroke text-fg-disabled opacity-40 cursor-not-allowed`; icon = '—'; break; } return ( ); } // ─── 트리 행 컴포넌트 ──────────────────────────────── interface TreeRowProps { node: PermTreeNode; stateMap: Map; expanded: Set; onToggleExpand: (code: string) => void; onTogglePerm: (code: string, oper: OperCode, currentState: PermState) => void; readOnly?: boolean; } function TreeRow({ node, stateMap, expanded, onToggleExpand, onTogglePerm, readOnly = false, }: TreeRowProps) { const hasChildren = node.children.length > 0; const isExpanded = expanded.has(node.code); const indent = node.level * 16; // 이 노드의 READ 상태 (CUD 비활성 판단용) const readState = stateMap.get(makeKey(node.code, 'READ')) ?? 'forced-denied'; const readDenied = readState === 'explicit-denied' || readState === 'forced-denied'; return ( <>
{hasChildren ? ( ) : ( {node.level > 0 ? '├' : ''} )} {node.icon && {node.icon}}
{node.name}
{OPER_CODES.map((oper) => { const key = makeKey(node.code, oper); const state = stateMap.get(key) ?? 'forced-denied'; // READ 거부 시 CUD도 강제 거부 const effectiveState = oper !== 'READ' && readDenied ? ('forced-denied' as PermState) : state; return (
onTogglePerm(node.code, oper, effectiveState)} readOnly={readOnly} />
); })} {hasChildren && isExpanded && node.children.map((child) => ( ))} ); } // ─── 공통 범례 컴포넌트 ────────────────────────────── function PermLegend() { return (
허용 상속 거부 비활성 R=조회 C=생성 U=수정 D=삭제
); } // ─── RolePermTab: 기존 그룹별 권한 탭 ─────────────── interface RolePermTabProps { roles: RoleWithPermissions[]; permTree: PermTreeNode[]; rolePerms: Map>; setRolePerms: React.Dispatch>>>; selectedRoleSn: number | null; setSelectedRoleSn: (sn: number | null) => void; dirty: boolean; saving: boolean; saveError: string | null; handleSave: () => Promise; handleToggleExpand: (code: string) => void; handleTogglePerm: (code: string, oper: OperCode, currentState: PermState) => void; expanded: Set; flatNodes: PermTreeNode[]; editingRoleSn: number | null; editRoleName: string; setEditRoleName: (name: string) => void; handleStartEditName: (role: RoleWithPermissions) => void; handleSaveRoleName: (roleSn: number) => Promise; setEditingRoleSn: (sn: number | null) => void; toggleDefault: (roleSn: number) => Promise; handleDeleteRole: (roleSn: number, roleName: string) => Promise; showCreateForm: boolean; setShowCreateForm: (show: boolean) => void; setCreateError: (err: string) => void; newRoleCode: string; setNewRoleCode: (code: string) => void; newRoleName: string; setNewRoleName: (name: string) => void; newRoleDesc: string; setNewRoleDesc: (desc: string) => void; creating: boolean; createError: string; handleCreateRole: () => Promise; } function RolePermTab({ roles, permTree, selectedRoleSn, setSelectedRoleSn, dirty, saving, saveError, handleSave, handleToggleExpand, handleTogglePerm, expanded, flatNodes, rolePerms, editingRoleSn, editRoleName, setEditRoleName, handleStartEditName, handleSaveRoleName, setEditingRoleSn, toggleDefault, handleDeleteRole, showCreateForm, setShowCreateForm, setCreateError, newRoleCode, setNewRoleCode, newRoleName, setNewRoleName, newRoleDesc, setNewRoleDesc, creating, createError, handleCreateRole, }: RolePermTabProps) { const currentStateMap = selectedRoleSn ? buildEffectiveStates(flatNodes, rolePerms.get(selectedRoleSn) ?? new Map()) : new Map(); return ( <> {/* 헤더 액션 버튼 */}
{saveError && ( {saveError} )}
{/* 역할 탭 바 */}
{roles.map((role, idx) => { const color = getRoleColor(role.code, idx); const isSelected = selectedRoleSn === role.sn; return (
{isSelected && (
{role.code !== 'ADMIN' && ( )}
)}
); })}
{/* 범례 */} {/* CRUD 매트릭스 테이블 */} {selectedRoleSn ? (
{OPER_CODES.map((oper) => ( ))} {permTree.map((rootNode) => ( ))}
기능
{OPER_LABELS[oper]}
{OPER_FULL_LABELS[oper]}
) : (
역할을 선택하세요
)} {/* 역할 생성 모달 */} {showCreateForm && (

새 역할 추가

setNewRoleCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '')) } placeholder="CUSTOM_ROLE" className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono" />

영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)

setNewRoleName(e.target.value)} placeholder="사용자 정의 역할" className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean" />
setNewRoleDesc(e.target.value)} placeholder="역할에 대한 설명" className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean" />
{createError && (
{createError}
)}
)} ); } // ─── UserPermTab: 사용자별 권한 탭 ─────────────────── interface UserPermTabProps { roles: RoleWithPermissions[]; permTree: PermTreeNode[]; rolePerms: Map>; } function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) { const [users, setUsers] = useState([]); const [loadingUsers, setLoadingUsers] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [showDropdown, setShowDropdown] = useState(false); const [selectedUser, setSelectedUser] = useState(null); const [assignedRoleSns, setAssignedRoleSns] = useState([]); const [savingRoles, setSavingRoles] = useState(false); const [rolesDirty, setRolesDirty] = useState(false); const [expanded, setExpanded] = useState>(new Set()); const dropdownRef = useRef(null); const flatNodes = flattenTree(permTree); useEffect(() => { const loadUsers = async () => { setLoadingUsers(true); try { const data = await fetchUsers(); setUsers(data); } catch (err) { console.error('사용자 목록 조회 실패:', err); } finally { setLoadingUsers(false); } }; loadUsers(); }, []); // 최상위 노드 기본 펼침 useEffect(() => { if (permTree.length > 0) { setExpanded(new Set(permTree.map((n) => n.code))); } }, [permTree]); // 드롭다운 외부 클릭 시 닫기 useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { setShowDropdown(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); const filteredUsers = users.filter((u) => { if (!searchQuery) return true; const q = searchQuery.toLowerCase(); return ( u.name.toLowerCase().includes(q) || u.account.toLowerCase().includes(q) || (u.orgName?.toLowerCase().includes(q) ?? false) ); }); const handleSelectUser = (user: UserListItem) => { setSelectedUser(user); setSearchQuery(user.name); setShowDropdown(false); setAssignedRoleSns(user.roleSns ?? []); setRolesDirty(false); }; const handleToggleRole = (roleSn: number) => { setAssignedRoleSns((prev) => { const next = prev.includes(roleSn) ? prev.filter((sn) => sn !== roleSn) : [...prev, roleSn]; return next; }); setRolesDirty(true); }; const handleSaveRoles = async () => { if (!selectedUser) return; setSavingRoles(true); try { await assignRolesApi(selectedUser.id, assignedRoleSns); setRolesDirty(false); // 로컬 users 상태 갱신 setUsers((prev) => prev.map((u) => u.id === selectedUser.id ? { ...u, roleSns: assignedRoleSns, roles: roles.filter((r) => assignedRoleSns.includes(r.sn)).map((r) => r.name), } : u, ), ); setSelectedUser((prev) => prev ? { ...prev, roleSns: assignedRoleSns, roles: roles.filter((r) => assignedRoleSns.includes(r.sn)).map((r) => r.name), } : null, ); } catch (err) { console.error('역할 저장 실패:', err); } finally { setSavingRoles(false); } }; const handleToggleExpand = useCallback((code: string) => { setExpanded((prev) => { const next = new Set(prev); if (next.has(code)) next.delete(code); else next.add(code); return next; }); }, []); // 사용자의 유효 권한: 할당된 역할들의 권한 병합 (OR 결합) const effectiveStateMap = (() => { if (!selectedUser || assignedRoleSns.length === 0) { return new Map(); } // 각 역할의 명시적 권한 병합: 어느 역할이든 granted=true면 허용 const mergedPerms = new Map(); for (const roleSn of assignedRoleSns) { const perms = rolePerms.get(roleSn); if (!perms) continue; for (const [key, granted] of perms) { if (granted) { mergedPerms.set(key, true); } else if (!mergedPerms.has(key)) { mergedPerms.set(key, false); } } } return buildEffectiveStates(flatNodes, mergedPerms); })(); const noOpToggle = useCallback((_code: string, _oper: OperCode, _state: PermState): void => { void _code; void _oper; void _state; // 읽기 전용 — 토글 없음 }, []); return (
{/* 사용자 검색/선택 */}
{ setSearchQuery(e.target.value); setShowDropdown(true); if (selectedUser && e.target.value !== selectedUser.name) { setSelectedUser(null); setAssignedRoleSns([]); setRolesDirty(false); } }} onFocus={() => setShowDropdown(true)} placeholder={loadingUsers ? '불러오는 중...' : '이름, 계정, 조직으로 검색...'} disabled={loadingUsers} className="w-full max-w-sm px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean disabled:opacity-50" /> {showDropdown && filteredUsers.length > 0 && (
{filteredUsers.map((user) => ( ))}
)} {showDropdown && !loadingUsers && filteredUsers.length === 0 && searchQuery && (
검색 결과 없음
)}
{selectedUser ? ( <> {/* 역할 할당 섹션 */}
역할 할당
{roles.map((role, idx) => { const color = getRoleColor(role.code, idx); const isChecked = assignedRoleSns.includes(role.sn); return ( ); })}
{/* 유효 권한 매트릭스 (읽기 전용) */}
유효 권한 (읽기 전용) — 할당된 역할의 권한 합산 결과
{assignedRoleSns.length > 0 ? (
{OPER_CODES.map((oper) => ( ))} {permTree.map((rootNode) => ( ))}
기능
{OPER_LABELS[oper]}
{OPER_FULL_LABELS[oper]}
) : (
역할을 하나 이상 할당하면 유효 권한이 표시됩니다
)} ) : (
사용자를 선택하세요
)}
); } // ─── 메인 PermissionsPanel ────────────────────────── type ActiveTab = 'role' | 'user'; function PermissionsPanel() { const [activeTab, setActiveTab] = useState('role'); const [roles, setRoles] = useState([]); const [permTree, setPermTree] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [dirty, setDirty] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false); const [newRoleCode, setNewRoleCode] = useState(''); const [newRoleName, setNewRoleName] = useState(''); const [newRoleDesc, setNewRoleDesc] = useState(''); const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(''); const [editingRoleSn, setEditingRoleSn] = useState(null); const [editRoleName, setEditRoleName] = useState(''); const [expanded, setExpanded] = useState>(new Set()); const [selectedRoleSn, setSelectedRoleSn] = useState(null); // 역할별 명시적 권한: Map> const [rolePerms, setRolePerms] = useState>>(new Map()); const loadData = useCallback(async () => { setLoading(true); try { const [rolesData, treeData] = await Promise.all([fetchRoles(), fetchPermTree()]); setRoles(rolesData); setPermTree(treeData); // 명시적 권한 맵 초기화 (rsrc::oper 키 형식) const permsMap = new Map>(); for (const role of rolesData) { const roleMap = new Map(); for (const p of role.permissions) { roleMap.set(makeKey(p.resourceCode, p.operationCode), p.granted); } permsMap.set(role.sn, roleMap); } setRolePerms(permsMap); // 최상위 노드 기본 펼침 setExpanded(new Set(treeData.map((n) => n.code))); // 첫 번째 역할 선택 if (rolesData.length > 0 && !selectedRoleSn) { setSelectedRoleSn(rolesData[0].sn); } setDirty(false); } catch (err) { console.error('권한 데이터 조회 실패:', err); } finally { setLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps -- 초기 마운트 시 1회만 실행 }, []); useEffect(() => { loadData(); }, [loadData]); // 플랫 노드 목록 const flatNodes = flattenTree(permTree); const handleToggleExpand = useCallback((code: string) => { setExpanded((prev) => { const next = new Set(prev); if (next.has(code)) next.delete(code); else next.add(code); return next; }); }, []); const handleTogglePerm = useCallback( (code: string, oper: OperCode, currentState: PermState) => { if (!selectedRoleSn) return; setRolePerms((prev) => { const next = new Map(prev); const roleMap = new Map(next.get(selectedRoleSn) ?? new Map()); const key = makeKey(code, oper); const node = flatNodes.find((n) => n.code === code); const isRoot = node ? node.parentCode === null : false; switch (currentState) { case 'explicit-granted': roleMap.set(key, false); break; case 'inherited-granted': roleMap.set(key, false); break; case 'explicit-denied': if (isRoot) { roleMap.set(key, true); } else { roleMap.delete(key); } break; default: return prev; } next.set(selectedRoleSn, roleMap); return next; }); setDirty(true); }, [selectedRoleSn, flatNodes], ); const handleSave = async () => { setSaving(true); setSaveError(null); try { for (const role of roles) { const perms = rolePerms.get(role.sn); if (!perms) continue; const permsList: Array<{ resourceCode: string; operationCode: string; granted: boolean }> = []; for (const [key, granted] of perms) { const sepIdx = key.indexOf('::'); permsList.push({ resourceCode: key.substring(0, sepIdx), operationCode: key.substring(sepIdx + 2), granted, }); } await updatePermissionsApi(role.sn, permsList); } setDirty(false); } catch (err) { console.error('권한 저장 실패:', err); setSaveError('권한 저장에 실패했습니다. 다시 시도해주세요.'); } finally { setSaving(false); } }; const handleCreateRole = async () => { setCreating(true); setCreateError(''); try { await createRoleApi({ code: newRoleCode, name: newRoleName, description: newRoleDesc || undefined, }); await loadData(); setShowCreateForm(false); setNewRoleCode(''); setNewRoleName(''); setNewRoleDesc(''); } catch (err) { const message = err instanceof Error ? err.message : '역할 생성에 실패했습니다.'; setCreateError(message); } finally { setCreating(false); } }; const handleDeleteRole = async (roleSn: number, roleName: string) => { if ( !window.confirm( `"${roleName}" 역할을 삭제하시겠습니까?\n이 역할을 가진 모든 사용자에서 해당 역할이 제거됩니다.`, ) ) { return; } try { await deleteRoleApi(roleSn); if (selectedRoleSn === roleSn) setSelectedRoleSn(null); await loadData(); } catch (err) { console.error('역할 삭제 실패:', err); } }; const handleStartEditName = (role: RoleWithPermissions) => { setEditingRoleSn(role.sn); setEditRoleName(role.name); }; const handleSaveRoleName = async (roleSn: number) => { if (!editRoleName.trim()) return; try { await updateRoleApi(roleSn, { name: editRoleName.trim() }); setRoles((prev) => prev.map((r) => (r.sn === roleSn ? { ...r, name: editRoleName.trim() } : r)), ); setEditingRoleSn(null); } catch (err) { console.error('역할 이름 수정 실패:', err); } }; const toggleDefault = async (roleSn: number) => { const role = roles.find((r) => r.sn === roleSn); if (!role) return; const newValue = !role.isDefault; try { await updateRoleDefaultApi(roleSn, newValue); setRoles((prev) => prev.map((r) => (r.sn === roleSn ? { ...r, isDefault: newValue } : r))); } catch (err) { console.error('기본 역할 변경 실패:', err); } }; if (loading) { return (
불러오는 중...
); } return (
{/* 헤더 */}

권한 관리

역할별 리소스 × CRUD 권한 설정

{/* 탭 전환 */}
{activeTab === 'role' ? ( ) : ( )}
); } export default PermissionsPanel;