kcg-ai-monitoring/frontend/src/features/admin/AccessControl.tsx
htlee a07c745cbc feat(frontend): 40+ 페이지 Badge/시맨틱 토큰 마이그레이션
- 모든 feature 페이지의 Badge className 패턴을 intent/size prop으로 변환
- 컬러풀 액션 버튼 (bg-*-500/600/700 + text-heading) -> text-on-vivid
- 검색/필터 버튼 배경 bg-blue-400 + text-on-bright (밝은 배경 위 검정)
- ROLE_COLORS 4곳 중복 제거 (MainLayout/UserRoleAssignDialog/
  PermissionsPanel/AccessControl) -> getRoleBadgeStyle 공통 호출
- PermissionsPanel 역할 생성/수정에 ColorPicker 통합
- MainLayout: PagePagination + scroll page state 제거 (데이터 페이지네이션 혼동)
- Dashboard RiskBar 단위 버그 수정 (0~100 정수 처리)
- ReportManagement, TransferDetection p-5 space-y-4 padding 복구
- EnforcementHistory 그리드 minmax 적용으로 컬럼 잘림 해소
- timeline 시간 formatDateTime 적용 (ISO T 구분자 처리)
- 각 feature 페이지가 공통 카탈로그 API (getXxxIntent/Label/Classes) 사용
2026-04-08 10:53:58 +09:00

387 lines
16 KiB
TypeScript

