import { useState, useEffect, useCallback } from 'react' import { fetchRoles, fetchPermTree, updatePermissionsApi, createRoleApi, updateRoleApi, deleteRoleApi, updateRoleDefaultApi, 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 } function PermCell({ state, onToggle, label }: PermCellProps) { const isDisabled = state === 'forced-denied' const baseClasses = 'w-7 h-7 rounded border text-xs font-bold transition-all flex items-center justify-center' let classes: string let icon: string switch (state) { case 'explicit-granted': classes = `${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 = `${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 = `${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 } function TreeRow({ node, stateMap, expanded, onToggleExpand, onTogglePerm }: TreeRowProps) { const hasChildren = node.children.length > 0 const isExpanded = expanded.has(node.code) const indent = node.level * 24 // 이 노드의 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}
{node.description && node.level === 0 && (
{node.description}
)}
{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)} />
) })} {hasChildren && isExpanded && node.children.map(child => ( ))} ) } // ─── 메인 PermissionsPanel ────────────────────────── function PermissionsPanel() { const [roles, setRoles] = useState([]) const [permTree, setPermTree] = useState([]) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) 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) // 선택된 역할의 effective state 계산 const currentStateMap = selectedRoleSn ? buildEffectiveStates(flatNodes, rolePerms.get(selectedRoleSn) ?? new Map()) : new Map() 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) 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) } 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 권한을 설정합니다

{/* 역할 탭 바 */}
{roles.map((role, idx) => { const color = getRoleColor(role.code, idx) const isSelected = selectedRoleSn === role.sn return (
{isSelected && (
{role.code !== 'ADMIN' && ( )}
)}
) })}
{/* 범례 */}
명시적 허용 상속 허용 명시적 거부 강제 거부 R=조회 C=생성 U=수정 D=삭제
{/* 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}
)}
)}
) } export default PermissionsPanel