guardia-messenger/app/(auth)/login.tsx

200 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react'
import {
View, Text, TextInput, TouchableOpacity, StyleSheet,
KeyboardAvoidingView, Platform, ActivityIndicator, Alert, ScrollView,
} from 'react-native'
import { useRouter } from 'expo-router'
import * as LocalAuthentication from 'expo-local-authentication'
import * as SecureStore from 'expo-secure-store'
import { useAuth } from '../../hooks/useAuth'
import { COLORS } from '../../constants/Config'
import { recordActivity } from '../../hooks/useSessionExpiry'
export default function LoginScreen() {
const { login } = useAuth()
const router = useRouter()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [bioAvailable, setBioAvailable] = useState(false) // #29 토큰이 있을 때만 노출
/* #29 SecureStore에 토큰이 있고 생체 하드웨어가 등록된 경우에만 버튼 표시 */
useEffect(() => {
;(async () => {
try {
const token = await SecureStore.getItemAsync('grd_token')
const hasHardware = await LocalAuthentication.hasHardwareAsync()
const isEnrolled = await LocalAuthentication.isEnrolledAsync()
setBioAvailable(!!token && hasHardware && isEnrolled)
} catch {
setBioAvailable(false)
}
})()
}, [])
/* #29 생체인증 로그인 */
const biometricLogin = async () => {
const hasHardware = await LocalAuthentication.hasHardwareAsync()
const isEnrolled = await LocalAuthentication.isEnrolledAsync()
if (!hasHardware || !isEnrolled) {
Alert.alert('생체인증 불가', '지문/Face ID가 등록되지 않았습니다.')
return
}
const result = await LocalAuthentication.authenticateAsync({
promptMessage: 'GUARDiA 로그인',
cancelLabel: '취소',
fallbackLabel: '비밀번호 사용',
})
if (result.success) {
const token = await SecureStore.getItemAsync('grd_token')
if (token) {
await recordActivity()
router.replace('/(tabs)')
} else {
Alert.alert('재로그인 필요', '저장된 인증 정보가 없습니다. 비밀번호로 로그인해주세요.')
}
}
}
const handleLogin = async () => {
if (!username.trim() || !password.trim()) {
Alert.alert('입력 오류', '아이디와 비밀번호를 입력해주세요.')
return
}
setLoading(true)
try {
await login(username.trim(), password)
await recordActivity() // #31 로그인 시점을 마지막 활동으로 기록
} catch (e: any) {
const msg = e.response?.data?.detail ?? '로그인에 실패했습니다.'
Alert.alert('로그인 실패', msg)
} finally {
setLoading(false)
}
}
return (
<KeyboardAvoidingView
style={s.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView contentContainerStyle={s.inner} keyboardShouldPersistTaps="handled">
{/* 로고 영역 */}
<View style={s.logoBox}>
<Text style={s.logoIcon}>🛡</Text>
<Text style={s.logoTitle}>GUARDiA</Text>
<Text style={s.logoSub}>AI </Text>
<View style={s.badge}>
<Text style={s.badgeText}>()</Text>
</View>
</View>
{/* 로그인 카드 */}
<View style={s.card}>
<Text style={s.cardTitle}></Text>
<View style={s.field}>
<Text style={s.label}></Text>
<TextInput
style={s.input}
value={username}
onChangeText={setUsername}
placeholder="관리자 아이디"
placeholderTextColor={COLORS.muted}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="next"
/>
</View>
<View style={s.field}>
<Text style={s.label}></Text>
<TextInput
style={s.input}
value={password}
onChangeText={setPassword}
placeholder="비밀번호"
placeholderTextColor={COLORS.muted}
secureTextEntry
returnKeyType="done"
onSubmitEditing={handleLogin}
/>
</View>
<TouchableOpacity
style={[s.btn, loading && s.btnDisabled]}
onPress={handleLogin}
disabled={loading}
>
{loading
? <ActivityIndicator color="#fff" />
: <Text style={s.btnText}></Text>
}
</TouchableOpacity>
{bioAvailable && (
<TouchableOpacity style={s.bioBtn} onPress={biometricLogin}>
<Text style={s.bioIcon}>👆</Text>
<Text style={s.bioText}> </Text>
</TouchableOpacity>
)}
<Text style={s.hint}>GUARDiA ITSM </Text>
</View>
<Text style={s.version}>v1.0.0 · zioinfo.co.kr</Text>
</ScrollView>
</KeyboardAvoidingView>
)
}
/* Variant 스타일 로그인 — 딥네이비 배경 + 흰 카드 */
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#001530' },
inner: { flexGrow: 1, justifyContent: 'center', padding: 28 },
logoBox: { alignItems: 'center', marginBottom: 36 },
logoIcon: { fontSize: 52, marginBottom: 10 },
logoTitle: { fontSize: 34, fontWeight: '900', color: '#fff', letterSpacing: 1.5 },
logoSub: { fontSize: 13, color: 'rgba(0,160,200,.85)', marginTop: 5 },
badge: {
marginTop: 10,
backgroundColor: 'rgba(0,160,200,.15)',
borderWidth: 1, borderColor: 'rgba(0,160,200,.3)',
paddingHorizontal: 14, paddingVertical: 5, borderRadius: 20,
},
badgeText: { color: '#00A0C8', fontSize: 11, fontWeight: '700' },
card: {
backgroundColor: '#fff', borderRadius: 20, padding: 28,
shadowColor: '#001530', shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.25, shadowRadius: 32, elevation: 10,
},
cardTitle: {
fontSize: 18, fontWeight: '800', color: '#003366',
marginBottom: 22, textAlign: 'center', letterSpacing: -0.3,
},
field: { marginBottom: 16 },
label: { fontSize: 11, fontWeight: '700', color: '#00A0C8',
marginBottom: 6, textTransform: 'uppercase', letterSpacing: .6 },
input: {
borderWidth: 1.5, borderColor: '#E2E8F0', borderRadius: 12,
padding: 14, fontSize: 15, color: '#1E293B', backgroundColor: '#F8FAFC',
},
btn: {
backgroundColor: '#00A0C8', borderRadius: 12, padding: 16,
alignItems: 'center', marginTop: 8,
shadowColor: '#00A0C8', shadowOffset: { width: 0, height: 4 },
shadowOpacity: .3, shadowRadius: 10, elevation: 4,
},
btnDisabled: { opacity: .6 },
btnText: { color: '#fff', fontSize: 16, fontWeight: '800', letterSpacing: 0.3 },
bioBtn: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
marginTop: 12, padding: 14, borderRadius: 12,
borderWidth: 1.5, borderColor: '#00A0C8', backgroundColor: 'rgba(0,160,200,.06)',
},
bioIcon: { fontSize: 18 },
bioText: { color: '#00A0C8', fontSize: 15, fontWeight: '700' },
hint: { textAlign: 'center', color: '#64748B', fontSize: 12, marginTop: 16 },
version: { textAlign: 'center', color: 'rgba(0,160,200,.4)', fontSize: 11, marginTop: 24 },
})