wing-ops/frontend/src/tabs/admin/components/PermissionsPanel.tsx

1146 lines
43 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 { 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<OperCode, string> = { READ: 'R', CREATE: 'C', UPDATE: 'U', DELETE: 'D' }
const OPER_FULL_LABELS: Record<OperCode, string> = { 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<string, boolean>,
cache: Map<string, PermState>,
): 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<string, boolean>,
): Map<string, PermState> {
const cache = new Map<string, PermState>()
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 (
<button
onClick={isDisabled ? undefined : onToggle}
disabled={isDisabled}
className={classes}
title={
readOnly
? state === 'explicit-granted' ? `${label ?? ''} 허용`
: state === 'inherited-granted' ? `${label ?? ''} 상속 허용`
: state === 'explicit-denied' ? `${label ?? ''} 거부`
: `${label ?? ''} 비활성`
: state === 'explicit-granted' ? `${label ?? ''} 명시적 허용 (클릭: 거부로 전환)`
: state === 'inherited-granted' ? `${label ?? ''} 부모 상속 허용 (클릭: 명시적 거부)`
: state === 'explicit-denied' ? `${label ?? ''} 명시적 거부 (클릭: 허용으로 전환)`
: `${label ?? ''} 부모 거부로 비활성`
}
>
{icon}
</button>
)
}
// ─── 트리 행 컴포넌트 ────────────────────────────────
interface TreeRowProps {
node: PermTreeNode
stateMap: Map<string, PermState>
expanded: Set<string>
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 (
<>
<tr className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
<td className="px-3 py-1">
<div className="flex items-center" style={{ paddingLeft: indent }}>
{hasChildren ? (
<button
onClick={() => onToggleExpand(node.code)}
className="w-4 h-4 flex items-center justify-center text-text-3 hover:text-text-1 transition-colors mr-1 flex-shrink-0"
>
<svg
width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
className={`transition-transform ${isExpanded ? 'rotate-90' : ''}`}
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
) : (
<span className="w-4 mr-1 flex-shrink-0 text-center text-text-3 text-[9px]">
{node.level > 0 ? '├' : ''}
</span>
)}
{node.icon && <span className="mr-1 flex-shrink-0 text-[11px]">{node.icon}</span>}
<div className="min-w-0">
<div className={`text-[11px] font-korean truncate ${node.level === 0 ? 'font-bold text-text-1' : 'font-medium text-text-2'}`}>
{node.name}
</div>
</div>
</div>
</td>
{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 (
<td key={oper} className="px-1 py-1 text-center">
<div className="flex justify-center">
<PermCell
state={effectiveState}
label={OPER_FULL_LABELS[oper]}
onToggle={() => onTogglePerm(node.code, oper, effectiveState)}
readOnly={readOnly}
/>
</div>
</td>
)
})}
</tr>
{hasChildren && isExpanded && node.children.map(child => (
<TreeRow
key={child.code}
node={child}
stateMap={stateMap}
expanded={expanded}
onToggleExpand={onToggleExpand}
onTogglePerm={onTogglePerm}
readOnly={readOnly}
/>
))}
</>
)
}
// ─── 공통 범례 컴포넌트 ──────────────────────────────
function PermLegend() {
return (
<div className="flex items-center gap-3 px-4 py-1.5 border-b border-border bg-bg-1 text-[9px] text-text-3 font-korean" style={{ flexShrink: 0 }}>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-3 rounded border bg-[rgba(6,182,212,0.2)] border-primary-cyan text-primary-cyan text-center text-[8px] leading-3"></span>
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-3 rounded border bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] text-center text-[8px] leading-3"></span>
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-3 rounded border bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-red-400 text-center text-[8px] leading-3"></span>
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-3 rounded border bg-bg-2 border-border text-text-3 opacity-40 text-center text-[8px] leading-3"></span>
</span>
<span className="ml-2 border-l border-border pl-2 text-text-3">
R= C= U= D=
</span>
</div>
)
}
// ─── RolePermTab: 기존 그룹별 권한 탭 ───────────────
interface RolePermTabProps {
roles: RoleWithPermissions[]
permTree: PermTreeNode[]
rolePerms: Map<number, Map<string, boolean>>
setRolePerms: React.Dispatch<React.SetStateAction<Map<number, Map<string, boolean>>>>
selectedRoleSn: number | null
setSelectedRoleSn: (sn: number | null) => void
dirty: boolean
saving: boolean
saveError: string | null
handleSave: () => Promise<void>
handleToggleExpand: (code: string) => void
handleTogglePerm: (code: string, oper: OperCode, currentState: PermState) => void
expanded: Set<string>
flatNodes: PermTreeNode[]
editingRoleSn: number | null
editRoleName: string
setEditRoleName: (name: string) => void
handleStartEditName: (role: RoleWithPermissions) => void
handleSaveRoleName: (roleSn: number) => Promise<void>
setEditingRoleSn: (sn: number | null) => void
toggleDefault: (roleSn: number) => Promise<void>
handleDeleteRole: (roleSn: number, roleName: string) => Promise<void>
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<void>
}
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<string, PermState>()
return (
<>
{/* 헤더 액션 버튼 */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-border" style={{ flexShrink: 0 }}>
<button
onClick={() => { setShowCreateForm(true); setCreateError('') }}
className="px-3 py-1.5 text-[11px] font-semibold rounded-md border border-primary-cyan text-primary-cyan hover:bg-[rgba(6,182,212,0.08)] transition-all font-korean"
>
+
</button>
<button
onClick={handleSave}
disabled={!dirty || saving}
className={`px-3 py-1.5 text-[11px] font-semibold rounded-md transition-all font-korean ${
dirty ? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]' : 'bg-bg-3 text-text-3 cursor-not-allowed'
}`}
>
{saving ? '저장 중...' : '변경사항 저장'}
</button>
{saveError && (
<span className="text-[11px] text-status-red font-korean">{saveError}</span>
)}
</div>
{/* 역할 탭 바 */}
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-border bg-bg-1 overflow-x-auto" style={{ flexShrink: 0 }}>
{roles.map((role, idx) => {
const color = getRoleColor(role.code, idx)
const isSelected = selectedRoleSn === role.sn
return (
<div key={role.sn} className="flex items-center gap-0.5 flex-shrink-0">
<button
onClick={() => setSelectedRoleSn(role.sn)}
className={`px-2.5 py-1 text-[11px] font-semibold rounded-md transition-all font-korean ${
isSelected
? 'border-2 shadow-[0_0_8px_rgba(6,182,212,0.2)]'
: 'border border-border text-text-3 hover:border-border'
}`}
style={isSelected ? { borderColor: color, color } : undefined}
>
{editingRoleSn === role.sn ? (
<input
type="text"
value={editRoleName}
onChange={(e) => setEditRoleName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveRoleName(role.sn)
if (e.key === 'Escape') setEditingRoleSn(null)
}}
onBlur={() => handleSaveRoleName(role.sn)}
onClick={(e) => e.stopPropagation()}
autoFocus
className="w-20 px-1 py-0 text-[11px] font-semibold bg-bg-2 border border-primary-cyan rounded text-center text-text-1 focus:outline-none font-korean"
/>
) : (
<span onDoubleClick={() => handleStartEditName(role)}>
{role.name}
</span>
)}
<span className="ml-1 text-[9px] font-mono opacity-50">{role.code}</span>
{role.isDefault && <span className="ml-1 text-[9px] text-primary-cyan"></span>}
</button>
{isSelected && (
<div className="flex items-center gap-0.5">
<button
onClick={() => toggleDefault(role.sn)}
className={`px-1.5 py-0.5 text-[9px] rounded transition-all font-korean ${
role.isDefault
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan'
: 'text-text-3 hover:text-text-2'
}`}
title="신규 사용자 기본 역할 설정"
>
{role.isDefault ? '기본역할' : '기본설정'}
</button>
{role.code !== 'ADMIN' && (
<button
onClick={() => handleDeleteRole(role.sn, role.name)}
className="w-5 h-5 flex items-center justify-center text-text-3 hover:text-red-400 transition-colors"
title="역할 삭제"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
)}
</div>
)}
</div>
)
})}
</div>
{/* 범례 */}
<PermLegend />
{/* CRUD 매트릭스 테이블 */}
{selectedRoleSn ? (
<div className="flex-1 overflow-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-bg-1 sticky top-0 z-10">
<th className="px-3 py-1.5 text-left text-[10px] font-semibold text-text-3 font-korean min-w-[200px]"></th>
{OPER_CODES.map(oper => (
<th key={oper} className="px-1 py-1.5 text-center w-12">
<div className="text-[10px] font-semibold text-text-2">{OPER_LABELS[oper]}</div>
<div className="text-[8px] text-text-3 font-korean">{OPER_FULL_LABELS[oper]}</div>
</th>
))}
</tr>
</thead>
<tbody>
{permTree.map(rootNode => (
<TreeRow
key={rootNode.code}
node={rootNode}
stateMap={currentStateMap}
expanded={expanded}
onToggleExpand={handleToggleExpand}
onTogglePerm={handleTogglePerm}
/>
))}
</tbody>
</table>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-text-3 text-sm font-korean">
</div>
)}
{/* 역할 생성 모달 */}
{showCreateForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-[400px] bg-bg-1 rounded-lg border border-border shadow-2xl">
<div className="px-5 py-4 border-b border-border">
<h3 className="text-sm font-bold text-text-1 font-korean"> </h3>
</div>
<div className="px-5 py-4 flex flex-col gap-3">
<div>
<label className="text-[11px] text-text-3 font-korean block mb-1"> </label>
<input
type="text"
value={newRoleCode}
onChange={(e) => 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"
/>
<p className="text-[10px] text-text-3 mt-1 font-korean"> , , ( )</p>
</div>
<div>
<label className="text-[11px] text-text-3 font-korean block mb-1"> </label>
<input
type="text"
value={newRoleName}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-[11px] text-text-3 font-korean block mb-1"> ()</label>
<input
type="text"
value={newRoleDesc}
onChange={(e) => 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"
/>
</div>
{createError && (
<div className="px-3 py-2 text-[11px] text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)] rounded-md font-korean">
{createError}
</div>
)}
</div>
<div className="px-5 py-3 border-t border-border flex justify-end gap-2">
<button
onClick={() => setShowCreateForm(false)}
className="px-4 py-2 text-xs text-text-3 border border-border rounded-md hover:bg-bg-hover font-korean"
>
</button>
<button
onClick={handleCreateRole}
disabled={!newRoleCode || !newRoleName || creating}
className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean disabled:opacity-50"
>
{creating ? '생성 중...' : '생성'}
</button>
</div>
</div>
</div>
)}
</>
)
}
// ─── UserPermTab: 사용자별 권한 탭 ───────────────────
interface UserPermTabProps {
roles: RoleWithPermissions[]
permTree: PermTreeNode[]
rolePerms: Map<number, Map<string, boolean>>
}
function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
const [users, setUsers] = useState<UserListItem[]>([])
const [loadingUsers, setLoadingUsers] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [showDropdown, setShowDropdown] = useState(false)
const [selectedUser, setSelectedUser] = useState<UserListItem | null>(null)
const [assignedRoleSns, setAssignedRoleSns] = useState<number[]>([])
const [savingRoles, setSavingRoles] = useState(false)
const [rolesDirty, setRolesDirty] = useState(false)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const dropdownRef = useRef<HTMLDivElement>(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<string, PermState>()
}
// 각 역할의 명시적 권한 병합: 어느 역할이든 granted=true면 허용
const mergedPerms = new Map<string, boolean>()
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 (
<div className="flex flex-col flex-1 min-h-0">
{/* 사용자 검색/선택 */}
<div className="px-4 py-2.5 border-b border-border" style={{ flexShrink: 0 }}>
<label className="text-[10px] text-text-3 font-korean block mb-1.5"> </label>
<div className="relative" ref={dropdownRef}>
<input
type="text"
value={searchQuery}
onChange={(e) => {
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 && (
<div className="absolute left-0 top-full mt-1 w-full max-w-sm bg-bg-1 border border-border rounded-md shadow-xl z-20 overflow-auto max-h-52">
{filteredUsers.map(user => (
<button
key={user.id}
onClick={() => handleSelectUser(user)}
className="w-full px-3 py-2 text-left hover:bg-bg-hover transition-colors flex items-center gap-2"
>
<div className="min-w-0 flex-1">
<div className="text-xs font-semibold text-text-1 font-korean truncate">
{user.name}
{user.rank && <span className="ml-1 text-[10px] text-text-3 font-korean">{user.rank}</span>}
</div>
<div className="text-[10px] text-text-3 font-mono truncate">{user.account}</div>
</div>
{user.orgName && (
<span className="text-[10px] text-text-3 font-korean flex-shrink-0 truncate max-w-[100px]">{user.orgName}</span>
)}
</button>
))}
</div>
)}
{showDropdown && !loadingUsers && filteredUsers.length === 0 && searchQuery && (
<div className="absolute left-0 top-full mt-1 w-full max-w-sm bg-bg-1 border border-border rounded-md shadow-xl z-20 px-3 py-2 text-xs text-text-3 font-korean">
</div>
)}
</div>
</div>
{selectedUser ? (
<>
{/* 역할 할당 섹션 */}
<div className="px-4 py-2.5 border-b border-border bg-bg-1" style={{ flexShrink: 0 }}>
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-semibold text-text-2 font-korean"> </span>
<button
onClick={handleSaveRoles}
disabled={!rolesDirty || savingRoles}
className={`px-3 py-1.5 text-[11px] font-semibold rounded-md transition-all font-korean ${
rolesDirty
? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
: 'bg-bg-3 text-text-3 cursor-not-allowed'
}`}
>
{savingRoles ? '저장 중...' : '역할 저장'}
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{roles.map((role, idx) => {
const color = getRoleColor(role.code, idx)
const isChecked = assignedRoleSns.includes(role.sn)
return (
<label
key={role.sn}
className={[
'flex items-center gap-1 px-2 py-1 rounded-md border cursor-pointer transition-all font-korean text-[11px] select-none',
isChecked ? '' : 'border-border text-text-3 hover:border-text-2',
].join(' ')}
style={isChecked ? { borderColor: color, color, backgroundColor: `${color}18` } : undefined}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => handleToggleRole(role.sn)}
className="w-3 h-3 accent-primary-cyan"
/>
<span>{role.name}</span>
<span className="text-[9px] font-mono opacity-60">{role.code}</span>
</label>
)
})}
</div>
</div>
{/* 유효 권한 매트릭스 (읽기 전용) */}
<div className="px-4 py-1.5 border-b border-border bg-bg-1 text-[9px] text-text-3 font-korean" style={{ flexShrink: 0 }}>
<span className="font-semibold text-text-2"> ( )</span>
<span className="ml-2"> </span>
</div>
<PermLegend />
{assignedRoleSns.length > 0 ? (
<div className="flex-1 overflow-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-bg-1 sticky top-0 z-10">
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean min-w-[240px]"></th>
{OPER_CODES.map(oper => (
<th key={oper} className="px-2 py-3 text-center w-16">
<div className="text-[11px] font-semibold text-text-2">{OPER_LABELS[oper]}</div>
<div className="text-[9px] text-text-3 font-korean">{OPER_FULL_LABELS[oper]}</div>
</th>
))}
</tr>
</thead>
<tbody>
{permTree.map(rootNode => (
<TreeRow
key={rootNode.code}
node={rootNode}
stateMap={effectiveStateMap}
expanded={expanded}
onToggleExpand={handleToggleExpand}
onTogglePerm={noOpToggle}
readOnly={true}
/>
))}
</tbody>
</table>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-text-3 text-sm font-korean">
</div>
)}
</>
) : (
<div className="flex-1 flex items-center justify-center text-text-3 text-sm font-korean">
</div>
)}
</div>
)
}
// ─── 메인 PermissionsPanel ──────────────────────────
type ActiveTab = 'role' | 'user'
function PermissionsPanel() {
const [activeTab, setActiveTab] = useState<ActiveTab>('role')
const [roles, setRoles] = useState<RoleWithPermissions[]>([])
const [permTree, setPermTree] = useState<PermTreeNode[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(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<number | null>(null)
const [editRoleName, setEditRoleName] = useState('')
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [selectedRoleSn, setSelectedRoleSn] = useState<number | null>(null)
// 역할별 명시적 권한: Map<roleSn, Map<"rsrc::oper", boolean>>
const [rolePerms, setRolePerms] = useState<Map<number, Map<string, boolean>>>(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<number, Map<string, boolean>>()
for (const role of rolesData) {
const roleMap = new Map<string, boolean>()
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 <div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean"> ...</div>
}
return (
<div className="flex flex-col h-full">
{/* 헤더 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border" style={{ flexShrink: 0 }}>
<div>
<h1 className="text-sm font-bold text-text-1 font-korean"> </h1>
<p className="text-[10px] text-text-3 mt-0.5 font-korean"> × CRUD </p>
</div>
{/* 탭 전환 */}
<div className="flex items-center gap-1 p-1 bg-bg-2 rounded-lg border border-border">
<button
onClick={() => setActiveTab('role')}
className={`px-4 py-1.5 text-xs font-semibold rounded-md transition-all font-korean ${
activeTab === 'role'
? 'bg-primary-cyan text-bg-0 shadow-[0_0_8px_rgba(6,182,212,0.25)]'
: 'text-text-3 hover:text-text-2'
}`}
>
</button>
<button
onClick={() => setActiveTab('user')}
className={`px-4 py-1.5 text-xs font-semibold rounded-md transition-all font-korean ${
activeTab === 'user'
? 'bg-primary-cyan text-bg-0 shadow-[0_0_8px_rgba(6,182,212,0.25)]'
: 'text-text-3 hover:text-text-2'
}`}
>
</button>
</div>
</div>
{activeTab === 'role' ? (
<RolePermTab
roles={roles}
permTree={permTree}
rolePerms={rolePerms}
setRolePerms={setRolePerms}
selectedRoleSn={selectedRoleSn}
setSelectedRoleSn={setSelectedRoleSn}
dirty={dirty}
saving={saving}
saveError={saveError}
handleSave={handleSave}
handleToggleExpand={handleToggleExpand}
handleTogglePerm={handleTogglePerm}
expanded={expanded}
flatNodes={flatNodes}
editingRoleSn={editingRoleSn}
editRoleName={editRoleName}
setEditRoleName={setEditRoleName}
handleStartEditName={handleStartEditName}
handleSaveRoleName={handleSaveRoleName}
setEditingRoleSn={setEditingRoleSn}
toggleDefault={toggleDefault}
handleDeleteRole={handleDeleteRole}
showCreateForm={showCreateForm}
setShowCreateForm={setShowCreateForm}
setCreateError={setCreateError}
newRoleCode={newRoleCode}
setNewRoleCode={setNewRoleCode}
newRoleName={newRoleName}
setNewRoleName={setNewRoleName}
newRoleDesc={newRoleDesc}
setNewRoleDesc={setNewRoleDesc}
creating={creating}
createError={createError}
handleCreateRole={handleCreateRole}
/>
) : (
<UserPermTab
roles={roles}
permTree={permTree}
rolePerms={rolePerms}
/>
)}
</div>
)
}
export default PermissionsPanel