148 lines
5.5 KiB
TypeScript
148 lines
5.5 KiB
TypeScript
/**
|
||
* #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<string, { label: string; color: string; bg: string; icon: string }> = {
|
||
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<SecEvent[]>([])
|
||
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 (
|
||
<View style={s.center}>
|
||
<ActivityIndicator color={COLORS.accent} size="large" />
|
||
</View>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<FlatList
|
||
style={{ flex: 1, backgroundColor: COLORS.bg }}
|
||
data={events}
|
||
keyExtractor={(e, i) => String(e.id ?? i)}
|
||
refreshControl={<RefreshControl refreshing={refresh} onRefresh={() => load(true)} tintColor={COLORS.accent} />}
|
||
ListHeaderComponent={
|
||
<View style={s.header}>
|
||
<Text style={s.headerTitle}>보안 이벤트 로그</Text>
|
||
<Text style={s.headerSub}>최근 인증·기기 활동 {events.length}건 · IP는 마스킹 표시</Text>
|
||
</View>
|
||
}
|
||
ListEmptyComponent={
|
||
<View style={s.empty}><Text style={s.emptyText}>보안 이벤트가 없습니다.</Text></View>
|
||
}
|
||
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 (
|
||
<View style={s.row}>
|
||
<View style={[s.iconBox, { backgroundColor: meta.bg }]}>
|
||
<Text style={[s.icon, { color: meta.color }]}>{meta.icon}</Text>
|
||
</View>
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={[s.type, { color: meta.color }]}>{meta.label}</Text>
|
||
{!!item.detail && <Text style={s.detail}>{item.detail}</Text>}
|
||
<Text style={s.date}>{fmt(item.created_at ?? item.timestamp)}</Text>
|
||
</View>
|
||
<Text style={s.ip}>{maskIp(item.ip ?? item.ip_addr)}</Text>
|
||
</View>
|
||
)
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
|
||
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 },
|
||
})
|