import { useEffect, useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import {
Shield, Users, UserCheck, Key, Lock, FileText, Loader2, RefreshCw, UserCog,
} from 'lucide-react';
import {
fetchUsers,
fetchUserStats,
fetchAuditLogs,
fetchAuditStats,
unlockUser,
type AdminUser,
type UserStats,
type AuditLog as ApiAuditLog,
type AuditStats,
} from '@/services/adminApi';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getRoleBadgeStyle } from '@shared/constants/userRoles';
import { getUserAccountStatusIntent, getUserAccountStatusLabel } from '@shared/constants/userAccountStatuses';
import { useSettingsStore } from '@stores/settingsStore';
import { PermissionsPanel } from './PermissionsPanel';
import { UserRoleAssignDialog } from './UserRoleAssignDialog';
/*
* SFR-01: 역할 기반 권한 관리(RBAC) - 백엔드 연동 버전
*
* 4개 탭:
* 1) 역할 관리 - GET /api/roles (admin:role-management) + 사용자 통계
* 2) 사용자 관리 - GET /api/admin/users + 잠금 해제
* 3) 감사 로그 - GET /api/admin/audit-logs + GET /api/admin/stats/audit
* 4) 보안 정책 - 정적 정보
*/
type Tab = 'roles' | 'users' | 'audit' | 'policy';
export function AccessControl() {
const { t } = useTranslation('admin');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const [tab, setTab] = useState<Tab>('roles');
// 공통 상태
const [error, setError] = useState('');
// 사용자 목록
const [users, setUsers] = useState<AdminUser[]>([]);
const [userStats, setUserStats] = useState<UserStats | null>(null);
const [usersLoading, setUsersLoading] = useState(false);
// 감사 로그
const [auditLogs, setAuditLogs] = useState<ApiAuditLog[]>([]);
const [auditStats, setAuditStats] = useState<AuditStats | null>(null);
const [auditLoading, setAuditLoading] = useState(false);
// 역할 배정 다이얼로그
const [assignTarget, setAssignTarget] = useState<AdminUser | null>(null);
// 사용자 + 통계 로드
const loadUsers = useCallback(async () => {
setUsersLoading(true); setError('');
try {
const [u, s] = await Promise.all([fetchUsers(), fetchUserStats()]);
setUsers(u);
setUserStats(s);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown');
} finally {
setUsersLoading(false);
}
}, []);
const loadAudit = useCallback(async () => {
setAuditLoading(true); setError('');
try {
const [logs, stats] = await Promise.all([fetchAuditLogs(0, 100), fetchAuditStats()]);
setAuditLogs(logs.content);
setAuditStats(stats);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'unknown');
} finally {
setAuditLoading(false);
}
}, []);
// 탭 전환 시 자동 로드 (roles 탭은 PermissionsPanel이 자체 로드)
useEffect(() => {
if (tab === 'users') loadUsers();
else if (tab === 'audit') loadAudit();
}, [tab, loadUsers, loadAudit]);
const handleUnlock = async (userId: string, acnt: string) => {
if (!confirm(`계정 ${acnt} 잠금을 해제하시겠습니까?`)) return;
try {
await unlockUser(userId);
await loadUsers();
} catch (e: unknown) {
alert('실패: ' + (e instanceof Error ? e.message : 'unknown'));
}
};
// ── 사용자 테이블 컬럼 ──────────────
// eslint-disable-next-line react-hooks/exhaustive-deps
const userColumns: DataColumn<AdminUser & Record<string, unknown>>[] = useMemo(() => [
{ key: 'userAcnt', label: '계정', width: '90px',
render: (v) => <span className="text-cyan-400 font-mono text-[11px]">{v as string}</span> },
{ key: 'userNm', label: '이름', width: '80px', sortable: true,
render: (v) => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'rnkpNm', label: '직급', width: '60px',
render: (v) => <span className="text-muted-foreground">{(v as string) || '-'}</span> },
{ key: 'email', label: '이메일',
render: (v) => <span className="text-muted-foreground text-[10px]">{(v as string) || '-'}</span> },
{ key: 'roles', label: '역할', width: '120px',
render: (v) => {
const list = (v as string[]) || [];
return (
<div className="flex flex-wrap gap-1">
{list.map((r) => (
<Badge key={r} size="sm" style={getRoleBadgeStyle(r)}>{r}</Badge>
))}
</div>
);
},
},
{ key: 'userSttsCd', label: '상태', width: '70px', sortable: true,
render: (v) => {
const s = v as string;
return <Badge intent={getUserAccountStatusIntent(s)} size="sm">{getUserAccountStatusLabel(s, tc, lang)}</Badge>;
},
},
{ key: 'failCnt', label: '실패', width: '50px', align: 'center',
render: (v) => <span className={`text-[10px] ${(v as number) > 0 ? 'text-red-400' : 'text-hint'}`}>{v as number}</span> },
{ key: 'authProvider', label: '인증', width: '70px',
render: (v) => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
{ key: 'lastLoginDtm', label: '최종 로그인', width: '140px', sortable: true,
render: (v) => (
<span className="text-muted-foreground font-mono text-[10px]">
{formatDateTime(v as string)}
</span>
),
},
{ key: 'userId', label: '관리', width: '90px', align: 'center', sortable: false,
render: (_v, row) => (
<div className="flex items-center justify-center gap-1">
<button type="button" onClick={() => setAssignTarget(row)}
className="p-1 text-hint hover:text-purple-400" title="역할 배정">
<UserCog className="w-3 h-3" />
</button>
{row.userSttsCd === 'LOCKED' && (
<button type="button" onClick={() => handleUnlock(row.userId, row.userAcnt)}
className="p-1 text-hint hover:text-green-400" title="잠금 해제">
<Key className="w-3 h-3" />
</button>
)}
</div>
),
},
], []);
// ── 감사 로그 컬럼 ──────────────
const auditColumns: DataColumn<ApiAuditLog & Record<string, unknown>>[] = useMemo(() => [
{ key: 'createdAt', label: '일시', width: '160px', sortable: true,
render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{formatDateTime(v as string)}</span> },
{ key: 'userAcnt', label: '사용자', width: '90px', sortable: true,
render: (v) => <span className="text-cyan-400 font-mono">{(v as string) || '-'}</span> },
{ key: 'actionCd', label: '액션', width: '180px', sortable: true,
render: (v) => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'resourceType', label: '리소스', width: '110px',
render: (v) => <span className="text-muted-foreground">{(v as string) || '-'}</span> },
{ key: 'ipAddress', label: 'IP', width: '120px',
render: (v) => <span className="text-hint font-mono text-[10px]">{(v as string) || '-'}</span> },
{ key: 'result', label: '결과', width: '70px', sortable: true,
render: (v) => {
const r = v as string;
const c = r === 'SUCCESS' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400';
return <Badge className={`border-0 text-[9px] ${c}`}>{r || '-'}</Badge>;
},
},
{ key: 'failReason', label: '실패 사유',
render: (v) => <span className="text-red-400 text-[10px]">{(v as string) || '-'}</span> },
], []);
return (
<div className="space-y-4 p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Shield className="w-5 h-5 text-blue-400" />
{t('accessControl.title')}
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('accessControl.desc')}</p>
</div>
<div className="flex items-center gap-2">
{userStats && (
<div className="flex items-center gap-2 text-[10px] text-hint">
<UserCheck className="w-3.5 h-3.5 text-green-500" />
<span className="text-green-400 font-bold">{userStats.active}</span>
<span className="mx-1">|</span>
<span className="text-red-400 font-bold">{userStats.locked}</span>
<span className="mx-1">|</span>
<span className="text-heading font-bold">{userStats.total}</span>
</div>
)}
<button type="button"
onClick={() => { if (tab === 'users') loadUsers(); else if (tab === 'audit') loadAudit(); }}
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* 탭 */}
<div className="flex gap-1">
{([
{ key: 'roles', icon: Shield, label: '역할 관리' },
{ key: 'users', icon: Users, label: '사용자 관리' },
{ key: 'audit', icon: FileText, label: '감사 로그' },
{ key: 'policy', icon: Lock, label: '보안 정책' },
] as const).map((tt) => (
<button
key={tt.key}
type="button"
onClick={() => setTab(tt.key)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs transition-colors ${
tab === tt.key ? 'bg-blue-600 text-on-vivid' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
}`}
>
<tt.icon className="w-3.5 h-3.5" />
{tt.label}
</button>
))}
</div>
{error && <div className="text-xs text-red-400">: {error}</div>}
{/* ── 역할 관리 (PermissionsPanel: 트리 + R/C/U/D 매트릭스) ── */}
{tab === 'roles' && <PermissionsPanel />}
{/* ── 사용자 관리 ── */}
{tab === 'users' && (
<>
{/* 통계 카드 */}
{userStats && (
<div className="grid grid-cols-4 gap-3">
<StatCard label="총 사용자" value={userStats.total} color="text-heading" />
<StatCard label="활성" value={userStats.active} color="text-green-400" />
<StatCard label="잠금" value={userStats.locked} color="text-red-400" />
<StatCard label="비활성" value={userStats.inactive} color="text-gray-400" />
</div>
)}
{usersLoading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
{!usersLoading && (
<DataTable
data={users as (AdminUser & Record<string, unknown>)[]}
columns={userColumns}
pageSize={10}
searchPlaceholder="계정, 이름, 이메일 검색..."
searchKeys={['userAcnt', 'userNm', 'email', 'rnkpNm']}
exportFilename="사용자목록"
exportResource="admin:user-management"
showPagination
/>
)}
</>
)}
{/* ── 감사 로그 ── */}
{tab === 'audit' && (
<>
{/* 통계 카드 */}
{auditStats && (
<div className="grid grid-cols-4 gap-3">
<StatCard label="전체 로그" value={auditStats.total} color="text-heading" />
<StatCard label="24시간" value={auditStats.last24h} color="text-blue-400" />
<StatCard label="실패 (24시간)" value={auditStats.failed24h} color="text-red-400" />
<StatCard label="액션 종류" value={auditStats.byAction.length} color="text-purple-400" />
</div>
)}
{/* 액션별 분포 */}
{auditStats && auditStats.byAction.length > 0 && (
<Card>
<CardHeader className="px-4 pt-3 pb-2"><CardTitle className="text-xs text-label"> (7)</CardTitle></CardHeader>
<CardContent className="px-4 pb-4">
<div className="flex flex-wrap gap-2">
{auditStats.byAction.map((a) => (
<Badge key={a.action} className="bg-surface-overlay text-muted-foreground border border-border text-[10px]">
{a.action} <span className="text-cyan-400 font-bold ml-1">{a.count}</span>
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{auditLoading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
{!auditLoading && (
<DataTable
data={auditLogs as (ApiAuditLog & Record<string, unknown>)[]}
columns={auditColumns}
pageSize={20}
searchPlaceholder="사용자, 액션, IP 검색..."
searchKeys={['userAcnt', 'actionCd', 'resourceType', 'ipAddress']}
exportFilename="감사로그"
exportResource="admin:audit-logs"
title="모든 운영자 의사결정 자동 기록 (audit_log)"
showPagination
/>
)}
</>
)}
{/* 역할 배정 다이얼로그 */}
{assignTarget && (
<UserRoleAssignDialog
user={assignTarget}
onClose={() => setAssignTarget(null)}
onSaved={loadUsers}
/>
)}
{/* ── 보안 정책 ── */}
{tab === 'policy' && (
<div className="grid grid-cols-2 gap-3">
<PolicyCard title="비밀번호 정책" rows={[
['최소 길이', '9자 이상'],
['복잡도', '영문+숫자+특수문자 조합'],
['변경 주기', '90일'],
['재사용 제한', '최근 3회'],
['만료 경고', '14일 전'],
]} />
<PolicyCard title="계정 잠금 정책" rows={[
['잠금 임계', '5회 연속 실패'],
['잠금 시간', '관리자 해제 시까지'],
['실패 카운터 증가', 'PasswordAuthProvider'],
['관리자 해제', 'POST /api/admin/users/{id}/unlock'],
['감사 기록', 'auth_login_hist + auth_audit_log'],
]} />
<PolicyCard title="세션 관리" rows={[
['세션 타임아웃', '30분 (미사용 시)'],
['JWT 만료', '24시간'],
['저장 방식', 'HttpOnly Cookie'],
['세션 갱신', '활동 시 자동 연장'],
]} />
<PolicyCard title="감사 로그 정책" rows={[
['감사 대상', '모든 @Auditable 액션 + 로그인'],
['기록 위치', 'kcg.auth_audit_log'],
['접근 로그', 'kcg.auth_access_log (비동기)'],
['로그인 이력', 'kcg.auth_login_hist'],
['조회 권한', 'admin:audit-logs (READ)'],
]} />
</div>
)}
</div>
);
}
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
return (
<Card>
<CardContent className="p-4">
<div className="text-[10px] text-hint">{label}</div>
<div className={`text-2xl font-bold ${color} mt-1`}>{value.toLocaleString()}</div>
</CardContent>
</Card>
);
}
function PolicyCard({ title, rows }: { title: string; rows: [string, string][] }) {
return (
<Card>
<CardHeader className="px-4 pt-3 pb-2"><CardTitle className="text-xs text-label">{title}</CardTitle></CardHeader>
<CardContent className="px-4 pb-4 space-y-2">
{rows.map(([k, v]) => (
<div key={k} className="flex justify-between text-[11px]">
<span className="text-hint">{k}</span>
<span className="text-label font-medium">{v}</span>
</div>
))}
</CardContent>
</Card>
);
}