wing-ops/frontend/src/tabs/admin/components/PermissionsPanel.tsx
htlee 8657190578 feat(auth): RBAC 오퍼레이션 기반 2차원 권한 시스템 구현
리소스 가시성(READ/HIDE) 단일 차원에서 리소스 × 오퍼레이션(RCUD) 2차원
권한 모델로 전환하여 세밀한 CRUD 권한 제어 지원.

- DB: AUTH_PERM에 OPER_CD 컬럼 추가, 마이그레이션 004 작성
- DB: AUTH_PERM_TREE 리소스 트리 테이블 추가 (마이그레이션 003)
- Backend: permResolver 2차원 권한 해석 엔진 (상속 + 오퍼레이션)
- Backend: requirePermission 미들웨어 신규 (리소스×오퍼레이션 검증)
- Backend: authService permissions → Record<string, string[]> 반환
- Backend: roleService/roleRouter OPER_CD 지원 API
- Backend: Helmet CORP 설정 (sendBeacon cross-origin 허용)
- Frontend: authStore.hasPermission(resource, operation?) 하위 호환 확장
- Frontend: PermissionsPanel 역할탭 + RCUD 4열 매트릭스 UI 전면 재작성
- Frontend: sendBeacon API_BASE_URL 절대경로 전환
- Docs: COMMON-GUIDE 권한 체계 + CRUD API 규칙 문서화

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:55:06 +09:00

668 lines
26 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 } 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<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
}
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 (
<button
onClick={isDisabled ? undefined : onToggle}
disabled={isDisabled}
className={classes}
title={
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
}
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 (
<>
<tr className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
<td className="px-4 py-2.5">
<div className="flex items-center" style={{ paddingLeft: indent }}>
{hasChildren ? (
<button
onClick={() => onToggleExpand(node.code)}
className="w-5 h-5 flex items-center justify-center text-text-3 hover:text-text-1 transition-colors mr-1 flex-shrink-0"
>
<svg
width="12" height="12" 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-5 mr-1 flex-shrink-0 text-center text-text-3 text-[10px]">
{node.level > 0 ? '├' : ''}
</span>
)}
{node.icon && <span className="mr-1.5 flex-shrink-0">{node.icon}</span>}
<div className="min-w-0">
<div className={`text-[12px] font-korean truncate ${node.level === 0 ? 'font-bold text-text-1' : 'font-medium text-text-2'}`}>
{node.name}
</div>
{node.description && node.level === 0 && (
<div className="text-[10px] text-text-3 font-korean truncate mt-0.5">{node.description}</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-2 py-2.5 text-center">
<div className="flex justify-center">
<PermCell
state={effectiveState}
label={OPER_FULL_LABELS[oper]}
onToggle={() => onTogglePerm(node.code, oper, effectiveState)}
/>
</div>
</td>
)
})}
</tr>
{hasChildren && isExpanded && node.children.map(child => (
<TreeRow
key={child.code}
node={child}
stateMap={stateMap}
expanded={expanded}
onToggleExpand={onToggleExpand}
onTogglePerm={onTogglePerm}
/>
))}
</>
)
}
// ─── 메인 PermissionsPanel ──────────────────────────
function PermissionsPanel() {
const [roles, setRoles] = useState<RoleWithPermissions[]>([])
const [permTree, setPermTree] = useState<PermTreeNode[]>([])
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<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)
// 선택된 역할의 effective state 계산
const currentStateMap = selectedRoleSn
? buildEffectiveStates(flatNodes, rolePerms.get(selectedRoleSn) ?? new Map())
: new Map<string, PermState>()
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 <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-6 py-4 border-b border-border">
<div>
<h1 className="text-lg font-bold text-text-1 font-korean"> </h1>
<p className="text-xs text-text-3 mt-1 font-korean"> × CRUD </p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => { setShowCreateForm(true); setCreateError('') }}
className="px-4 py-2 text-xs 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-4 py-2 text-xs 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>
</div>
</div>
{/* 역할 탭 바 */}
<div className="flex items-center gap-2 px-6 py-3 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-1 flex-shrink-0">
<button
onClick={() => setSelectedRoleSn(role.sn)}
className={`px-3 py-1.5 text-xs 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>
{/* 범례 */}
<div className="flex items-center gap-4 px-6 py-2 border-b border-border bg-bg-1 text-[10px] text-text-3 font-korean" style={{ flexShrink: 0 }}>
<span className="flex items-center gap-1">
<span className="inline-block w-4 h-4 rounded border bg-[rgba(6,182,212,0.2)] border-primary-cyan text-primary-cyan text-center text-[9px] leading-4"></span>
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-4 h-4 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-[9px] leading-4"></span>
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-4 h-4 rounded border bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-red-400 text-center text-[9px] leading-4"></span>
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-4 h-4 rounded border bg-bg-2 border-border text-text-3 opacity-40 text-center text-[9px] leading-4"></span>
</span>
<span className="ml-4 border-l border-border pl-4 text-text-3">
R= C= U= D=
</span>
</div>
{/* 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-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={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>
)}
</div>
)
}
export default PermissionsPanel