kintex/mobile/app/login.tsx
zio 490b723a45 chore(mobile): v0.2.0 source snapshot for reproducibility
- app.json: version 0.2.0 / versionCode 2, drop expo-screen-capture plugin
- add lib/i18n (ko/en/ja/zh), lib/roleTrack, LanguageContext + LanguageSelector
- profile/security hardening across screens (login/register/forgot/profile/tabs)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 00:29:38 +09:00

225 lines
7.9 KiB
TypeScript

/*
* SCR-01 로그인 — 이메일/비번 + 아이디 기억 + 비밀번호 찾기·회원가입 링크 + 2FA(OTP) 대응.
* 브랜드 패널(딥블루 그라디언트 대체 카피) + AI 이미지 고지 캡션.
* 비밀번호는 저장하지 않는다. 아이디(이메일)만 AsyncStorage에 기억.
*/
import { Link, router } from 'expo-router';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
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 { landingPathFor } from '../lib/roleTrack';
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 { t } = useTranslation();
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(t('login.errEmpty'));
return;
}
if (otpRequired && !otp.trim()) {
setError(t('login.errOtpEmpty'));
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);
// 역할 우선순위로 랜딩 결정(웹 roleTrack 패리티) — visitor=관람객 티켓, agency=현장, 그 외 홈.
const landing = landingPathFor({ user: res.user, workspaces: res.workspaces });
router.replace(landing as never);
} catch (e) {
if (isOtpRequired(e)) {
setOtpRequired(true);
setError(t('login.errOtpRequired'));
} else if (e instanceof ApiRequestError && e.code === 'OTP_INVALID') {
setError(t('login.errOtpInvalid'));
} else if (e instanceof ApiRequestError && e.code === 'ACCOUNT_LOCKED') {
setError(t('login.errLocked'));
} else if (e instanceof ApiRequestError) {
setError(e.message);
} else {
setError(t('login.errGeneric'));
}
} 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}>{t('login.brandTitle')}</Text>
<Text style={styles.brandCaption}>{t('login.brandCaption')}</Text>
</View>
{/* 위변조 단말 경고(비차단) — 공공 대민 정책: 경고 후 이용 제한 안내 */}
{integrity.compromised ? (
<Banner tone="warning">{t('login.compromised')}</Banner>
) : null}
{/* 로그인 카드 */}
<View style={styles.card}>
<Text style={styles.cardTitle}>{t('login.title')}</Text>
{error ? (
<Banner tone={otpRequired ? 'warning' : 'error'}>{error}</Banner>
) : null}
<Field
label={t('login.email')}
value={email}
onChangeText={setEmail}
placeholder={t('login.emailPlaceholder')}
keyboardType="email-address"
/>
<Field
label={t('login.password')}
value={password}
onChangeText={setPassword}
placeholder={t('login.passwordPlaceholder')}
secureTextEntry
/>
{otpRequired ? (
<Field
label={t('login.otpLabel')}
value={otp}
onChangeText={setOtp}
placeholder={t('login.otpPlaceholder')}
keyboardType="number-pad"
helperText={t('login.otpHelper')}
/>
) : 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}>{t('login.remember')}</Text>
</Pressable>
<Button
label={otpRequired ? t('login.submitOtp') : t('login.submit')}
onPress={onSubmit}
loading={loading}
/>
<View style={styles.links}>
<Link href="/forgot-password" style={styles.link}>
{t('login.forgot')}
</Link>
<Text style={styles.linkDivider}>·</Text>
<Link href="/register" style={styles.link}>
{t('login.register')}
</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 },
});