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

176 lines
6.1 KiB
TypeScript

/**
* #33 등록 디바이스 관리
* GET /api/auth/devices — 디바이스 목록
* DELETE /api/auth/devices/{id} — 등록 해제
* 현재 기기는 삭제 버튼 비활성화
*/
import { useCallback, useEffect, useState } from 'react'
import {
View, Text, FlatList, TouchableOpacity, StyleSheet,
RefreshControl, Alert, ActivityIndicator, Platform,
} from 'react-native'
import { COLORS } from '../../constants/Config'
import { getDevices, deleteDevice } from '../../services/api'
import LineIcon from '../../components/LineIcon'
interface Device {
id: string | number
name?: string
device_name?: string
os?: string
platform?: string
last_seen?: string
last_active_at?: string
is_current?: boolean
current?: boolean
}
function osIcon(os?: string): Parameters<typeof LineIcon>[0]['name'] {
const v = (os ?? '').toLowerCase()
if (v.includes('ios') || v.includes('iphone') || v.includes('mac')) return 'lock'
if (v.includes('android')) return 'server'
return 'dashboard'
}
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
}
}
export default function DevicesScreen() {
const [devices, setDevices] = useState<Device[]>([])
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 getDevices()
const list: Device[] = Array.isArray(r.data) ? r.data : r.data?.items ?? []
setDevices(list)
} catch {
setDevices([])
} finally {
setLoading(false)
setRefresh(false)
}
}, [])
useEffect(() => { load() }, [load])
const handleRemove = (dev: Device) => {
const name = dev.name ?? dev.device_name ?? '이 기기'
Alert.alert('디바이스 등록 해제', `"${name}"의 등록을 해제하시겠습니까?\n해당 기기는 다시 로그인해야 합니다.`, [
{ text: '취소', style: 'cancel' },
{
text: '해제', style: 'destructive',
onPress: async () => {
try {
await deleteDevice(dev.id)
setDevices((prev) => prev.filter((d) => d.id !== dev.id))
} catch (e: any) {
Alert.alert('오류', e.response?.data?.detail ?? '등록 해제에 실패했습니다.')
}
},
},
])
}
if (loading) {
return (
<View style={s.center}>
<ActivityIndicator color={COLORS.accent} size="large" />
</View>
)
}
return (
<FlatList
style={{ flex: 1, backgroundColor: COLORS.bg }}
data={devices}
keyExtractor={(d) => String(d.id)}
refreshControl={<RefreshControl refreshing={refresh} onRefresh={() => load(true)} tintColor={COLORS.accent} />}
ListHeaderComponent={
<View style={s.header}>
<Text style={s.headerTitle}> </Text>
<Text style={s.headerSub}> {devices.length}</Text>
</View>
}
ListEmptyComponent={
<View style={s.empty}>
<Text style={s.emptyText}> .</Text>
</View>
}
contentContainerStyle={devices.length === 0 ? { flexGrow: 1 } : undefined}
renderItem={({ item }) => {
const isCurrent = item.is_current ?? item.current ?? false
return (
<View style={s.card}>
<View style={s.iconBox}>
<LineIcon name={osIcon(item.os ?? item.platform)} size={20} color={COLORS.accent} />
</View>
<View style={{ flex: 1 }}>
<View style={s.row}>
<Text style={s.name}>{item.name ?? item.device_name ?? '알 수 없는 기기'}</Text>
{isCurrent && (
<View style={s.currentBadge}>
<Text style={s.currentText}> </Text>
</View>
)}
</View>
<Text style={s.meta}>{item.os ?? item.platform ?? Platform.OS}</Text>
<Text style={s.meta}> : {fmt(item.last_seen ?? item.last_active_at)}</Text>
</View>
<TouchableOpacity
style={[s.removeBtn, isCurrent && s.removeDisabled]}
disabled={isCurrent}
onPress={() => handleRemove(item)}
>
<Text style={[s.removeText, isCurrent && s.removeTextDisabled]}>
{isCurrent ? '사용 중' : '해제'}
</Text>
</TouchableOpacity>
</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: 13, color: COLORS.muted, marginTop: 4 },
card: {
flexDirection: 'row', alignItems: 'center', gap: 12,
backgroundColor: '#fff', marginHorizontal: 16, marginTop: 10,
borderRadius: 14, padding: 16,
borderWidth: 1, borderColor: COLORS.border,
},
iconBox: {
width: 42, height: 42, borderRadius: 11,
backgroundColor: 'rgba(0,160,200,.08)', alignItems: 'center', justifyContent: 'center',
},
row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
name: { fontSize: 15, fontWeight: '700', color: COLORS.text },
currentBadge: { backgroundColor: '#dcfce7', paddingHorizontal: 8, paddingVertical: 2, borderRadius: 8 },
currentText: { fontSize: 10, fontWeight: '700', color: '#15803d' },
meta: { fontSize: 12, color: COLORS.muted, marginTop: 2 },
removeBtn: {
paddingHorizontal: 14, paddingVertical: 8, borderRadius: 10,
backgroundColor: '#fee2e2',
},
removeDisabled: { backgroundColor: '#f1f5f9' },
removeText: { fontSize: 13, fontWeight: '700', color: COLORS.danger },
removeTextDisabled: { color: COLORS.muted },
empty: { flex: 1, alignItems: 'center', justifyContent: 'center' },
emptyText: { color: COLORS.muted, fontSize: 14 },
})