feat: 시스템 관리 페이지 백엔드 연결 + 메트릭 카드

백엔드 API 추가:
- UserManagementController (admin:user-management)
  - GET /api/admin/users : 사용자 목록 + 역할 코드
  - GET /api/admin/users/stats : 상태별/역할별/인증방식별 카운트
  - POST /api/admin/users/{id}/unlock : 잠금 해제 (@Auditable USER_UNLOCK)
  - PUT /api/admin/users/{id}/status : 상태 변경 (@Auditable USER_STATUS_CHANGE)
  - 권한 캐시 evict 자동 호출
- AdminStatsController (admin:audit-logs/access-logs/login-history READ)
  - GET /api/admin/stats/audit : 전체/24시간/실패/액션별/시간별 통계
  - GET /api/admin/stats/access : 전체/24시간/4xx/5xx/평균응답/인기경로
  - GET /api/admin/stats/login : 성공률/사용자별/일별 추세

프론트엔드 연결:
- adminApi.ts 확장: AdminUser/UserStats/AuditStats/AccessStats/LoginStats
  타입 정의 + 사용자/통계 fetch 함수
- AccessControl.tsx (시스템 관리 > 권한 관리):
  - 4개 탭 모두 백엔드 연결
  - 역할 관리: GET /api/roles + 사용자별 카운트 표시
  - 사용자 관리: GET /api/admin/users + DataTable + 잠금 해제 버튼
    + 통계 카드 4개 (총/활성/잠금/비활성)
  - 감사 로그: GET /api/admin/audit-logs + GET /api/admin/stats/audit
    + 액션별 분포 Badge + 통계 카드
  - 보안 정책: 실제 백엔드 동작과 일치하도록 갱신
- AuditLogs.tsx: 메트릭 카드 4개 + 액션별 분포
- AccessLogs.tsx: 메트릭 카드 5개 (전체/24시간/4xx/5xx/평균) + Top 10 경로 테이블
- LoginHistoryView.tsx: 메트릭 카드 5개 + 사용자별 + 일별 추세

검증:
- /api/admin/users → 5명 (admin/operator/analyst/field/viewer)
- /api/admin/users/stats → byRole, byStatus, byProvider 카운트
- /api/admin/stats/audit → total 15, 액션 6종, hourly 추세
- /api/admin/stats/login → success 80%, byUser top, daily 추세
- 프론트엔드 빌드 통과 (493ms)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-07 09:57:59 +09:00
부모 bae2f33b86
커밋 fc1a686700
7개의 변경된 파일944개의 추가작업 그리고 270개의 파일을 삭제

파일 보기

