1266 lines
45 KiB
TypeScript
1266 lines
45 KiB
TypeScript
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-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 (
|
||
<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-stroke 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-fg-disabled hover:text-fg 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-fg-disabled 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-fg' : 'font-medium text-fg-sub'}`}
|
||
>
|
||
{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-stroke bg-bg-surface text-[9px] text-fg-disabled 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-color-accent text-color-accent 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-elevated border-stroke text-fg-disabled opacity-40 text-center text-[8px] leading-3">
|
||
—
|
||
</span>
|
||
비활성
|
||
</span>
|
||
<span className="ml-2 border-l border-stroke pl-2 text-fg-disabled">
|
||
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-stroke"
|
||
style={{ flexShrink: 0 }}
|
||
>
|
||
<button
|
||
onClick={() => {
|
||
setShowCreateForm(true);
|
||
setCreateError('');
|
||
}}
|
||
className="px-3 py-1.5 text-[11px] font-semibold rounded-md border border-color-accent text-color-accent 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-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||
: 'bg-bg-card text-fg-disabled cursor-not-allowed'
|
||
}`}
|
||
>
|
||
{saving ? '저장 중...' : '변경사항 저장'}
|
||
</button>
|
||
{saveError && (
|
||
<span className="text-[11px] text-color-danger font-korean">{saveError}</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* 역할 탭 바 */}
|
||
<div
|
||
className="flex items-center gap-1.5 px-4 py-2 border-b border-stroke bg-bg-surface 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-stroke text-fg-disabled hover:border-stroke'
|
||
}`}
|
||
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-elevated border border-color-accent rounded text-center text-fg 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-color-accent">기본</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-color-accent'
|
||
: 'text-fg-disabled hover:text-fg-sub'
|
||
}`}
|
||
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-fg-disabled 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-stroke bg-bg-surface sticky top-0 z-10">
|
||
<th className="px-3 py-1.5 text-left text-[10px] font-semibold text-fg-disabled 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-fg-sub">{OPER_LABELS[oper]}</div>
|
||
<div className="text-[8px] text-fg-disabled 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-fg-disabled 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-surface rounded-lg border border-stroke shadow-2xl">
|
||
<div className="px-5 py-4 border-b border-stroke">
|
||
<h3 className="text-sm font-bold text-fg font-korean">새 역할 추가</h3>
|
||
</div>
|
||
<div className="px-5 py-4 flex flex-col gap-3">
|
||
<div>
|
||
<label className="text-[11px] text-fg-disabled 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-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
||
/>
|
||
<p className="text-[10px] text-fg-disabled mt-1 font-korean">
|
||
영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<label className="text-[11px] text-fg-disabled 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-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-[11px] text-fg-disabled 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-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent 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-stroke flex justify-end gap-2">
|
||
<button
|
||
onClick={() => setShowCreateForm(false)}
|
||
className="px-4 py-2 text-xs text-fg-disabled border border-stroke rounded-md hover:bg-bg-surface-hover font-korean"
|
||
>
|
||
취소
|
||
</button>
|
||
<button
|
||
onClick={handleCreateRole}
|
||
disabled={!newRoleCode || !newRoleName || creating}
|
||
className="px-4 py-2 text-xs font-semibold rounded-md bg-color-accent 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-stroke" style={{ flexShrink: 0 }}>
|
||
<label className="text-[10px] text-fg-disabled 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-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 && (
|
||
<div className="absolute left-0 top-full mt-1 w-full max-w-sm bg-bg-surface border border-stroke 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-surface-hover transition-colors flex items-center gap-2"
|
||
>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-xs font-semibold text-fg font-korean truncate">
|
||
{user.name}
|
||
{user.rank && (
|
||
<span className="ml-1 text-[10px] text-fg-disabled font-korean">
|
||
{user.rank}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="text-[10px] text-fg-disabled font-mono truncate">
|
||
{user.account}
|
||
</div>
|
||
</div>
|
||
{user.orgName && (
|
||
<span className="text-[10px] text-fg-disabled 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-surface border border-stroke rounded-md shadow-xl z-20 px-3 py-2 text-xs text-fg-disabled font-korean">
|
||
검색 결과 없음
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{selectedUser ? (
|
||
<>
|
||
{/* 역할 할당 섹션 */}
|
||
<div
|
||
className="px-4 py-2.5 border-b border-stroke bg-bg-surface"
|
||
style={{ flexShrink: 0 }}
|
||
>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-[10px] font-semibold text-fg-sub 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-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||
: 'bg-bg-card text-fg-disabled 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-stroke text-fg-disabled 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-stroke bg-bg-surface text-[9px] text-fg-disabled font-korean"
|
||
style={{ flexShrink: 0 }}
|
||
>
|
||
<span className="font-semibold text-fg-sub">유효 권한 (읽기 전용)</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-stroke bg-bg-surface sticky top-0 z-10">
|
||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled 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-fg-sub">
|
||
{OPER_LABELS[oper]}
|
||
</div>
|
||
<div className="text-[9px] text-fg-disabled 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-fg-disabled text-sm font-korean">
|
||
역할을 하나 이상 할당하면 유효 권한이 표시됩니다
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="flex-1 flex items-center justify-center text-fg-disabled 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-fg-disabled 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-stroke"
|
||
style={{ flexShrink: 0 }}
|
||
>
|
||
<div>
|
||
<h1 className="text-sm font-bold text-fg font-korean">권한 관리</h1>
|
||
<p className="text-[10px] text-fg-disabled mt-0.5 font-korean">
|
||
역할별 리소스 × CRUD 권한 설정
|
||
</p>
|
||
</div>
|
||
{/* 탭 전환 */}
|
||
<div className="flex items-center gap-1 p-1 bg-bg-elevated rounded-lg border border-stroke">
|
||
<button
|
||
onClick={() => setActiveTab('role')}
|
||
className={`px-4 py-1.5 text-xs font-semibold rounded-md transition-all font-korean ${
|
||
activeTab === 'role'
|
||
? 'bg-color-accent text-bg-0 shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
||
: 'text-fg-disabled hover:text-fg-sub'
|
||
}`}
|
||
>
|
||
그룹별
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('user')}
|
||
className={`px-4 py-1.5 text-xs font-semibold rounded-md transition-all font-korean ${
|
||
activeTab === 'user'
|
||
? 'bg-color-accent text-bg-0 shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
||
: 'text-fg-disabled hover:text-fg-sub'
|
||
}`}
|
||
>
|
||
사용자별
|
||
</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;
|