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-[10px] 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-primary-cyan text-primary-cyan cursor-default` : `${baseClasses} bg-[rgba(6,182,212,0.2)] border-primary-cyan text-primary-cyan 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-primary-cyan` 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-2 border-border text-text-3 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-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono" />

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

setNewRoleName(e.target.value)} placeholder="사용자 정의 역할" className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean" />
setNewRoleDesc(e.target.value)} placeholder="역할에 대한 설명" className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan 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-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan 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