diff --git a/backend/src/main/java/gc/mda/kcg/admin/AdminStatsController.java b/backend/src/main/java/gc/mda/kcg/admin/AdminStatsController.java new file mode 100644 index 0000000..c3b6f2a --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/admin/AdminStatsController.java @@ -0,0 +1,154 @@ +package gc.mda.kcg.admin; + +import gc.mda.kcg.audit.AccessLogRepository; +import gc.mda.kcg.audit.AuditLogRepository; +import gc.mda.kcg.auth.LoginHistoryRepository; +import gc.mda.kcg.permission.annotation.RequirePermission; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 시스템 관리 대시보드 메트릭 API. + * + * - 감사 로그 / 접근 로그 / 로그인 이력 통계 + * - 24시간 / 7일 추세 + * - 액션별 / 상태별 분포 + * + * 권한: admin:audit-logs, admin:access-logs, admin:login-history (READ) + */ +@RestController +@RequestMapping("/api/admin/stats") +@RequiredArgsConstructor +public class AdminStatsController { + + private final AuditLogRepository auditLogRepository; + private final AccessLogRepository accessLogRepository; + private final LoginHistoryRepository loginHistoryRepository; + private final JdbcTemplate jdbc; + + /** + * 감사 로그 통계. + * - total: 전체 건수 + * - last24h: 24시간 내 건수 + * - failed24h: 24시간 내 FAILED 건수 + * - byAction: 액션별 카운트 (top 10) + * - hourly24: 시간별 24시간 추세 + */ + @GetMapping("/audit") + @RequirePermission(resource = "admin:audit-logs", operation = "READ") + public Map auditStats() { + Map result = new LinkedHashMap<>(); + result.put("total", auditLogRepository.count()); + result.put("last24h", jdbc.queryForObject( + "SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours'", Long.class)); + result.put("failed24h", jdbc.queryForObject( + "SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours' AND result = 'FAILED'", Long.class)); + + List> byAction = jdbc.queryForList( + "SELECT action_cd AS action, COUNT(*) AS count FROM kcg.auth_audit_log " + + "WHERE created_at > now() - interval '7 days' " + + "GROUP BY action_cd ORDER BY count DESC LIMIT 10"); + result.put("byAction", byAction); + + List> hourly = jdbc.queryForList( + "SELECT date_trunc('hour', created_at) AS hour, COUNT(*) AS count " + + "FROM kcg.auth_audit_log " + + "WHERE created_at > now() - interval '24 hours' " + + "GROUP BY hour ORDER BY hour"); + result.put("hourly24", hourly); + + return result; + } + + /** + * 접근 로그 통계. + * - total: 전체 건수 + * - last24h: 24시간 내 + * - error4xx, error5xx: 24시간 내 에러 + * - avgDurationMs: 24시간 내 평균 응답 시간 + * - topPaths: 24시간 내 호출 많은 경로 + */ + @GetMapping("/access") + @RequirePermission(resource = "admin:access-logs", operation = "READ") + public Map accessStats() { + Map result = new LinkedHashMap<>(); + result.put("total", accessLogRepository.count()); + result.put("last24h", jdbc.queryForObject( + "SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'", Long.class)); + result.put("error4xx", jdbc.queryForObject( + "SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 400 AND status_code < 500", Long.class)); + result.put("error5xx", jdbc.queryForObject( + "SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 500", Long.class)); + + Double avg = jdbc.queryForObject( + "SELECT AVG(duration_ms)::float FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'", + Double.class); + result.put("avgDurationMs", avg != null ? Math.round(avg * 10) / 10.0 : 0); + + List> topPaths = jdbc.queryForList( + "SELECT request_path AS path, COUNT(*) AS count, AVG(duration_ms)::int AS avg_ms " + + "FROM kcg.auth_access_log " + + "WHERE created_at > now() - interval '24 hours' AND request_path NOT LIKE '/actuator%' " + + "GROUP BY request_path ORDER BY count DESC LIMIT 10"); + result.put("topPaths", topPaths); + + return result; + } + + /** + * 로그인 통계. + * - total: 전체 건수 + * - success24h: 24시간 내 성공 + * - failed24h: 24시간 내 실패 + * - locked24h: 24시간 내 잠금 + * - successRate: 성공률 (24시간 내, %) + * - byUser: 사용자별 성공 카운트 (top 10) + * - daily7d: 7일 일별 추세 + */ + @GetMapping("/login") + @RequirePermission(resource = "admin:login-history", operation = "READ") + public Map loginStats() { + Map result = new LinkedHashMap<>(); + result.put("total", loginHistoryRepository.count()); + + Long success24h = jdbc.queryForObject( + "SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'SUCCESS'", Long.class); + Long failed24h = jdbc.queryForObject( + "SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'FAILED'", Long.class); + Long locked24h = jdbc.queryForObject( + "SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'LOCKED'", Long.class); + + result.put("success24h", success24h); + result.put("failed24h", failed24h); + result.put("locked24h", locked24h); + + long total24h = (success24h == null ? 0 : success24h) + (failed24h == null ? 0 : failed24h) + (locked24h == null ? 0 : locked24h); + double rate = total24h == 0 ? 0 : (success24h == null ? 0 : success24h) * 100.0 / total24h; + result.put("successRate", Math.round(rate * 10) / 10.0); + + List> byUser = jdbc.queryForList( + "SELECT user_acnt, COUNT(*) AS count FROM kcg.auth_login_hist " + + "WHERE login_dtm > now() - interval '7 days' AND result = 'SUCCESS' " + + "GROUP BY user_acnt ORDER BY count DESC LIMIT 10"); + result.put("byUser", byUser); + + List> daily = jdbc.queryForList( + "SELECT date_trunc('day', login_dtm) AS day, " + + "COUNT(*) FILTER (WHERE result='SUCCESS') AS success, " + + "COUNT(*) FILTER (WHERE result='FAILED') AS failed, " + + "COUNT(*) FILTER (WHERE result='LOCKED') AS locked " + + "FROM kcg.auth_login_hist " + + "WHERE login_dtm > now() - interval '7 days' " + + "GROUP BY day ORDER BY day"); + result.put("daily7d", daily); + + return result; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/admin/UserManagementController.java b/backend/src/main/java/gc/mda/kcg/admin/UserManagementController.java new file mode 100644 index 0000000..0f4c056 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/admin/UserManagementController.java @@ -0,0 +1,138 @@ +package gc.mda.kcg.admin; + +import gc.mda.kcg.audit.annotation.Auditable; +import gc.mda.kcg.auth.User; +import gc.mda.kcg.auth.UserRepository; +import gc.mda.kcg.permission.PermissionService; +import gc.mda.kcg.permission.UserRoleRepository; +import gc.mda.kcg.permission.annotation.RequirePermission; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 사용자 관리 API. + * 권한: admin:user-management + */ +@Slf4j +@RestController +@RequestMapping("/api/admin/users") +@RequiredArgsConstructor +public class UserManagementController { + + private final UserRepository userRepository; + private final UserRoleRepository userRoleRepository; + private final PermissionService permissionService; + + /** + * 사용자 목록 조회 (역할 코드 포함). + */ + @GetMapping + @RequirePermission(resource = "admin:user-management", operation = "READ") + public List> listUsers() { + List users = userRepository.findAll( + org.springframework.data.domain.Sort.by("userAcnt").ascending()); + + return users.stream().>map(u -> { + List roles = userRoleRepository.findRoleCodesByUserId(u.getUserId()); + Map m = new LinkedHashMap<>(); + m.put("userId", u.getUserId().toString()); + m.put("userAcnt", u.getUserAcnt()); + m.put("userNm", u.getUserNm()); + m.put("rnkpNm", u.getRnkpNm()); + m.put("email", u.getEmail()); + m.put("userSttsCd", u.getUserSttsCd()); + m.put("authProvider", u.getAuthProvider()); + m.put("failCnt", u.getFailCnt()); + m.put("lastLoginDtm", u.getLastLoginDtm()); + m.put("createdAt", u.getCreatedAt()); + m.put("roles", roles); + return m; + }).toList(); + } + + /** + * 사용자 통계 (역할별 카운트, 상태별 카운트). + */ + @GetMapping("/stats") + @RequirePermission(resource = "admin:user-management", operation = "READ") + public Map stats() { + List users = userRepository.findAll(); + + Map byStatus = users.stream() + .collect(Collectors.groupingBy(User::getUserSttsCd, Collectors.counting())); + + Map byProvider = users.stream() + .collect(Collectors.groupingBy(User::getAuthProvider, Collectors.counting())); + + // 역할별 사용자 수 + Map byRole = new LinkedHashMap<>(); + for (User u : users) { + for (String role : userRoleRepository.findRoleCodesByUserId(u.getUserId())) { + byRole.merge(role, 1L, Long::sum); + } + } + + Map result = new LinkedHashMap<>(); + result.put("total", (long) users.size()); + result.put("active", byStatus.getOrDefault("ACTIVE", 0L)); + result.put("locked", byStatus.getOrDefault("LOCKED", 0L)); + result.put("inactive", byStatus.getOrDefault("INACTIVE", 0L)); + result.put("pending", byStatus.getOrDefault("PENDING", 0L)); + result.put("byStatus", byStatus); + result.put("byProvider", byProvider); + result.put("byRole", byRole); + return result; + } + + /** + * 잠긴 계정 해제. + */ + @Auditable(action = "USER_UNLOCK", resourceType = "USER") + @PostMapping("/{userId}/unlock") + @RequirePermission(resource = "admin:user-management", operation = "UPDATE") + public Map unlockUser(@PathVariable String userId) { + UUID uid = UUID.fromString(userId); + User user = userRepository.findById(uid) + .orElseThrow(() -> new IllegalArgumentException("USER_NOT_FOUND: " + userId)); + + user.setUserSttsCd("ACTIVE"); + user.setFailCnt(0); + userRepository.save(user); + permissionService.evictUserPermissions(uid); + + log.info("계정 잠금 해제: {}", user.getUserAcnt()); + return Map.of( + "userId", userId, + "userAcnt", user.getUserAcnt(), + "userSttsCd", user.getUserSttsCd() + ); + } + + /** + * 계정 상태 변경 (ACTIVE/LOCKED/INACTIVE). + */ + @Auditable(action = "USER_STATUS_CHANGE", resourceType = "USER") + @PutMapping("/{userId}/status") + @RequirePermission(resource = "admin:user-management", operation = "UPDATE") + public Map changeStatus(@PathVariable String userId, @RequestBody Map body) { + String newStatus = body.get("status"); + if (newStatus == null || !Set.of("ACTIVE", "LOCKED", "INACTIVE", "PENDING").contains(newStatus)) { + throw new IllegalArgumentException("INVALID_STATUS: " + newStatus); + } + UUID uid = UUID.fromString(userId); + User user = userRepository.findById(uid) + .orElseThrow(() -> new IllegalArgumentException("USER_NOT_FOUND: " + userId)); + user.setUserSttsCd(newStatus); + if ("ACTIVE".equals(newStatus)) { + user.setFailCnt(0); + } + userRepository.save(user); + permissionService.evictUserPermissions(uid); + + return Map.of("userId", userId, "userAcnt", user.getUserAcnt(), "userSttsCd", newStatus); + } +} diff --git a/frontend/src/features/admin/AccessControl.tsx b/frontend/src/features/admin/AccessControl.tsx index 41bcec8..a2982bc 100644 --- a/frontend/src/features/admin/AccessControl.tsx +++ b/frontend/src/features/admin/AccessControl.tsx @@ -1,132 +1,222 @@ -import { useState } from 'react'; +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, Clock, Search, Plus, Edit2, Trash2, - Eye, Lock, AlertTriangle, FileText, ChevronDown, ChevronRight + Shield, Users, UserCheck, Key, Lock, FileText, Loader2, RefreshCw, Eye, } from 'lucide-react'; +import { + fetchUsers, + fetchUserStats, + fetchRoles, + fetchAuditLogs, + fetchAuditStats, + unlockUser, + type AdminUser, + type UserStats, + type RoleWithPermissions, + type AuditLog as ApiAuditLog, + type AuditStats, +} from '@/services/adminApi'; /* - * SFR-01: 역할 기반 권한 관리(RBAC) - * - 조직·직급·직무에 따른 권한 관리 - * - 메뉴·기능·데이터 접근 권한 분리 - * - 감사 로그 기록 및 조회 - * - 비밀번호/계정 잠금 정책 설정 + * 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) 보안 정책 - 정적 정보 */ -interface UserAccount { - id: string; - name: string; - rank: string; - org: string; - role: string; - status: '활성' | '잠금' | '비활성'; - lastLogin: string; - loginCount: number; -} +const ROLE_COLORS: Record = { + ADMIN: 'bg-red-500/20 text-red-400', + OPERATOR: 'bg-blue-500/20 text-blue-400', + ANALYST: 'bg-purple-500/20 text-purple-400', + FIELD: 'bg-green-500/20 text-green-400', + VIEWER: 'bg-yellow-500/20 text-yellow-400', +}; -interface AuditLog { - time: string; - user: string; - action: string; - target: string; - ip: string; - result: '성공' | '실패' | '차단'; -} +const STATUS_COLORS: Record = { + ACTIVE: 'bg-green-500/20 text-green-400', + LOCKED: 'bg-red-500/20 text-red-400', + INACTIVE: 'bg-gray-500/20 text-gray-400', + PENDING: 'bg-yellow-500/20 text-yellow-400', +}; -const ROLES = [ - { name: '시스템 관리자', level: 'ADMIN', count: 3, color: 'bg-red-500/20 text-red-400', menus: '전체 메뉴', data: '전체 데이터' }, - { name: '상황실 운영자', level: 'OPERATOR', count: 12, color: 'bg-blue-500/20 text-blue-400', menus: '상황판·통계·경보', data: '관할 해역' }, - { name: '분석 담당자', level: 'ANALYST', count: 8, color: 'bg-purple-500/20 text-purple-400', menus: 'AI모드·통계·항적', data: '분석 데이터' }, - { name: '현장 단속요원', level: 'FIELD', count: 45, color: 'bg-green-500/20 text-green-400', menus: '함정Agent·모바일', data: '할당 구역' }, - { name: '유관기관 열람자', level: 'VIEWER', count: 6, color: 'bg-yellow-500/20 text-yellow-400', menus: '공유 대시보드', data: '공개 정보' }, -]; - -const USERS: UserAccount[] = [ - { id: 'U001', name: '김영수', rank: '사무관', org: '본청 정보통신과', role: '시스템 관리자', status: '활성', lastLogin: '2026-04-03 09:15', loginCount: 342 }, - { id: 'U002', name: '이상호', rank: '경위', org: '서해지방해경청', role: '상황실 운영자', status: '활성', lastLogin: '2026-04-03 08:30', loginCount: 128 }, - { id: 'U003', name: '박민수', rank: '경사', org: '5001함 삼봉', role: '현장 단속요원', status: '활성', lastLogin: '2026-04-02 22:15', loginCount: 67 }, - { id: 'U004', name: '정해진', rank: '주무관', org: '남해지방해경청', role: '분석 담당자', status: '잠금', lastLogin: '2026-04-01 14:20', loginCount: 89 }, - { id: 'U005', name: '최원석', rank: '6급', org: '해수부 어업관리과', role: '유관기관 열람자', status: '활성', lastLogin: '2026-03-28 10:00', loginCount: 12 }, - { id: 'U006', name: '한지영', rank: '경장', org: '3009함', role: '현장 단속요원', status: '비활성', lastLogin: '2026-02-15 16:40', loginCount: 5 }, -]; - -const AUDIT_LOGS: AuditLog[] = [ - { time: '2026-04-03 09:15:23', user: '김영수', action: '로그인', target: '시스템', ip: '10.20.30.1', result: '성공' }, - { time: '2026-04-03 09:12:05', user: '미상', action: '로그인 시도', target: '시스템', ip: '192.168.5.99', result: '차단' }, - { time: '2026-04-03 08:55:11', user: '이상호', action: '위험도 지도 조회', target: 'SFR-05', ip: '10.20.31.5', result: '성공' }, - { time: '2026-04-03 08:30:44', user: '이상호', action: '로그인', target: '시스템', ip: '10.20.31.5', result: '성공' }, - { time: '2026-04-03 07:45:00', user: '정해진', action: '로그인 시도(5회 실패)', target: '시스템', ip: '10.20.40.12', result: '실패' }, - { time: '2026-04-03 07:44:30', user: '시스템', action: '계정 잠금 처리', target: '정해진(U004)', ip: '-', result: '성공' }, - { time: '2026-04-02 22:15:10', user: '박민수', action: '불법어선 탐지 결과 조회', target: 'SFR-09', ip: '10.50.1.33', result: '성공' }, - { time: '2026-04-02 21:00:00', user: '시스템', action: '일일 감사 로그 백업', target: 'DB', ip: '-', result: '성공' }, -]; +const STATUS_LABELS: Record = { + ACTIVE: '활성', + LOCKED: '잠금', + INACTIVE: '비활성', + PENDING: '승인대기', +}; type Tab = 'roles' | 'users' | 'audit' | 'policy'; -// DataTable 컬럼: 사용자 관리 -const userColumns: DataColumn>[] = [ - { key: 'id', label: 'ID', width: '60px', render: (v) => {v as string} }, - { key: 'name', label: '이름', width: '70px', sortable: true, render: (v) => {v as string} }, - { key: 'rank', label: '직급', width: '60px' }, - { key: 'org', label: '소속', sortable: true }, - { key: 'role', label: '역할', width: '100px', sortable: true, - render: (v) => {v as string}, - }, - { key: 'status', label: '상태', width: '60px', sortable: true, - render: (v) => { - const s = v as string; - const c = s === '활성' ? 'bg-green-500/20 text-green-400' : s === '잠금' ? 'bg-red-500/20 text-red-400' : 'bg-muted text-muted-foreground'; - return {s}; - }, - }, - { key: 'lastLogin', label: '최종 로그인', width: '130px', sortable: true, - render: (v) => {v as string}, - }, - { key: 'id', label: '관리', width: '70px', align: 'center', sortable: false, - render: (_v, row) => ( -
- - - {row.status === '잠금' && } -
- ), - }, -]; - -// DataTable 컬럼: 감사 로그 -const auditColumns: DataColumn>[] = [ - { key: 'time', label: '일시', width: '160px', sortable: true, - render: (v) => {v as string}, - }, - { key: 'user', label: '사용자', width: '70px', sortable: true }, - { key: 'action', label: '행위', sortable: true, render: (v) => {v as string} }, - { key: 'target', label: '대상', width: '80px' }, - { key: 'ip', label: 'IP', width: '110px', render: (v) => {v as string} }, - { key: 'result', label: '결과', width: '60px', sortable: true, - render: (v) => { - const r = v as string; - const c = r === '성공' ? 'bg-green-500/20 text-green-400' : r === '실패' ? 'bg-red-500/20 text-red-400' : 'bg-orange-500/20 text-orange-400'; - return {r}; - }, - }, -]; - export function AccessControl() { const { t } = useTranslation('admin'); const [tab, setTab] = useState('roles'); - const tabs: { key: Tab; icon: React.ElementType; label: string }[] = [ - { key: 'roles', icon: Shield, label: '역할 관리' }, - { key: 'users', icon: Users, label: '사용자 관리' }, - { key: 'audit', icon: FileText, label: '감사 로그' }, - { key: 'policy', icon: Lock, label: '보안 정책' }, - ]; + // 공통 상태 + const [error, setError] = useState(''); + + // 사용자 목록 + const [users, setUsers] = useState([]); + const [userStats, setUserStats] = useState(null); + const [usersLoading, setUsersLoading] = useState(false); + + // 역할 목록 + const [roles, setRoles] = useState([]); + const [rolesLoading, setRolesLoading] = useState(false); + + // 감사 로그 + const [auditLogs, setAuditLogs] = useState([]); + const [auditStats, setAuditStats] = useState(null); + const [auditLoading, setAuditLoading] = useState(false); + + // 사용자 + 통계 로드 + 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 loadRoles = useCallback(async () => { + setRolesLoading(true); setError(''); + try { + const r = await fetchRoles(); + setRoles(r); + // 사용자 통계도 같이 로드 (역할별 카운트 사용) + if (!userStats) { + const s = await fetchUserStats(); + setUserStats(s); + } + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'unknown'); + } finally { + setRolesLoading(false); + } + }, [userStats]); + + 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); + } + }, []); + + // 탭 전환 시 자동 로드 + useEffect(() => { + if (tab === 'roles') loadRoles(); + else if (tab === 'users') loadUsers(); + else if (tab === 'audit') loadAudit(); + }, [tab, loadRoles, 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')); + } + }; + + // ── 사용자 테이블 컬럼 ────────────── + const userColumns: DataColumn>[] = useMemo(() => [ + { key: 'userAcnt', label: '계정', width: '90px', + render: (v) => {v as string} }, + { key: 'userNm', label: '이름', width: '80px', sortable: true, + render: (v) => {v as string} }, + { key: 'rnkpNm', label: '직급', width: '60px', + render: (v) => {(v as string) || '-'} }, + { key: 'email', label: '이메일', + render: (v) => {(v as string) || '-'} }, + { key: 'roles', label: '역할', width: '120px', + render: (v) => { + const list = (v as string[]) || []; + return ( +
+ {list.map((r) => ( + {r} + ))} +
+ ); + }, + }, + { key: 'userSttsCd', label: '상태', width: '70px', sortable: true, + render: (v) => { + const s = v as string; + return {STATUS_LABELS[s] || s}; + }, + }, + { key: 'failCnt', label: '실패', width: '50px', align: 'center', + render: (v) => 0 ? 'text-red-400' : 'text-hint'}`}>{v as number} }, + { key: 'authProvider', label: '인증', width: '70px', + render: (v) => {v as string} }, + { key: 'lastLoginDtm', label: '최종 로그인', width: '140px', sortable: true, + render: (v) => ( + + {v ? new Date(v as string).toLocaleString('ko-KR') : '-'} + + ), + }, + { key: 'userId', label: '관리', width: '70px', align: 'center', sortable: false, + render: (_v, row) => ( +
+ + {row.userSttsCd === 'LOCKED' && ( + + )} +
+ ), + }, + ], []); + + // ── 감사 로그 컬럼 ────────────── + const auditColumns: DataColumn>[] = useMemo(() => [ + { key: 'createdAt', label: '일시', width: '160px', sortable: true, + render: (v) => {new Date(v as string).toLocaleString('ko-KR')} }, + { key: 'userAcnt', label: '사용자', width: '90px', sortable: true, + render: (v) => {(v as string) || '-'} }, + { key: 'actionCd', label: '액션', width: '180px', sortable: true, + render: (v) => {v as string} }, + { key: 'resourceType', label: '리소스', width: '110px', + render: (v) => {(v as string) || '-'} }, + { key: 'ipAddress', label: 'IP', width: '120px', + render: (v) => {(v as string) || '-'} }, + { 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 {r || '-'}; + }, + }, + { key: 'failReason', label: '실패 사유', + render: (v) => {(v as string) || '-'} }, + ], []); return ( -
+

@@ -135,176 +225,222 @@ export function AccessControl() {

{t('accessControl.desc')}

-
- - 활성 사용자 {USERS.filter((u) => u.status === '활성').length}명 - | - 총 등록 {USERS.length}명 +
+ {userStats && ( +
+ + 활성 {userStats.active}명 + | + 잠금 {userStats.locked} + | + 총 {userStats.total} +
+ )} +
{/* 탭 */}
- {tabs.map((t) => ( + {([ + { key: 'roles', icon: Shield, label: '역할 관리' }, + { key: 'users', icon: Users, label: '사용자 관리' }, + { key: 'audit', icon: FileText, label: '감사 로그' }, + { key: 'policy', icon: Lock, label: '보안 정책' }, + ] as const).map((tt) => ( ))}
+ {error &&
에러: {error}
} + {/* ── 역할 관리 ── */} {tab === 'roles' && ( -
- {ROLES.map((r) => ( - - -
-
- {r.level} -
-
{r.name}
-
할당 인원: {r.count}명
+
+ {rolesLoading &&
} + {!rolesLoading && roles.map((r) => { + const userCount = userStats?.byRole?.[r.roleCd] ?? 0; + const grantCount = r.permissions?.filter((p) => p.grantYn === 'Y').length ?? 0; + return ( + + +
+
+ + {r.roleCd} + +
+
{r.roleNm}
+
{r.roleDc || '-'}
+
+
+
+
+ 할당 인원: + {userCount}명 +
+
+ 명시 권한: + {grantCount}개 +
+ {r.builtinYn === 'Y' && BUILT-IN} + {r.dfltYn === 'Y' && DEFAULT}
-
-
- 메뉴 접근: - {r.menus} -
-
- 데이터 범위: - {r.data} -
- -
-
- - - ))} - + + + ); + })} + {!rolesLoading && roles.length === 0 &&
역할이 없습니다.
}
)} - {/* ── 사용자 관리 — DataTable 적용 ── */} + {/* ── 사용자 관리 ── */} {tab === 'users' && ( - )[]} - columns={userColumns} - pageSize={10} - searchPlaceholder="이름, 소속, 역할 검색..." - searchKeys={['name', 'org', 'role', 'rank']} - exportFilename="사용자목록" - showPagination - /> + <> + {/* 통계 카드 */} + {userStats && ( +
+ + + + +
+ )} + + {usersLoading &&
} + {!usersLoading && ( + )[]} + columns={userColumns} + pageSize={10} + searchPlaceholder="계정, 이름, 이메일 검색..." + searchKeys={['userAcnt', 'userNm', 'email', 'rnkpNm']} + exportFilename="사용자목록" + showPagination + /> + )} + )} - {/* ── 감사 로그 — DataTable 적용 ── */} + {/* ── 감사 로그 ── */} {tab === 'audit' && ( - )[]} - columns={auditColumns} - pageSize={10} - searchPlaceholder="사용자, 행위, IP 검색..." - searchKeys={['user', 'action', 'ip', 'target']} - exportFilename="감사로그" - title="로그인/로그아웃·비정상 접속·중요 정보 접근 감사 로그" - showPagination - /> + <> + {/* 통계 카드 */} + {auditStats && ( +
+ + + + +
+ )} + + {/* 액션별 분포 */} + {auditStats && auditStats.byAction.length > 0 && ( + + 액션별 분포 (7일) + +
+ {auditStats.byAction.map((a) => ( + + {a.action} {a.count} + + ))} +
+
+
+ )} + + {auditLoading &&
} + {!auditLoading && ( + )[]} + columns={auditColumns} + pageSize={20} + searchPlaceholder="사용자, 액션, IP 검색..." + searchKeys={['userAcnt', 'actionCd', 'resourceType', 'ipAddress']} + exportFilename="감사로그" + title="모든 운영자 의사결정 자동 기록 (audit_log)" + showPagination + /> + )} + )} {/* ── 보안 정책 ── */} {tab === 'policy' && (
- - - 비밀번호 정책 - - - {[ - ['최소 길이', '9자 이상'], - ['복잡도', '영문+숫자+특수문자 조합'], - ['변경 주기', '90일'], - ['재사용 제한', '최근 3회'], - ['만료 경고', '14일 전'], - ].map(([k, v]) => ( -
- {k} - {v} -
- ))} -
-
- - - - 계정 잠금 정책 - - - {[ - ['잠금 임계', '5회 연속 실패'], - ['잠금 시간', '30분'], - ['자동 해제', '활성'], - ['관리자 해제', '즉시 가능'], - ['비정상 접속 알림', 'SMS + 시스템 알림'], - ].map(([k, v]) => ( -
- {k} - {v} -
- ))} -
-
- - - - 세션 관리 - - - {[ - ['세션 타임아웃', '30분 (미사용 시)'], - ['동시 접속', '1계정 1세션'], - ['중복 로그인', '이전 세션 종료'], - ['세션 갱신', '활동 시 자동 연장'], - ].map(([k, v]) => ( -
- {k} - {v} -
- ))} -
-
- - - - 감사 로그 정책 - - - {[ - ['로그 보존', '1년 이상'], - ['기록 대상', '로그인·권한변경·데이터접근'], - ['무결성 보장', 'Hash 검증'], - ['백업 주기', '일 1회 자동'], - ['조회 권한', 'ADMIN 전용'], - ].map(([k, v]) => ( -
- {k} - {v} -
- ))} -
-
+ + + +
)}
); } + +function StatCard({ label, value, color }: { label: string; value: number; color: string }) { + return ( + + +
{label}
+
{value.toLocaleString()}
+
+
+ ); +} + +function PolicyCard({ title, rows }: { title: string; rows: [string, string][] }) { + return ( + + {title} + + {rows.map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/features/admin/AccessLogs.tsx b/frontend/src/features/admin/AccessLogs.tsx index a6333f6..a6fb1a6 100644 --- a/frontend/src/features/admin/AccessLogs.tsx +++ b/frontend/src/features/admin/AccessLogs.tsx @@ -1,25 +1,25 @@ import { useEffect, useState, useCallback } from 'react'; import { Loader2, RefreshCw } from 'lucide-react'; -import { Card, CardContent } from '@shared/components/ui/card'; +import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; -import { fetchAccessLogs, type AccessLog } from '@/services/adminApi'; +import { fetchAccessLogs, fetchAccessStats, type AccessLog, type AccessStats } from '@/services/adminApi'; /** - * 접근 이력 조회 (모든 HTTP 요청). + * 접근 이력 조회 + 메트릭 카드. * 권한: admin:access-logs (READ) - * - * 백엔드 AccessLogFilter가 모든 요청을 비동기로 기록. */ export function AccessLogs() { const [items, setItems] = useState([]); + const [stats, setStats] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const load = useCallback(async () => { setLoading(true); setError(''); try { - const res = await fetchAccessLogs(0, 100); - setItems(res.content); + const [logs, st] = await Promise.all([fetchAccessLogs(0, 100), fetchAccessStats()]); + setItems(logs.content); + setStats(st); } catch (e: unknown) { setError(e instanceof Error ? e.message : 'unknown'); } finally { @@ -29,20 +29,59 @@ export function AccessLogs() { useEffect(() => { load(); }, [load]); - const statusColor = (s: number) => s >= 500 ? 'bg-red-500/20 text-red-400' : s >= 400 ? 'bg-orange-500/20 text-orange-400' : 'bg-green-500/20 text-green-400'; + const statusColor = (s: number) => + s >= 500 ? 'bg-red-500/20 text-red-400' + : s >= 400 ? 'bg-orange-500/20 text-orange-400' + : 'bg-green-500/20 text-green-400'; return (

접근 이력

-

모든 HTTP 요청 (AccessLogFilter 비동기 기록)

+

AccessLogFilter가 모든 HTTP 요청 비동기 기록

+ {stats && ( +
+ + + + + +
+ )} + + {stats && stats.topPaths.length > 0 && ( + + 호출 빈도 Top 10 (24시간) + + + + + + + + + + + {stats.topPaths.map((p) => ( + + + + + + ))} + +
경로호출수평균(ms)
{p.path}{p.count}{p.avg_ms}
+
+
+ )} + {error &&
에러: {error}
} {loading &&
} @@ -87,3 +126,14 @@ export function AccessLogs() {
); } + +function MetricCard({ label, value, color }: { label: string; value: number; color: string }) { + return ( + + +
{label}
+
{value.toLocaleString()}
+
+
+ ); +} diff --git a/frontend/src/features/admin/AuditLogs.tsx b/frontend/src/features/admin/AuditLogs.tsx index 1360ad4..a4a3b38 100644 --- a/frontend/src/features/admin/AuditLogs.tsx +++ b/frontend/src/features/admin/AuditLogs.tsx @@ -1,26 +1,25 @@ import { useEffect, useState, useCallback } from 'react'; import { Loader2, RefreshCw } from 'lucide-react'; -import { Card, CardContent } from '@shared/components/ui/card'; +import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; -import { fetchAuditLogs, type AuditLog } from '@/services/adminApi'; +import { fetchAuditLogs, fetchAuditStats, type AuditLog, type AuditStats } from '@/services/adminApi'; /** - * 감사 로그 조회 화면. + * 감사 로그 조회 + 메트릭 카드. * 권한: admin:audit-logs (READ) - * - * 모든 운영자 의사결정 액션 (CONFIRM/REJECT/EXCLUDE/LABEL/LOGIN/...) - * 이 백엔드 AuditAspect를 통해 자동 기록됨. */ export function AuditLogs() { const [items, setItems] = useState([]); + const [stats, setStats] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const load = useCallback(async () => { setLoading(true); setError(''); try { - const res = await fetchAuditLogs(0, 100); - setItems(res.content); + const [logs, st] = await Promise.all([fetchAuditLogs(0, 100), fetchAuditStats()]); + setItems(logs.content); + setStats(st); } catch (e: unknown) { setError(e instanceof Error ? e.message : 'unknown'); } finally { @@ -35,13 +34,39 @@ export function AuditLogs() {

감사 로그

-

모든 운영자 의사결정 액션 자동 기록 (LOGIN/REVIEW_PARENT/EXCLUDE/LABEL...)

+

@Auditable AOP가 모든 운영자 의사결정 자동 기록

+ {/* 통계 카드 */} + {stats && ( +
+ + + + +
+ )} + + {/* 액션별 분포 */} + {stats && stats.byAction.length > 0 && ( + + 액션별 분포 (최근 7일) + +
+ {stats.byAction.map((a) => ( + + {a.action} {a.count} + + ))} +
+
+
+ )} + {error &&
에러: {error}
} {loading &&
} @@ -92,3 +117,14 @@ export function AuditLogs() {
); } + +function MetricCard({ label, value, color }: { label: string; value: number; color: string }) { + return ( + + +
{label}
+
{value.toLocaleString()}
+
+
+ ); +} diff --git a/frontend/src/features/admin/LoginHistoryView.tsx b/frontend/src/features/admin/LoginHistoryView.tsx index 1842de6..283b497 100644 --- a/frontend/src/features/admin/LoginHistoryView.tsx +++ b/frontend/src/features/admin/LoginHistoryView.tsx @@ -1,23 +1,25 @@ import { useEffect, useState, useCallback } from 'react'; import { Loader2, RefreshCw } from 'lucide-react'; -import { Card, CardContent } from '@shared/components/ui/card'; +import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; -import { fetchLoginHistory, type LoginHistory } from '@/services/adminApi'; +import { fetchLoginHistory, fetchLoginStats, type LoginHistory, type LoginStats } from '@/services/adminApi'; /** - * 로그인 이력 조회. + * 로그인 이력 조회 + 메트릭 카드. * 권한: admin:login-history (READ) */ export function LoginHistoryView() { const [items, setItems] = useState([]); + const [stats, setStats] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const load = useCallback(async () => { setLoading(true); setError(''); try { - const res = await fetchLoginHistory(0, 100); - setItems(res.content); + const [logs, st] = await Promise.all([fetchLoginHistory(0, 100), fetchLoginStats()]); + setItems(logs.content); + setStats(st); } catch (e: unknown) { setError(e instanceof Error ? e.message : 'unknown'); } finally { @@ -45,6 +47,53 @@ export function LoginHistoryView() {
+ {/* 통계 카드 */} + {stats && ( +
+ + + + + +
+ )} + + {/* 사용자별 + 일자별 추세 */} + {stats && (stats.byUser.length > 0 || stats.daily7d.length > 0) && ( +
+ {stats.byUser.length > 0 && ( + + 사용자별 성공 로그인 (7일) + + {stats.byUser.map((u) => ( +
+ {u.user_acnt} + {u.count}회 +
+ ))} +
+
+ )} + {stats.daily7d.length > 0 && ( + + 일별 추세 (7일) + + {stats.daily7d.map((d) => ( +
+ {new Date(d.day).toLocaleDateString('ko-KR')} +
+ 성공 {d.success} + 실패 {d.failed} + 잠금 {d.locked} +
+
+ ))} +
+
+ )} +
+ )} + {error &&
에러: {error}
} {loading &&
} @@ -87,3 +136,14 @@ export function LoginHistoryView() {
); } + +function MetricCard({ label, value, color, suffix }: { label: string; value: number; color: string; suffix?: string }) { + return ( + + +
{label}
+
{value.toLocaleString()}{suffix && {suffix}}
+
+
+ ); +} diff --git a/frontend/src/services/adminApi.ts b/frontend/src/services/adminApi.ts index 4b76bd3..401fc5a 100644 --- a/frontend/src/services/adminApi.ts +++ b/frontend/src/services/adminApi.ts @@ -98,3 +98,103 @@ export function fetchPermTree() { export function fetchRoles() { return apiGet('/roles'); } + +// ============================================================================ +// 사용자 관리 +// ============================================================================ + +export interface AdminUser { + userId: string; + userAcnt: string; + userNm: string; + rnkpNm: string | null; + email: string | null; + userSttsCd: string; + authProvider: string; + failCnt: number; + lastLoginDtm: string | null; + createdAt: string; + roles: string[]; +} + +export interface UserStats { + total: number; + active: number; + locked: number; + inactive: number; + pending: number; + byStatus: Record; + byProvider: Record; + byRole: Record; +} + +export function fetchUsers() { + return apiGet('/admin/users'); +} + +export function fetchUserStats() { + return apiGet('/admin/users/stats'); +} + +export async function unlockUser(userId: string) { + const res = await fetch(`${API_BASE}/admin/users/${userId}/unlock`, { + method: 'POST', + credentials: 'include', + }); + if (!res.ok) throw new Error(`API ${res.status}: unlock`); + return res.json(); +} + +export async function changeUserStatus(userId: string, status: string) { + const res = await fetch(`${API_BASE}/admin/users/${userId}/status`, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }), + }); + if (!res.ok) throw new Error(`API ${res.status}: status`); + return res.json(); +} + +// ============================================================================ +// 통계 (대시보드 카드) +// ============================================================================ + +export interface AuditStats { + total: number; + last24h: number; + failed24h: number; + byAction: { action: string; count: number }[]; + hourly24: { hour: string; count: number }[]; +} + +export interface AccessStats { + total: number; + last24h: number; + error4xx: number; + error5xx: number; + avgDurationMs: number; + topPaths: { path: string; count: number; avg_ms: number }[]; +} + +export interface LoginStats { + total: number; + success24h: number; + failed24h: number; + locked24h: number; + successRate: number; + byUser: { user_acnt: string; count: number }[]; + daily7d: { day: string; success: number; failed: number; locked: number }[]; +} + +export function fetchAuditStats() { + return apiGet('/admin/stats/audit'); +} + +export function fetchAccessStats() { + return apiGet('/admin/stats/access'); +} + +export function fetchLoginStats() { + return apiGet('/admin/stats/login'); +}