kcg-ai-monitoring/frontend/src/features/admin/PermissionsPanel.tsx
htlee 234169d540 refactor(frontend): admin 11개 페이지 디자인 시스템 하드코딩 색상 제거 (Phase 1-B)
129건 하드코딩 Tailwind 색상 → 시맨틱 토큰 치환:
- text-cyan-400 (45건) → text-label
- text-green-400/500 (51건) → text-label + Badge intent="success"
- text-red-400/500 (31건) → text-heading + Badge intent="critical"
- text-blue-400 (33건) → text-label + Badge intent="info"
- text-purple-400 (20건) → text-heading
- text-yellow/orange/amber (32건) → text-heading + Badge intent="warning"

raw <button> → <Button> 컴포넌트 교체 (DataHub/NoticeManagement/SystemConfig 등)
미사용 import 정리 (SaveButton/DataTable/lucide 아이콘)

대상: AIAgentSecurityPage, AISecurityPage, AccessControl, AccessLogs,
AdminPanel, AuditLogs, DataHub, LoginHistoryView, NoticeManagement,
PermissionsPanel, SystemConfig

검증: tsc 0 errors, eslint 0 errors, 하드코딩 색상 잔여 0건
2026-04-16 11:25:51 +09:00

526 lines
22 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 { Fragment, useEffect, useState, useCallback, useMemo } from 'react';
import {
Loader2, Save, Plus, Trash2, RefreshCw, ChevronRight, ChevronDown,
ExternalLink, Layers,
} from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import {
fetchRoles, fetchPermTree, createRole, deleteRole, updateRolePermissions,
type RoleWithPermissions, type PermTreeNode, type PermEntry,
} from '@/services/adminApi';
import {
resolveSingleRoleEffective, OPERATIONS,
type Operation, type TreeNode, type PermRow,
} from '@/lib/permission/permResolver';
import { useAuth } from '@/app/auth/AuthContext';
import { useSettingsStore } from '@stores/settingsStore';
import { getRoleBadgeStyle, ROLE_DEFAULT_PALETTE } from '@shared/constants/userRoles';
import { ColorPicker } from '@shared/components/common/ColorPicker';
import { updateRole as apiUpdateRole } from '@/services/adminApi';
/**
* 트리 기반 권한 관리 패널 (wing 패턴).
*
* - 좌측: 역할 목록
* - 우측: 권한 트리 + R/C/U/D/E 체크박스 매트릭스
*
* 셀 상태 (4가지):
* • explicit-granted (✓ 파랑) - 명시적 Y
* • explicit-denied (— 빨강) - 명시적 N
* • inherited-granted (✓ 연파랑) - 부모로부터 상속
* • forced-denied (회색) - 부모 READcandid가 N → 강제 거부
*
* 클릭 사이클: explicit-granted → explicit-denied → 미지정(상속) → ...
*
* 권한:
* - admin:role-management (READ): 역할 목록 조회
* - admin:role-management (CREATE/DELETE): 역할 생성/삭제
* - admin:permission-management (UPDATE): 권한 매트릭스 갱신
*/
type DraftPerms = Map<string, 'Y' | 'N' | null>; // null = 명시 권한 제거
function makeKey(rsrcCd: string, operCd: string) { return `${rsrcCd}::${operCd}`; }
export function PermissionsPanel() {
const { hasPermission } = useAuth();
const canCreateRole = hasPermission('admin:role-management', 'CREATE');
const canDeleteRole = hasPermission('admin:role-management', 'DELETE');
const canUpdatePerm = hasPermission('admin:permission-management', 'UPDATE');
const [roles, setRoles] = useState<RoleWithPermissions[]>([]);
const [tree, setTree] = useState<PermTreeNode[]>([]);
const [selectedRoleSn, setSelectedRoleSn] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [draftPerms, setDraftPerms] = useState<DraftPerms>(new Map());
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [showCreate, setShowCreate] = useState(false);
const [newRoleCd, setNewRoleCd] = useState('');
const [newRoleNm, setNewRoleNm] = useState('');
const [newRoleColor, setNewRoleColor] = useState<string>(ROLE_DEFAULT_PALETTE[0]);
const [editingColor, setEditingColor] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const [r, t] = await Promise.all([fetchRoles(), fetchPermTree()]);
setRoles(r);
setTree(t);
if (r.length > 0 && selectedRoleSn === null) {
setSelectedRoleSn(r[0].roleSn);
}
// Level 0 노드 자동 펼침
setExpanded(new Set(t.filter((n) => n.rsrcLevel === 0).map((n) => n.rsrcCd)));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown');
} finally {
setLoading(false);
}
}, [selectedRoleSn]);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { load(); /* 최초 1회만 로드 */ }, []);
// 역할 선택 시 draft 초기화
const selectedRole = useMemo(
() => roles.find((r) => r.roleSn === selectedRoleSn) ?? null,
[roles, selectedRoleSn],
);
useEffect(() => {
if (!selectedRole) return;
const m: DraftPerms = new Map();
for (const p of selectedRole.permissions) {
m.set(makeKey(p.rsrcCd, p.operCd), p.grantYn as 'Y' | 'N');
}
setDraftPerms(m);
}, [selectedRole]);
// 트리 → 트리 인덱싱 (parent → children), nav_sort 기반 정렬 (메뉴 순서 일치)
const childrenMap = useMemo(() => {
const m = new Map<string | null, PermTreeNode[]>();
for (const n of tree) {
if (n.useYn !== 'Y') continue;
const arr = m.get(n.parentCd) ?? [];
arr.push(n);
m.set(n.parentCd, arr);
}
// nav_sort > 0 우선 (메뉴 표시 항목), 그 다음 sort_ord — 좌측 메뉴 순서와 일치
for (const [, arr] of m.entries()) {
arr.sort((a, b) => {
const aSort = (a.navSort > 0) ? a.navSort : 10000 + a.sortOrd;
const bSort = (b.navSort > 0) ? b.navSort : 10000 + b.sortOrd;
return aSort - bSort;
});
}
return m;
}, [tree]);
// draft 기반 effective 권한 해석 (PermResolver TS 미러)
const effective = useMemo(() => {
const treeNodes: TreeNode[] = tree.map((n) => ({
rsrcCd: n.rsrcCd, parentCd: n.parentCd, rsrcNm: n.rsrcNm,
rsrcLevel: n.rsrcLevel, sortOrd: n.sortOrd, useYn: n.useYn,
}));
const perms: PermRow[] = [];
draftPerms.forEach((v, k) => {
if (v === 'Y' || v === 'N') {
const [rsrcCd, operCd] = k.split('::');
perms.push({ rsrcCd, operCd, grantYn: v });
}
});
return resolveSingleRoleEffective(treeNodes, perms);
}, [tree, draftPerms]);
const cellState = useCallback((rsrcCd: string, operCd: Operation, parentCd: string | null) => {
const key = makeKey(rsrcCd, operCd);
const explicit = draftPerms.get(key);
// 1) 부모 노드의 effective READ가 거부되면 자식의 모든 작업 강제 거부
let parentReadDenied = false;
if (parentCd) {
const parentEff = effective.get(parentCd);
parentReadDenied = !parentEff || !parentEff.has('READ');
}
if (parentReadDenied) return 'forced-denied';
// 2) 같은 노드의 READ가 effective로 거부되면 C/U/D/E도 강제 거부
// (READ가 안 되면 그 페이지/리소스 자체에 접근 못 하므로 다른 작업 권한도 의미 없음)
if (operCd !== 'READ') {
const ownEff = effective.get(rsrcCd);
const ownReadGranted = ownEff?.has('READ') ?? false;
if (!ownReadGranted) return 'forced-denied';
}
if (explicit === 'Y') return 'explicit-granted';
if (explicit === 'N') return 'explicit-denied';
// 상속 체크
const eff = effective.get(rsrcCd);
if (eff?.has(operCd)) return 'inherited-granted';
return 'inherited-denied';
}, [draftPerms, effective]);
const isDirty = useMemo(() => {
if (!selectedRole) return false;
const original = new Map<string, 'Y' | 'N'>();
for (const p of selectedRole.permissions) {
original.set(makeKey(p.rsrcCd, p.operCd), p.grantYn as 'Y' | 'N');
}
if (original.size !== Array.from(draftPerms.values()).filter((v) => v !== null).length) {
return true;
}
for (const [k, v] of draftPerms) {
if (v === null) {
if (original.has(k)) return true;
} else if (original.get(k) !== v) {
return true;
}
}
return false;
}, [selectedRole, draftPerms]);
// 셀 클릭: explicit Y → explicit N → 미지정(상속) → ...
const handleCellClick = (rsrcCd: string, operCd: Operation) => {
if (!canUpdatePerm) return;
const key = makeKey(rsrcCd, operCd);
setDraftPerms((prev) => {
const next = new Map(prev);
const cur = next.get(key);
if (cur === 'Y') next.set(key, 'N');
else if (cur === 'N') next.set(key, null); // 명시 권한 제거
else next.set(key, 'Y');
return next;
});
};
const handleSave = async () => {
if (!selectedRole || !canUpdatePerm) return;
setSaving(true); setError('');
try {
const original = new Map<string, 'Y' | 'N'>();
for (const p of selectedRole.permissions) {
original.set(makeKey(p.rsrcCd, p.operCd), p.grantYn as 'Y' | 'N');
}
// 변경된 셀만 수집
const changes: PermEntry[] = [];
const allKeys = new Set([...original.keys(), ...draftPerms.keys()]);
for (const k of allKeys) {
const [rsrcCd, operCd] = k.split('::');
const oldVal = original.get(k);
const newVal = draftPerms.get(k);
if (newVal === null && oldVal !== undefined) {
changes.push({ rsrcCd, operCd, grantYn: null });
} else if ((newVal === 'Y' || newVal === 'N') && newVal !== oldVal) {
changes.push({ rsrcCd, operCd, grantYn: newVal });
}
}
if (changes.length === 0) {
setSaving(false);
return;
}
await updateRolePermissions(selectedRole.roleSn, changes);
await load(); // 새로 가져와서 동기화
alert(`권한 ${changes.length}건 갱신되었습니다.`);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown');
} finally {
setSaving(false);
}
};
const handleCreateRole = async () => {
if (!newRoleCd || !newRoleNm) return;
try {
await createRole({ roleCd: newRoleCd, roleNm: newRoleNm, colorHex: newRoleColor });
setShowCreate(false);
setNewRoleCd(''); setNewRoleNm('');
setNewRoleColor(ROLE_DEFAULT_PALETTE[0]);
await load();
} catch (e: unknown) {
alert('생성 실패: ' + (e instanceof Error ? e.message : 'unknown'));
}
};
const handleUpdateColor = async (roleSn: number, hex: string) => {
try {
await apiUpdateRole(roleSn, { colorHex: hex });
await load();
setEditingColor(null);
} catch (e: unknown) {
alert('색상 변경 실패: ' + (e instanceof Error ? e.message : 'unknown'));
}
};
const handleDeleteRole = async () => {
if (!selectedRole) return;
if (selectedRole.builtinYn === 'Y') {
alert('내장 역할은 삭제할 수 없습니다.');
return;
}
if (!confirm(`"${selectedRole.roleNm}" 역할을 삭제하시겠습니까?`)) return;
try {
await deleteRole(selectedRole.roleSn);
setSelectedRoleSn(null);
await load();
} catch (e: unknown) {
alert('삭제 실패: ' + (e instanceof Error ? e.message : 'unknown'));
}
};
const toggleExpand = (rsrcCd: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(rsrcCd)) next.delete(rsrcCd); else next.add(rsrcCd);
return next;
});
};
const renderTreeRow = (node: PermTreeNode, depth: number): React.ReactNode => {
const children = childrenMap.get(node.rsrcCd) ?? [];
const hasChildren = children.length > 0;
const isExpanded = expanded.has(node.rsrcCd);
// DB labels JSONB에서 현재 언어 라벨 사용, 없으면 rsrcNm 폴백
const lang = useSettingsStore.getState().language;
const displayName = node.labels?.[lang] || node.labels?.ko || node.rsrcNm;
return (
<Fragment key={node.rsrcCd}>
<tr className="border-t border-border hover:bg-surface-overlay/30">
<td className="py-1.5 pl-2" style={{ paddingLeft: 8 + depth * 20 }}>
<div className="flex items-center gap-1">
{hasChildren ? (
<button type="button" onClick={() => toggleExpand(node.rsrcCd)}
className="p-0.5 text-hint hover:text-heading">
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button>
) : <span className="w-4" />}
{/* 페이지/패널 구분 아이콘 */}
{node.urlPath
? <span title="별도 페이지"><ExternalLink className="w-3 h-3 text-cyan-500/60 shrink-0" /></span>
: depth > 0 ? <span title="페이지 내 패널"><Layers className="w-3 h-3 text-amber-500/50 shrink-0" /></span> : null
}
<span className="text-[11px] text-heading font-medium">{displayName}</span>
<span className="text-[9px] text-hint font-mono">({node.rsrcCd})</span>
{node.urlPath && <span className="text-[8px] text-cyan-500/70 font-mono">{node.urlPath}</span>}
</div>
</td>
{OPERATIONS.map((op) => {
const state = cellState(node.rsrcCd, op as Operation, node.parentCd);
const cls =
state === 'explicit-granted' ? 'bg-blue-500 text-white border-blue-400 font-bold'
: state === 'inherited-granted' ? 'bg-blue-500/30 text-blue-300 border-blue-500/40'
: state === 'explicit-denied' ? 'bg-red-500/40 text-red-300 border-red-500/50 font-bold'
: state === 'forced-denied' ? 'bg-gray-700/40 text-gray-600 border-gray-700/40 cursor-not-allowed'
: 'bg-surface-overlay text-hint border-border';
const icon =
state === 'explicit-granted' || state === 'inherited-granted' ? '✓'
: state === 'explicit-denied' ? '—'
: state === 'forced-denied' ? '×'
: '·';
return (
<td key={op} className="text-center py-1.5">
<button
type="button"
disabled={!canUpdatePerm || state === 'forced-denied'}
onClick={() => handleCellClick(node.rsrcCd, op as Operation)}
className={`w-7 h-6 rounded border text-[11px] transition-colors ${cls} ${canUpdatePerm && state !== 'forced-denied' ? 'hover:opacity-80 cursor-pointer' : ''}`}
title={`${op} - ${state}`}
>
{icon}
</button>
</td>
);
})}
</tr>
{isExpanded && children.map((c) => renderTreeRow(c, depth + 1))}
</Fragment>
);
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-bold text-heading"> ( RBAC)</h2>
<p className="text-[10px] text-hint mt-0.5">
(Y N )
</p>
</div>
<div className="flex items-center gap-1">
<button type="button" onClick={load}
className="p-1.5 rounded text-hint hover:text-label hover:bg-surface-overlay" title="새로고침">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
</div>
{error && <div className="text-xs text-heading">: {error}</div>}
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
{!loading && (
<div className="grid grid-cols-12 gap-3">
{/* 좌측: 역할 목록 */}
<Card className="col-span-3">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-label font-bold"></div>
<div className="flex items-center gap-1">
{canCreateRole && (
<button type="button" onClick={() => setShowCreate(!showCreate)}
className="p-1 text-hint hover:text-label" title="신규 역할">
<Plus className="w-3.5 h-3.5" />
</button>
)}
{canDeleteRole && selectedRole && selectedRole.builtinYn !== 'Y' && (
<button type="button" onClick={handleDeleteRole}
className="p-1 text-hint hover:text-heading" title="역할 삭제">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{showCreate && (
<div className="mb-2 p-2 bg-surface-overlay rounded space-y-1.5">
<input aria-label="역할 코드" value={newRoleCd} onChange={(e) => setNewRoleCd(e.target.value.toUpperCase())}
placeholder="ROLE_CD (대문자)"
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
<input aria-label="역할 이름" value={newRoleNm} onChange={(e) => setNewRoleNm(e.target.value)}
placeholder="역할 이름"
className="w-full bg-background border border-border rounded px-2 py-1 text-[10px] text-heading" />
<ColorPicker label="배지 색상" value={newRoleColor} onChange={setNewRoleColor} />
<div className="flex gap-1 pt-1">
<Button variant="primary" size="sm" onClick={handleCreateRole} disabled={!newRoleCd || !newRoleNm} className="flex-1">
</Button>
<Button variant="secondary" size="sm" onClick={() => setShowCreate(false)} className="flex-1">
</Button>
</div>
</div>
)}
<div className="space-y-1">
{roles.map((r) => {
const selected = r.roleSn === selectedRoleSn;
const isEditingColor = editingColor === String(r.roleSn);
return (
<div
key={r.roleSn}
className={`px-2 py-1.5 rounded border transition-colors ${
selected
? 'bg-blue-600/20 border-blue-500/40 text-heading'
: 'bg-surface-overlay border-border text-muted-foreground hover:text-heading hover:bg-surface-overlay/80'
}`}
>
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setSelectedRoleSn(r.roleSn)}
className="flex items-center gap-1.5 cursor-pointer"
title="역할 선택"
>
<Badge size="sm" style={getRoleBadgeStyle(r.roleCd)}>
{r.roleCd}
</Badge>
</button>
<div className="flex items-center gap-1">
{r.builtinYn === 'Y' && <span className="text-[8px] text-hint">BUILT-IN</span>}
{canUpdatePerm && (
<button
type="button"
onClick={() => setEditingColor(isEditingColor ? null : String(r.roleSn))}
className="text-[8px] text-hint hover:text-label"
title="색상 변경"
>
</button>
)}
</div>
</div>
<button type="button" onClick={() => setSelectedRoleSn(r.roleSn)} className="w-full text-left">
<div className="text-[10px] mt-0.5">{r.roleNm}</div>
<div className="text-[9px] text-hint mt-0.5"> {r.permissions.length}</div>
</button>
{isEditingColor && (
<div className="mt-2 p-2 bg-background rounded border border-border">
<ColorPicker
label="배지 색상"
value={r.colorHex}
onChange={(hex) => handleUpdateColor(r.roleSn, hex)}
/>
</div>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
{/* 우측: 권한 매트릭스 */}
<Card className="col-span-9">
<CardContent className="p-3">
<div className="flex items-center justify-between mb-3">
<div>
<div className="text-xs text-label font-bold">
{selectedRole ? `${selectedRole.roleNm} (${selectedRole.roleCd})` : '역할 선택'}
</div>
<div className="text-[10px] text-hint mt-0.5">
: <span className="text-label"> </span> /
<span className="text-blue-300/80 ml-1"> </span> /
<span className="text-heading ml-1"> </span> /
<span className="text-gray-500 ml-1">× </span> /
<span className="text-hint ml-1">· </span>
</div>
</div>
{canUpdatePerm && selectedRole && (
<Button
variant="primary"
size="sm"
onClick={handleSave}
disabled={!isDirty || saving}
icon={saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
>
{isDirty && <span className="text-yellow-300"></span>}
</Button>
)}
</div>
{selectedRole && (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-surface-overlay">
<tr className="border-b border-border">
<th className="text-left py-2 pl-2 text-hint font-medium"></th>
{OPERATIONS.map((op) => (
<th key={op} className="w-16 text-center py-2 text-hint font-medium">{op[0]}</th>
))}
</tr>
</thead>
<tbody>
{(childrenMap.get(null) ?? []).map((root) => renderTreeRow(root, 0))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
)}
</div>
);
}