guardia-messenger/app/(tabs)/security_log.tsx

148 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* #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 },
})