kintex/mobile/app/login.tsx
zio 6c9891c7e2 feat: mobile harness + design v2.1 (84 screens) + PLANNING v3.1 + deliverables + Stitch screens
- Harness: kintex-mobile-dev agent + kintex-mobile-orchestrator skill (WISE mobile ref, Stitch-first design rule, dual app targets B2B/B2C)
- design.md v2.1: full 84-screen inventory (web 51 / admin 10 / public 8 / mobile 15) with Stitch prompts incl. ticketing (SCR-P7/P8, M14/M15)
- PLANNING v3.1: unified account + split signup tracks (2FA required for staff, light signup/guest for visitors), one codebase / two app targets
- Deliverables: dev plan (21s), user/operator/developer guides (17/14/15s), program spec (44s, 65 programs, 8 flowcharts), DA (DB design 14s + table spec xlsx 35 tables/299 cols)
- Benchmark: ticketing-app-benchmark.md (7 apps) -> IMPLEMENTATION_BACKLOG Phase F (14 items)
- Stitch: 23 generated screens saved (mobile 10, admin 6, web core 5, ticket 2)
- mobile/: Expo scaffold (SDK 51, expo-router, secure store JWT)
- frontend: SCR-13~17 QA fixes, icons.tsx, kintexEvents, V10 seed migration
- ci/: KINTEX CI logo assets

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 23:31:45 +09:00

208 lines
7.2 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 { ApiRequestError } from '../lib/api';
import { isOtpRequired, login } from '../lib/auth';
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();
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>
{/* 로그인 카드 */}
<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 },
});