223 lines
8.0 KiB
TypeScript
223 lines
8.0 KiB
TypeScript
/*
|
|
* SCR-01 로그인 — 이메일/비번 + 아이디 기억 + 비밀번호 찾기·회원가입 링크 + 2FA(OTP) 대응.
|
|
* 브랜드 패널(딥블루 그라디언트 대체 카피) + AI 이미지 고지 캡션.
|
|
* 비밀번호는 저장하지 않는다. 아이디(이메일)만 AsyncStorage에 기억.
|
|
*/
|
|
import { Link, router } from 'expo-router';
|
|
import React, { useEffect, useState } from 'react';
|
|
import {
|
|
KeyboardAvoidingView,
|
|
Platform,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { Banner } from '../components/Banner';
|
|
import { Button } from '../components/Button';
|
|
import { Field } from '../components/Field';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { useSecureScreen } from '../context/SecureScreenContext';
|
|
import { ApiRequestError } from '../lib/api';
|
|
import { isOtpRequired, login } from '../lib/auth';
|
|
import { useDeviceIntegrity } from '../lib/integrity';
|
|
import { prefDelete, prefGet, prefSet } from '../lib/secureStore';
|
|
import { colors, radius, spacing, touch, type } from '../theme';
|
|
|
|
const REMEMBER_KEY = 'kintex.rememberedEmail';
|
|
|
|
export default function LoginScreen() {
|
|
const insets = useSafeAreaInsets();
|
|
const { signIn } = useAuth();
|
|
|
|
// 2FA/자격증명 입력 화면 — 캡처 차단 + 백그라운드 마스킹(B4/B7).
|
|
useSecureScreen('login');
|
|
// 위변조(루팅·후킹·디버거) 단말 경고(B1/B9) — 비차단 고지.
|
|
const integrity = useDeviceIntegrity();
|
|
|
|
const [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [otp, setOtp] = useState('');
|
|
const [otpRequired, setOtpRequired] = useState(false);
|
|
const [remember, setRemember] = useState(true);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
const saved = await prefGet(REMEMBER_KEY);
|
|
if (saved) {
|
|
setEmail(saved);
|
|
setRemember(true);
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
async function onSubmit() {
|
|
setError(null);
|
|
if (!email.trim() || !password) {
|
|
setError('이메일과 비밀번호를 입력해 주세요.');
|
|
return;
|
|
}
|
|
if (otpRequired && !otp.trim()) {
|
|
setError('2차 인증 코드를 입력해 주세요.');
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
const res = await login({ email, password, otp: otpRequired ? otp : undefined });
|
|
if (remember) await prefSet(REMEMBER_KEY, email.trim());
|
|
else await prefDelete(REMEMBER_KEY);
|
|
await signIn(res);
|
|
router.replace('/(tabs)');
|
|
} catch (e) {
|
|
if (isOtpRequired(e)) {
|
|
setOtpRequired(true);
|
|
setError('2차 인증 코드가 필요합니다. 인증 앱의 코드를 입력해 주세요.');
|
|
} else if (e instanceof ApiRequestError && e.code === 'OTP_INVALID') {
|
|
setError('인증 코드가 올바르지 않거나 만료되었습니다.');
|
|
} else if (e instanceof ApiRequestError && e.code === 'ACCOUNT_LOCKED') {
|
|
setError('로그인 시도 초과로 계정이 잠겼습니다. 잠시 후 다시 시도해 주세요.');
|
|
} else if (e instanceof ApiRequestError) {
|
|
setError(e.message);
|
|
} else {
|
|
setError('로그인에 실패했습니다.');
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<KeyboardAvoidingView
|
|
style={styles.flex}
|
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
>
|
|
<ScrollView
|
|
contentContainerStyle={[styles.scroll, { paddingTop: insets.top + spacing.lg }]}
|
|
keyboardShouldPersistTaps="handled"
|
|
>
|
|
{/* 브랜드 패널 */}
|
|
<View style={styles.brand}>
|
|
<Text style={styles.brandMark}>KINTEX</Text>
|
|
<Text style={styles.brandTitle}>신청서를 내는 순간,{'\n'}시공 후 사진을 먼저 봅니다</Text>
|
|
<Text style={styles.brandCaption}>AI 생성 예상 이미지 · 실제 시공 결과와 다를 수 있습니다</Text>
|
|
</View>
|
|
|
|
{/* 위변조 단말 경고(비차단) — 공공 대민 정책: 경고 후 이용 제한 안내 */}
|
|
{integrity.compromised ? (
|
|
<Banner tone="warning">
|
|
보안 위험이 감지된 기기입니다(루팅·후킹·디버거). 개인정보·티켓 보호를 위해 정상 기기에서
|
|
이용을 권장합니다.
|
|
</Banner>
|
|
) : null}
|
|
|
|
{/* 로그인 카드 */}
|
|
<View style={styles.card}>
|
|
<Text style={styles.cardTitle}>로그인</Text>
|
|
|
|
{error ? (
|
|
<Banner tone={otpRequired ? 'warning' : 'error'}>{error}</Banner>
|
|
) : null}
|
|
|
|
<Field
|
|
label="이메일"
|
|
value={email}
|
|
onChangeText={setEmail}
|
|
placeholder="name@company.co.kr"
|
|
keyboardType="email-address"
|
|
/>
|
|
<Field
|
|
label="비밀번호"
|
|
value={password}
|
|
onChangeText={setPassword}
|
|
placeholder="비밀번호"
|
|
secureTextEntry
|
|
/>
|
|
|
|
{otpRequired ? (
|
|
<Field
|
|
label="2차 인증 코드"
|
|
value={otp}
|
|
onChangeText={setOtp}
|
|
placeholder="6자리 코드"
|
|
keyboardType="number-pad"
|
|
helperText="인증 앱(TOTP)의 6자리 코드"
|
|
/>
|
|
) : null}
|
|
|
|
{/* 아이디 기억 */}
|
|
<Pressable
|
|
style={styles.rememberRow}
|
|
onPress={() => setRemember((v) => !v)}
|
|
accessibilityRole="checkbox"
|
|
accessibilityState={{ checked: remember }}
|
|
>
|
|
<View style={[styles.checkbox, remember && styles.checkboxOn]}>
|
|
{remember ? <Text style={styles.checkmark}>✓</Text> : null}
|
|
</View>
|
|
<Text style={styles.rememberText}>아이디 기억</Text>
|
|
</Pressable>
|
|
|
|
<Button
|
|
label={otpRequired ? '인증 후 로그인' : '로그인'}
|
|
onPress={onSubmit}
|
|
loading={loading}
|
|
/>
|
|
|
|
<View style={styles.links}>
|
|
<Link href="/forgot-password" style={styles.link}>
|
|
비밀번호 찾기
|
|
</Link>
|
|
<Text style={styles.linkDivider}>·</Text>
|
|
<Link href="/register" style={styles.link}>
|
|
회원가입
|
|
</Link>
|
|
</View>
|
|
</View>
|
|
</ScrollView>
|
|
</KeyboardAvoidingView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
flex: { flex: 1, backgroundColor: colors.neutral050 },
|
|
scroll: { padding: spacing.md, gap: spacing.lg, paddingBottom: spacing.xl },
|
|
brand: {
|
|
backgroundColor: colors.primary700,
|
|
borderRadius: radius.md,
|
|
padding: spacing.lg,
|
|
gap: 8,
|
|
},
|
|
brandMark: { color: colors.white, fontSize: 22, fontWeight: '800', letterSpacing: 1 },
|
|
brandTitle: { color: colors.white, fontSize: type.h2.fontSize, fontWeight: '700', lineHeight: 30 },
|
|
brandCaption: { color: 'rgba(255,255,255,0.75)', fontSize: type.caption.fontSize },
|
|
card: {
|
|
backgroundColor: colors.white,
|
|
borderRadius: radius.md,
|
|
borderWidth: 1,
|
|
borderColor: colors.neutral200,
|
|
padding: spacing.lg,
|
|
gap: spacing.md,
|
|
},
|
|
cardTitle: { fontSize: type.h1.fontSize, fontWeight: '700', color: colors.neutral900 },
|
|
rememberRow: { flexDirection: 'row', alignItems: 'center', gap: 8, minHeight: 40 },
|
|
checkbox: {
|
|
width: 22,
|
|
height: 22,
|
|
borderRadius: radius.sm,
|
|
borderWidth: 2,
|
|
borderColor: colors.neutral200,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
checkboxOn: { backgroundColor: colors.primary600, borderColor: colors.primary600 },
|
|
checkmark: { color: colors.white, fontSize: 14, fontWeight: '700' },
|
|
rememberText: { fontSize: type.body.fontSize, color: colors.neutral700 },
|
|
links: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10, minHeight: touch.min },
|
|
link: { color: colors.primary600, fontSize: type.body.fontSize, fontWeight: '600' },
|
|
linkDivider: { color: colors.neutral500 },
|
|
});
|