@ -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<String, Object> auditStats() {
Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> accessStats() {
Map<String, Object> 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<Map<String, Object>> 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<String, Object> loginStats() {
Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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;
}
}

파일 보기

@ -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<Map<String, Object>> listUsers() {
List<User> users = userRepository.findAll(
org.springframework.data.domain.Sort.by("userAcnt").ascending());
return users.stream().<Map<String, Object>>map(u -> {
List<String> roles = userRoleRepository.findRoleCodesByUserId(u.getUserId());
Map<String, Object> 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<String, Object> stats() {
List<User> users = userRepository.findAll();
Map<String, Long> byStatus = users.stream()
.collect(Collectors.groupingBy(User::getUserSttsCd, Collectors.counting()));
Map<String, Long> byProvider = users.stream()
.collect(Collectors.groupingBy(User::getAuthProvider, Collectors.counting()));
// 역할별 사용자
Map<String, Long> byRole = new LinkedHashMap<>();
for (User u : users) {
for (String role : userRoleRepository.findRoleCodesByUserId(u.getUserId())) {
byRole.merge(role, 1L, Long::sum);
}
}
Map<String, Object> 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<String, Object> 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<String, Object> changeStatus(@PathVariable String userId, @RequestBody Map<String, String> 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);
}
}

파일 보기

@ -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<string, string> = {
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<string, string> = {
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<string, string> = {
ACTIVE: '활성',
LOCKED: '잠금',
INACTIVE: '비활성',
PENDING: '승인대기',
};
type Tab = 'roles' | 'users' | 'audit' | 'policy';
// DataTable 컬럼: 사용자 관리
const userColumns: DataColumn<UserAccount & Record<string, unknown>>[] = [
{ key: 'id', label: 'ID', width: '60px', render: (v) => <span className="text-hint font-mono">{v as string}</span> },
{ key: 'name', label: '이름', width: '70px', sortable: true, render: (v) => <span className="text-heading font-medium">{v as string}</span> },
{ key: 'rank', label: '직급', width: '60px' },
{ key: 'org', label: '소속', sortable: true },
{ key: 'role', label: '역할', width: '100px', sortable: true,
render: (v) => <Badge className="bg-switch-background/50 text-label border-0 text-[9px]">{v as string}</Badge>,
},
{ 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 <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>;
},
},
{ key: 'lastLogin', label: '최종 로그인', width: '130px', sortable: true,
render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span>,
},
{ key: 'id', label: '관리', width: '70px', align: 'center', sortable: false,
render: (_v, row) => (
<div className="flex items-center justify-center gap-1">
<button className="p-1 text-hint hover:text-blue-400" title="상세"><Eye className="w-3 h-3" /></button>
<button className="p-1 text-hint hover:text-yellow-400" title="수정"><Edit2 className="w-3 h-3" /></button>
{row.status === '잠금' && <button className="p-1 text-hint hover:text-green-400" title="잠금 해제"><Key className="w-3 h-3" /></button>}
</div>
),
},
];
// DataTable 컬럼: 감사 로그
const auditColumns: DataColumn<AuditLog & Record<string, unknown>>[] = [
{ key: 'time', label: '일시', width: '160px', sortable: true,
render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span>,
},
{ key: 'user', label: '사용자', width: '70px', sortable: true },
{ key: 'action', label: '행위', sortable: true, render: (v) => <span className="text-heading">{v as string}</span> },
{ key: 'target', label: '대상', width: '80px' },
{ key: 'ip', label: 'IP', width: '110px', render: (v) => <span className="text-hint font-mono">{v as string}</span> },
{ 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 <Badge className={`border-0 text-[9px] ${c}`}>{r}</Badge>;
},
},
];
export function AccessControl() {
const { t } = useTranslation('admin');
const [tab, setTab] = useState<Tab>('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<AdminUser[]>([]);
const [userStats, setUserStats] = useState<UserStats | null>(null);
const [usersLoading, setUsersLoading] = useState(false);
// 역할 목록
const [roles, setRoles] = useState<RoleWithPermissions[]>([]);
const [rolesLoading, setRolesLoading] = useState(false);
// 감사 로그
const [auditLogs, setAuditLogs] = useState<ApiAuditLog[]>([]);
const [auditStats, setAuditStats] = useState<AuditStats | null>(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<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} className={`${ROLE_COLORS[r] || ''} border-0 text-[9px]`}>{r}</Badge>
))}
</div>
);
},
},
{ key: 'userSttsCd', label: '상태', width: '70px', sortable: true,
render: (v) => {
const s = v as string;
return <Badge className={`border-0 text-[9px] ${STATUS_COLORS[s] || ''}`}>{STATUS_LABELS[s] || s}</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]">
{v ? new Date(v as string).toLocaleString('ko-KR') : '-'}
</span>
),
},
{ key: 'userId', label: '관리', width: '70px', align: 'center', sortable: false,
render: (_v, row) => (
<div className="flex items-center justify-center gap-1">
<button type="button" className="p-1 text-hint hover:text-blue-400" title="상세">
<Eye 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]">{new Date(v as string).toLocaleString('ko-KR')}</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">
<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">
@ -135,176 +225,222 @@ export function AccessControl() {
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('accessControl.desc')}</p>
</div>
<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">{USERS.filter((u) => u.status === '활성').length}</span>
<span className="mx-1">|</span>
<span className="text-heading font-bold">{USERS.length}</span>
<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 === 'roles') loadRoles(); else 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">
{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) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
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 === t.key ? 'bg-blue-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
tab === tt.key ? 'bg-blue-600 text-heading' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'
}`}
>
<t.icon className="w-3.5 h-3.5" />
{t.label}
<tt.icon className="w-3.5 h-3.5" />
{tt.label}
</button>
))}
</div>
{error && <div className="text-xs text-red-400">: {error}</div>}
{/* ── 역할 관리 ── */}
{tab === 'roles' && (
<div className="grid grid-cols-1 gap-3">
{ROLES.map((r) => (
<Card key={r.name} className="bg-surface-raised border-border">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Badge className={`${r.color} border-0 text-[10px] px-2 py-0.5`}>{r.level}</Badge>
<div>
<div className="text-sm font-bold text-heading">{r.name}</div>
<div className="text-[10px] text-hint"> : {r.count}</div>
<div className="space-y-3">
{rolesLoading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
{!rolesLoading && roles.map((r) => {
const userCount = userStats?.byRole?.[r.roleCd] ?? 0;
const grantCount = r.permissions?.filter((p) => p.grantYn === 'Y').length ?? 0;
return (
<Card key={r.roleSn} className="bg-surface-raised border-border">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Badge className={`${ROLE_COLORS[r.roleCd] || 'bg-gray-500/20 text-gray-400'} border-0 text-[10px] px-2 py-0.5`}>
{r.roleCd}
</Badge>
<div>
<div className="text-sm font-bold text-heading">{r.roleNm}</div>
<div className="text-[10px] text-hint">{r.roleDc || '-'}</div>
</div>
</div>
<div className="flex items-center gap-6 text-[10px]">
<div>
<span className="text-hint"> : </span>
<span className="text-heading font-bold">{userCount}</span>
</div>
<div>
<span className="text-hint"> : </span>
<span className="text-cyan-400 font-bold">{grantCount}</span>
</div>
{r.builtinYn === 'Y' && <Badge className="bg-blue-500/20 text-blue-400 border-0 text-[9px]">BUILT-IN</Badge>}
{r.dfltYn === 'Y' && <Badge className="bg-green-500/20 text-green-400 border-0 text-[9px]">DEFAULT</Badge>}
</div>
</div>
<div className="flex items-center gap-6 text-[10px]">
<div>
<span className="text-hint"> : </span>
<span className="text-label">{r.menus}</span>
</div>
<div>
<span className="text-hint"> : </span>
<span className="text-label">{r.data}</span>
</div>
<button className="text-hint hover:text-blue-400"><Edit2 className="w-3.5 h-3.5" /></button>
</div>
</div>
</CardContent>
</Card>
))}
<button className="flex items-center justify-center gap-1.5 py-3 border border-dashed border-slate-700/50 rounded-lg text-[11px] text-hint hover:text-blue-400 hover:border-blue-500/30 transition-colors">
<Plus className="w-3.5 h-3.5" />
</button>
</CardContent>
</Card>
);
})}
{!rolesLoading && roles.length === 0 && <div className="text-center text-hint py-8"> .</div>}
</div>
)}
{/* ── 사용자 관리 — DataTable 적용 ── */}
{/* ── 사용자 관리 ── */}
{tab === 'users' && (
<DataTable
data={USERS as (UserAccount & Record<string, unknown>)[]}
columns={userColumns}
pageSize={10}
searchPlaceholder="이름, 소속, 역할 검색..."
searchKeys={['name', 'org', 'role', 'rank']}
exportFilename="사용자목록"
showPagination
/>
<>
{/* 통계 카드 */}
{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="사용자목록"
showPagination
/>
)}
</>
)}
{/* ── 감사 로그 — DataTable 적용 ── */}
{/* ── 감사 로그 ── */}
{tab === 'audit' && (
<DataTable
data={AUDIT_LOGS as (AuditLog & Record<string, unknown>)[]}
columns={auditColumns}
pageSize={10}
searchPlaceholder="사용자, 행위, IP 검색..."
searchKeys={['user', 'action', 'ip', 'target']}
exportFilename="감사로그"
title="로그인/로그아웃·비정상 접속·중요 정보 접근 감사 로그"
showPagination
/>
<>
{/* 통계 카드 */}
{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="감사로그"
title="모든 운영자 의사결정 자동 기록 (audit_log)"
showPagination
/>
)}
</>
)}
{/* ── 보안 정책 ── */}
{tab === 'policy' && (
<div className="grid grid-cols-2 gap-3">
<Card>
<CardHeader className="px-4 pt-3 pb-2">
<CardTitle className="text-xs text-label"> </CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-2">
{[
['최소 길이', '9자 이상'],
['복잡도', '영문+숫자+특수문자 조합'],
['변경 주기', '90일'],
['재사용 제한', '최근 3회'],
['만료 경고', '14일 전'],
].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>
<Card>
<CardHeader className="px-4 pt-3 pb-2">
<CardTitle className="text-xs text-label"> </CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-2">
{[
['잠금 임계', '5회 연속 실패'],
['잠금 시간', '30분'],
['자동 해제', '활성'],
['관리자 해제', '즉시 가능'],
['비정상 접속 알림', 'SMS + 시스템 알림'],
].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>
<Card>
<CardHeader className="px-4 pt-3 pb-2">
<CardTitle className="text-xs text-label"> </CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-2">
{[
['세션 타임아웃', '30분 (미사용 시)'],
['동시 접속', '1계정 1세션'],
['중복 로그인', '이전 세션 종료'],
['세션 갱신', '활동 시 자동 연장'],
].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>
<Card>
<CardHeader className="px-4 pt-3 pb-2">
<CardTitle className="text-xs text-label"> </CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-2">
{[
['로그 보존', '1년 이상'],
['기록 대상', '로그인·권한변경·데이터접근'],
['무결성 보장', 'Hash 검증'],
['백업 주기', '일 1회 자동'],
['조회 권한', 'ADMIN 전용'],
].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>
<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>
);
}

파일 보기

@ -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<AccessLog[]>([]);
const [stats, setStats] = useState<AccessStats | null>(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 (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-heading"> </h1>
<p className="text-xs text-hint mt-1"> HTTP (AccessLogFilter )</p>
<p className="text-xs text-hint mt-1">AccessLogFilter가 HTTP </p>
</div>
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded flex items-center gap-1">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{stats && (
<div className="grid grid-cols-5 gap-3">
<MetricCard label="전체 요청" value={stats.total} color="text-heading" />
<MetricCard label="24시간 내" value={stats.last24h} color="text-blue-400" />
<MetricCard label="4xx (24h)" value={stats.error4xx} color="text-orange-400" />
<MetricCard label="5xx (24h)" value={stats.error5xx} color="text-red-400" />
<MetricCard label="평균 응답(ms)" value={Math.round(stats.avgDurationMs)} color="text-purple-400" />
</div>
)}
{stats && stats.topPaths.length > 0 && (
<Card>
<CardHeader className="px-4 pt-3 pb-2"><CardTitle className="text-xs text-label"> Top 10 (24)</CardTitle></CardHeader>
<CardContent className="px-4 pb-4">
<table className="w-full text-[11px]">
<thead className="text-hint">
<tr>
<th className="text-left py-1"></th>
<th className="text-right py-1 w-24"></th>
<th className="text-right py-1 w-28">(ms)</th>
</tr>
</thead>
<tbody>
{stats.topPaths.map((p) => (
<tr key={p.path} className="border-t border-border">
<td className="py-1.5 text-heading font-mono text-[10px]">{p.path}</td>
<td className="py-1.5 text-right text-cyan-400 font-bold">{p.count}</td>
<td className="py-1.5 text-right text-muted-foreground">{p.avg_ms}</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
@ -87,3 +126,14 @@ export function AccessLogs() {
</div>
);
}
function MetricCard({ 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>
);
}

파일 보기

@ -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<AuditLog[]>([]);
const [stats, setStats] = useState<AuditStats | null>(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() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-heading"> </h1>
<p className="text-xs text-hint mt-1"> (LOGIN/REVIEW_PARENT/EXCLUDE/LABEL...)</p>
<p className="text-xs text-hint mt-1">@Auditable AOP가 </p>
</div>
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded flex items-center gap-1">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{/* 통계 카드 */}
{stats && (
<div className="grid grid-cols-4 gap-3">
<MetricCard label="전체 로그" value={stats.total} color="text-heading" />
<MetricCard label="24시간 내" value={stats.last24h} color="text-blue-400" />
<MetricCard label="실패 (24시간)" value={stats.failed24h} color="text-red-400" />
<MetricCard label="액션 종류 (7일)" value={stats.byAction.length} color="text-purple-400" />
</div>
)}
{/* 액션별 분포 */}
{stats && stats.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">
{stats.byAction.map((a) => (
<Badge key={a.action} className="bg-surface-overlay text-muted-foreground border border-border text-[10px] px-2 py-1">
{a.action} <span className="text-cyan-400 font-bold ml-1">{a.count}</span>
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
@ -92,3 +117,14 @@ export function AuditLogs() {
</div>
);
}
function MetricCard({ 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>
);
}

파일 보기

@ -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<LoginHistory[]>([]);
const [stats, setStats] = useState<LoginStats | null>(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() {
</button>
</div>
{/* 통계 카드 */}
{stats && (
<div className="grid grid-cols-5 gap-3">
<MetricCard label="전체 시도" value={stats.total} color="text-heading" />
<MetricCard label="성공 (24h)" value={stats.success24h} color="text-green-400" />
<MetricCard label="실패 (24h)" value={stats.failed24h} color="text-orange-400" />
<MetricCard label="잠금 (24h)" value={stats.locked24h} color="text-red-400" />
<MetricCard label="성공률 (24h)" value={stats.successRate} color="text-cyan-400" suffix="%" />
</div>
)}
{/* 사용자별 + 일자별 추세 */}
{stats && (stats.byUser.length > 0 || stats.daily7d.length > 0) && (
<div className="grid grid-cols-2 gap-3">
{stats.byUser.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 space-y-1">
{stats.byUser.map((u) => (
<div key={u.user_acnt} className="flex items-center justify-between text-[11px]">
<span className="text-cyan-400 font-mono">{u.user_acnt}</span>
<span className="text-heading font-bold">{u.count}</span>
</div>
))}
</CardContent>
</Card>
)}
{stats.daily7d.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 space-y-1">
{stats.daily7d.map((d) => (
<div key={d.day} className="flex items-center justify-between text-[11px]">
<span className="text-muted-foreground font-mono">{new Date(d.day).toLocaleDateString('ko-KR')}</span>
<div className="flex gap-3">
<span className="text-green-400"> {d.success}</span>
<span className="text-orange-400"> {d.failed}</span>
<span className="text-red-400"> {d.locked}</span>
</div>
</div>
))}
</CardContent>
</Card>
)}
</div>
)}
{error && <div className="text-xs text-red-400">: {error}</div>}
{loading && <div className="flex items-center justify-center py-12 text-muted-foreground"><Loader2 className="w-5 h-5 animate-spin" /></div>}
@ -87,3 +136,14 @@ export function LoginHistoryView() {
</div>
);
}
function MetricCard({ label, value, color, suffix }: { label: string; value: number; color: string; suffix?: 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()}{suffix && <span className="text-sm ml-1">{suffix}</span>}</div>
</CardContent>
</Card>
);
}

파일 보기

@ -98,3 +98,103 @@ export function fetchPermTree() {
export function fetchRoles() {
return apiGet<RoleWithPermissions[]>('/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<string, number>;
byProvider: Record<string, number>;
byRole: Record<string, number>;
}
export function fetchUsers() {
return apiGet<AdminUser[]>('/admin/users');
}
export function fetchUserStats() {
return apiGet<UserStats>('/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<AuditStats>('/admin/stats/audit');
}
export function fetchAccessStats() {
return apiGet<AccessStats>('/admin/stats/access');
}
export function fetchLoginStats() {
return apiGet<LoginStats>('/admin/stats/login');
}