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

1272 lines
46 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-caption 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-caption">
{node.level > 0 ? '├' : ''}
</span>
)}
{node.icon && <span className="mr-1 flex-shrink-0 text-label-2">{node.icon}</span>}
<div className="min-w-0">
<div
className={`text-label-2 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-caption 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-caption 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-caption 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-caption 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-caption 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-label-2 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-label-2 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-label-2 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-label-2 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-label-2 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-caption font-mono opacity-50">{role.code}</span>
{role.isDefault && (
<span className="ml-1 text-caption 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-caption 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-caption 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-caption font-semibold text-fg-sub">
{OPER_LABELS[oper]}
</div>
<div className="text-caption 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-body-2 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-body-2 font-bold text-fg font-korean"> </h3>
</div>
<div className="px-5 py-4 flex flex-col gap-3">
<div>
<label className="text-label-2 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-caption 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-caption text-fg-disabled mt-1 font-korean">
, , ( )
</p>
</div>
<div>
<label className="text-label-2 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-caption 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-label-2 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-caption 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-label-2 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-caption 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-caption 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-caption 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-caption 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-caption font-semibold text-fg font-korean truncate">
{user.name}
{user.rank && (
<span className="ml-1 text-caption text-fg-disabled font-korean">
{user.rank}
</span>
)}
</div>
<div className="text-caption text-fg-disabled font-mono truncate">
{user.account}
</div>
</div>
{user.orgName && (
<span className="text-caption 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-caption 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-caption font-semibold text-fg-sub font-korean"> </span>
<button
onClick={handleSaveRoles}
disabled={!rolesDirty || savingRoles}
className={`px-3 py-1.5 text-label-2 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-label-2 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-caption 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-caption 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-label-2 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-label-2 font-semibold text-fg-sub">
{OPER_LABELS[oper]}
</div>
<div className="text-caption 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-body-2 font-korean">
</div>
)}
</>
) : (
<div className="flex-1 flex items-center justify-center text-fg-disabled text-body-2 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-body-2 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-body-2 font-bold text-fg font-korean"> </h1>
<p className="text-caption 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-caption 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-caption 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;