200 lines
7.3 KiB
TypeScript
200 lines
7.3 KiB
TypeScript
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 },
|
||
})
|