/** * #34 보안 이벤트 로그 * GET /api/auth/events * 이벤트: 날짜, 유형(로그인성공/실패/디바이스등록), IP(마스킹 *.*.*.xxx) * FlatList, 날짜순 내림차순 */ import { useCallback, useEffect, useState } from 'react' import { View, Text, FlatList, StyleSheet, RefreshControl, ActivityIndicator, } from 'react-native' import { COLORS } from '../../constants/Config' import { getSecurityEvents } from '../../services/api' interface SecEvent { id?: string | number type?: string event_type?: string ip?: string ip_addr?: string created_at?: string timestamp?: string detail?: string } const TYPE_META: Record = { login_success: { label: '로그인 성공', color: '#15803d', bg: '#dcfce7', icon: '✓' }, login_failed: { label: '로그인 실패', color: '#b91c1c', bg: '#fee2e2', icon: '✕' }, login_fail: { label: '로그인 실패', color: '#b91c1c', bg: '#fee2e2', icon: '✕' }, device_register: { label: '디바이스 등록', color: '#a16207', bg: '#fef9c3', icon: '+' }, device_removed: { label: '디바이스 해제', color: '#c2410c', bg: '#ffedd5', icon: '-' }, logout: { label: '로그아웃', color: '#475569', bg: '#f1f5f9', icon: '↩' }, tenant_switch: { label: '기관 전환', color: '#1d4ed8', bg: '#dbeafe', icon: '⇄' }, } /** IP 마스킹: 앞 3옥텟 가림 → *.*.*.xxx */ function maskIp(ip?: string): string { if (!ip) return '*.*.*.***' const parts = ip.split('.') if (parts.length === 4) return `*.*.*.${parts[3]}` // IPv6 등은 끝 4자리만 return `*.*.*.${ip.slice(-4)}` } function fmt(d?: string): string { if (!d) return '-' try { const dt = new Date(d) if (isNaN(dt.getTime())) return d return dt.toLocaleString('ko-KR', { dateStyle: 'medium', timeStyle: 'short' }) } catch { return d } } function ts(e: SecEvent): number { const d = e.created_at ?? e.timestamp const t = d ? new Date(d).getTime() : 0 return isNaN(t) ? 0 : t } export default function SecurityLogScreen() { const [events, setEvents] = useState([]) const [loading, setLoading] = useState(true) const [refresh, setRefresh] = useState(false) const load = useCallback(async (isRefresh = false) => { isRefresh ? setRefresh(true) : setLoading(true) try { const r = await getSecurityEvents() const list: SecEvent[] = Array.isArray(r.data) ? r.data : r.data?.items ?? [] list.sort((a, b) => ts(b) - ts(a)) // 날짜 내림차순 setEvents(list) } catch { setEvents([]) } finally { setLoading(false) setRefresh(false) } }, []) useEffect(() => { load() }, [load]) if (loading) { return ( ) } return ( String(e.id ?? i)} refreshControl={ load(true)} tintColor={COLORS.accent} />} ListHeaderComponent={ 보안 이벤트 로그 최근 인증·기기 활동 {events.length}건 · IP는 마스킹 표시 } ListEmptyComponent={ 보안 이벤트가 없습니다. } contentContainerStyle={events.length === 0 ? { flexGrow: 1 } : undefined} renderItem={({ item }) => { const key = (item.type ?? item.event_type ?? '').toLowerCase() const meta = TYPE_META[key] ?? { label: item.type ?? item.event_type ?? '이벤트', color: COLORS.muted, bg: '#f1f5f9', icon: '•' } return ( {meta.icon} {meta.label} {!!item.detail && {item.detail}} {fmt(item.created_at ?? item.timestamp)} {maskIp(item.ip ?? item.ip_addr)} ) }} /> ) } const s = StyleSheet.create({ center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: COLORS.bg }, header: { padding: 20, paddingBottom: 8 }, headerTitle: { fontSize: 18, fontWeight: '800', color: COLORS.text }, headerSub: { fontSize: 12, color: COLORS.muted, marginTop: 4 }, row: { flexDirection: 'row', alignItems: 'center', gap: 12, backgroundColor: '#fff', marginHorizontal: 16, marginTop: 10, borderRadius: 14, padding: 14, borderWidth: 1, borderColor: COLORS.border, }, iconBox: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, icon: { fontSize: 16, fontWeight: '800' }, type: { fontSize: 14, fontWeight: '700' }, detail: { fontSize: 12, color: COLORS.text, marginTop: 2 }, date: { fontSize: 12, color: COLORS.muted, marginTop: 2 }, ip: { fontSize: 12, color: COLORS.muted, fontWeight: '600', fontVariant: ['tabular-nums'] }, empty: { flex: 1, alignItems: 'center', justifyContent: 'center' }, emptyText: { color: COLORS.muted, fontSize: 14 }, })