- 모든 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) 사용
387 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|