176 lines
6.1 KiB
TypeScript
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 },
|
|
})
|