/* * SCR-M(내정보/프로필) — WISE 모바일 mypage 이식(구조·컴포넌트·플로우 문자 그대로 차용). * 레퍼런스: guardia-messenger/app/uiws/mypage.tsx. * · 프로필 헤드(아바타+카메라 배지 → 사진 선택→미리보기→적용 시에만 업로드) * · 정보 행(InfoRow) · 계정/보안(2FA 상태) · 생체인식 잠금 토글 · 환경설정 · 로그아웃 * 추가: ① 생체인식 로컬 잠금(진입 게이트) ② 프로필 사진 등록(갤러리/카메라). * 보안: 새 시크릿·토큰·자격증명을 console로 출력하지 않는다. 생체 원본은 OS에만. */ import { Ionicons } from '@expo/vector-icons'; import { router, useFocusEffect } from 'expo-router'; import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Alert, Image, Modal, Pressable, ScrollView, StyleSheet, Switch, Text, View, } from 'react-native'; import { Avatar } from '../components/Avatar'; import { Banner } from '../components/Banner'; import { Card } from '../components/Card'; import { useAuth } from '../context/AuthContext'; import { useSecureScreen } from '../context/SecureScreenContext'; import { ApiRequestError, isDegraded } from '../lib/api'; import { authenticateBiometric, isBiometricEnabled, isBiometricSupported, isSessionUnlocked, markSessionUnlocked, setBiometricEnabled, } from '../lib/biometric'; import { isPickerAvailable, pickFromCamera, pickFromLibrary, type PickedImage, } from '../lib/imagePick'; import { LanguageSelector } from '../components/LanguageSelector'; import { avatarUrl, getMe, getOtpStatus, uploadAvatar } from '../lib/profile'; import { prefGet, prefSet } from '../lib/secureStore'; import type { MePrincipal, OtpStatusDto } from '../lib/types'; import { colors, radius, spacing, touch, type } from '../theme'; // 환경설정(이 기기 로컬 저장 — 서버 동기화 계약 부재, web MyPage와 동일 정책). const PREFS_KEY = 'kintex.prefs'; interface Prefs { notifyDeadline: boolean; notifyApproval: boolean; notifyPayment: boolean; } const DEFAULT_PREFS: Prefs = { notifyDeadline: true, notifyApproval: true, notifyPayment: true }; export default function ProfileScreen() { const { t } = useTranslation(); const { user, signOut } = useAuth(); // 개인정보 화면 — 캡처 차단 + 백그라운드 마스킹(B4/B7). useSecureScreen('profile'); const [me, setMe] = useState(null); const [loading, setLoading] = useState(false); const [loadErr, setLoadErr] = useState(''); const [otp, setOtp] = useState(null); // 프로필 사진 — 선택 → 미리보기(적용/취소) → 적용 시에만 업로드(WISE 불변식). const [photoUrl, setPhotoUrl] = useState(null); const [photoBusy, setPhotoBusy] = useState(false); const [previewImg, setPreviewImg] = useState(null); const [previewErr, setPreviewErr] = useState(''); const [photoPending, setPhotoPending] = useState(false); // 서버 미반영(degrade) 로컬 프리뷰 상태 // 생체인식 const [bioSupported, setBioSupported] = useState(false); const [bioEnabled, setBioEnabled] = useState(false); const [locked, setLocked] = useState(false); // 잠금 게이트 통과 전 콘텐츠 차단 // 환경설정 const [prefs, setPrefs] = useState(DEFAULT_PREFS); const displayName = me?.displayName ?? user?.displayName ?? t('common.user'); const loadProfile = useCallback(async () => { setLoading(true); setLoadErr(''); try { const p = await getMe(); setMe(p); setPhotoUrl(avatarUrl(p.userId)); setPhotoPending(false); } catch (e) { // /me 실패 시 최소한 로그인 세션 정보로 화면 유지(전면 오류 회피). setLoadErr(e instanceof ApiRequestError ? e.message : t('profile.loadErr')); } finally { setLoading(false); } try { setOtp(await getOtpStatus()); } catch { setOtp(null); // 상태 조회 실패는 무시(섹션은 안내로 degrade) } }, [t]); // 환경설정 로드 useEffect(() => { prefGet(PREFS_KEY) .then((raw) => { if (raw) setPrefs({ ...DEFAULT_PREFS, ...(JSON.parse(raw) as Partial) }); }) .catch(() => setPrefs(DEFAULT_PREFS)); }, []); // 생체 지원/토글 상태 useEffect(() => { isBiometricSupported().then(setBioSupported).catch(() => setBioSupported(false)); isBiometricEnabled().then(setBioEnabled).catch(() => setBioEnabled(false)); }, []); // 진입 게이트 — 생체 잠금 활성 + 세션 미해제 시 인증 요구. const runLockGate = useCallback(async () => { const enabled = await isBiometricEnabled(); if (!enabled || isSessionUnlocked()) { setLocked(false); return; } setLocked(true); const ok = await authenticateBiometric(); if (ok) { markSessionUnlocked(); setLocked(false); } }, []); useFocusEffect( useCallback(() => { runLockGate(); loadProfile(); }, [runLockGate, loadProfile]), ); const savePrefs = async (next: Prefs) => { setPrefs(next); try { await prefSet(PREFS_KEY, JSON.stringify(next)); } catch { /* graceful */ } }; const toggleBiometric = async (next: boolean) => { if (next) { // 활성화 전 1회 본인 확인. const ok = await authenticateBiometric(); if (!ok) { Alert.alert(t('profile.biometric'), t('profile.bioFailed')); return; } markSessionUnlocked(); // 방금 인증했으므로 이 세션은 통과 처리(즉시 재프롬프트 방지). } await setBiometricEnabled(next); setBioEnabled(next); }; // ── 사진 선택(미리보기용) — 업로드 호출 없음 ── const pickForPreview = async (from: 'library' | 'camera') => { if (photoBusy) return; if (!isPickerAvailable()) { Alert.alert(t('profile.photoTitle'), t('profile.photoPickerUnavailable')); return; } const img = from === 'library' ? await pickFromLibrary() : await pickFromCamera(); if (!img) { // 권한 거부/취소/미설치: 모달이 열려있으면 유지, 아니면 무동작 + 권한 안내. return; } setPreviewImg(img); setPreviewErr(''); }; // ── 적용 — 이 시점에만 1회 업로드. 미구현/네트워크면 로컬 프리뷰로 degrade ── const applyPhoto = async () => { if (!previewImg || photoBusy) return; setPhotoBusy(true); setPreviewErr(''); try { const res = await uploadAvatar(previewImg); const base = res.photoUrl || (me ? avatarUrl(me.userId) : null); setPhotoUrl(base ? `${base}${base.includes('?') ? '&' : '?'}_t=${Date.now()}` : previewImg.uri); setPhotoPending(false); setPreviewImg(null); } catch (e) { if (isDegraded(e)) { // 백엔드 아바타 업로드 미구현 — 로컬 프리뷰 유지 + 대기 안내(파괴적 실패 아님). setPhotoUrl(previewImg.uri); setPhotoPending(true); setPreviewImg(null); } else { setPreviewErr(e instanceof ApiRequestError ? e.message : t('profile.uploadErr')); } } finally { setPhotoBusy(false); } }; const cancelPreview = () => { if (photoBusy) return; setPreviewImg(null); setPreviewErr(''); }; const onChangePhoto = () => { if (photoBusy) return; Alert.alert(t('profile.photoTitle'), t('profile.photoSelect'), [ { text: t('profile.photoLibrary'), onPress: () => pickForPreview('library') }, { text: t('profile.photoCamera'), onPress: () => pickForPreview('camera') }, { text: t('common.cancel'), style: 'cancel' }, ]); }; const onSignOut = () => { Alert.alert(t('profile.signOut'), t('profile.signOutConfirm'), [ { text: t('common.cancel'), style: 'cancel' }, { text: t('profile.signOut'), style: 'destructive', onPress: async () => { await signOut(); router.replace('/login'); }, }, ]); }; // 생체 잠금 게이트 — 미통과 시 콘텐츠 차단 화면. if (locked) { return ( {t('profile.lockTitle')} {t('profile.lockHint')} {t('common.retry')} ); } const roles = me?.eventRoles ? Object.values(me.eventRoles) : []; return ( {/* ── 프로필 ── */} {t('profile.sectionInfo')} {loading ? ( ) : ( {photoBusy ? ( ) : ( )} {displayName} {me?.userId ?? user?.userId ?? '-'} {me?.roleCode ? ` · ${me.roleCode}` : ''} {photoPending ? ( {t('profile.photoPending')} ) : null} {loadErr ? {loadErr} : null} )} {/* ── 참여 행사 역할 ── */} {roles.length > 0 ? ( <> {t('profile.sectionEventRoles')} {Object.entries(me!.eventRoles).map(([eventId, r]) => ( {eventId} {t(`roles.${r}` as const, { defaultValue: r })} ))} ) : null} {/* ── 계정·보안(2차 인증) ── */} {t('profile.sectionSecurity')} {t('profile.otpTitle')} {otp?.otpEnabled ? otp.verifyMethod === 'OTP' ? t('profile.otpOnAuth') : t('profile.otpOnMethod', { method: otp.verifyMethod || 'OTP' }) : t('profile.otpOff')} {otp?.otpEnabled ? t('profile.on') : t('profile.off')} {/* ── 생체인식 잠금 ── */} {t('profile.sectionBiometric')} {bioSupported ? ( {t('profile.bioTitle')} {t('profile.bioDesc')} ) : ( {t('profile.bioUnsupported')} )} {/* ── 언어 설정 ── */} {t('lang.title')} {/* ── 환경설정(알림) ── */} {t('profile.sectionNotify')} {( [ ['notifyDeadline', t('profile.notifyDeadline')], ['notifyApproval', t('profile.notifyApproval')], ['notifyPayment', t('profile.notifyPayment')], ] as const ).map(([key, label]) => ( {label} savePrefs({ ...prefs, [key]: v })} trackColor={{ false: colors.neutral200, true: colors.primary600 }} thumbColor={colors.white} /> ))} {t('profile.notifyNote')} {/* ── 로그아웃 ── */} {t('profile.signOut')} {t('profile.version')} {/* ── 프로필 사진 미리보기 모달(선택→미리보기→적용 확정) ── */} {}}> {t('profile.photoChange')} {previewImg ? ( ) : null} {photoBusy ? ( ) : null} {t('profile.photoCaption')} pickForPreview('library')} disabled={photoBusy} > {t('profile.photoLibrary')} pickForPreview('camera')} disabled={photoBusy} > {t('profile.photoCamera')} {previewErr ? {previewErr} : null} {photoBusy ? ( {t('profile.photoApplying')} ) : ( {t('profile.photoApply')} )} {t('common.cancel')} ); } function InfoRow({ label, value, testID }: { label: string; value: string | null; testID?: string }) { return ( {label} {value && value.length ? value : '-'} ); } const styles = StyleSheet.create({ wrap: { flex: 1, backgroundColor: colors.neutral050 }, scroll: { padding: spacing.md, paddingBottom: spacing.xl }, section: { color: colors.neutral900, fontSize: type.h3.fontSize, fontWeight: '800', marginTop: spacing.md, marginBottom: spacing.sm, }, profileHead: { flexDirection: 'row', alignItems: 'center', marginBottom: spacing.sm }, photoEdit: { position: 'absolute', right: -2, bottom: -2, width: 22, height: 22, borderRadius: 11, backgroundColor: colors.primary600, alignItems: 'center', justifyContent: 'center', borderWidth: 2, borderColor: colors.white, }, profileName: { color: colors.neutral900, fontSize: type.h2.fontSize, fontWeight: '800' }, profileId: { color: colors.neutral500, fontSize: type.caption.fontSize, marginTop: 3 }, pendingNote: { color: colors.warning, fontSize: type.caption.fontSize, marginBottom: spacing.sm }, infoRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 9, borderTopWidth: 1, borderTopColor: colors.neutral200, }, infoLabel: { color: colors.neutral500, fontSize: type.caption.fontSize, fontWeight: '700' }, infoValue: { color: colors.neutral900, fontSize: type.body.fontSize, fontWeight: '600', flexShrink: 1, textAlign: 'right', marginLeft: 12, }, rolePill: { backgroundColor: colors.primary100, borderRadius: radius.pill, paddingHorizontal: 10, paddingVertical: 4, }, rolePillText: { color: colors.primary700, fontSize: type.caption.fontSize, fontWeight: '700' }, bioRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 4 }, rowTitle: { color: colors.neutral900, fontSize: type.body.fontSize, fontWeight: '700', marginBottom: 4 }, hint: { color: colors.neutral500, fontSize: type.caption.fontSize, lineHeight: 18, marginTop: 4 }, badgeOn: { backgroundColor: '#E7F5EF', borderRadius: radius.pill, paddingHorizontal: 10, paddingVertical: 4 }, badgeOnText: { color: colors.success, fontSize: type.caption.fontSize, fontWeight: '800' }, badgeOff: { backgroundColor: colors.neutral200, borderRadius: radius.pill, paddingHorizontal: 10, paddingVertical: 4 }, badgeOffText: { color: colors.neutral700, fontSize: type.caption.fontSize, fontWeight: '800' }, logoutBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, marginTop: spacing.lg, minHeight: touch.min, borderRadius: radius.sm, borderWidth: 1, borderColor: colors.error, backgroundColor: colors.white, }, logoutText: { color: colors.error, fontSize: type.h3.fontSize, fontWeight: '700' }, version: { textAlign: 'center', color: colors.neutral500, fontSize: type.caption.fontSize, marginTop: spacing.md }, // 생체 잠금 게이트 lockWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.sm, padding: spacing.lg, backgroundColor: colors.neutral050, }, lockTitle: { color: colors.neutral900, fontSize: type.h2.fontSize, fontWeight: '800', marginTop: spacing.sm }, lockHint: { color: colors.neutral500, fontSize: type.body.fontSize, textAlign: 'center' }, lockBtn: { marginTop: spacing.md, minHeight: touch.min, paddingHorizontal: spacing.lg, borderRadius: radius.sm, backgroundColor: colors.primary600, alignItems: 'center', justifyContent: 'center', }, lockBtnText: { color: colors.white, fontSize: type.h3.fontSize, fontWeight: '700' }, // 사진 미리보기 모달 modalBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.55)', justifyContent: 'flex-end' }, modalSheet: { backgroundColor: colors.white, borderTopLeftRadius: radius.md, borderTopRightRadius: radius.md, borderWidth: 1, borderColor: colors.neutral200, padding: spacing.lg, paddingBottom: spacing.xl, }, modalHeaderRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, modalTitle: { color: colors.neutral900, fontSize: type.h3.fontSize, fontWeight: '800' }, previewWrap: { width: 120, height: 120, borderRadius: 60, alignSelf: 'center', marginVertical: spacing.md, overflow: 'hidden', borderWidth: 1, borderColor: colors.neutral200, backgroundColor: colors.primary050, }, previewImg: { width: 120, height: 120, borderRadius: 60 }, previewOverlay: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0,0,0,0.35)', }, previewCaption: { color: colors.neutral500, fontSize: type.caption.fontSize, textAlign: 'center', marginTop: -4 }, modalPickRow: { flexDirection: 'row', gap: 10, marginTop: spacing.md }, pickBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, minHeight: touch.min, borderRadius: radius.sm, borderWidth: 1, borderColor: colors.neutral200, backgroundColor: colors.white, }, pickText: { color: colors.neutral900, fontSize: type.body.fontSize, fontWeight: '700' }, applyBtn: { marginTop: spacing.md, minHeight: touch.min, borderRadius: radius.sm, backgroundColor: colors.primary600, alignItems: 'center', justifyContent: 'center', }, applyBusyRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, applyText: { color: colors.white, fontSize: type.h3.fontSize, fontWeight: '800' }, cancelBtn: { marginTop: spacing.sm, minHeight: touch.min, borderRadius: radius.sm, borderWidth: 1, borderColor: colors.neutral200, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.white, }, cancelText: { color: colors.neutral700, fontSize: type.h3.fontSize, fontWeight: '700' }, err: { color: colors.error, fontSize: type.caption.fontSize, marginTop: spacing.sm }, disabled: { opacity: 0.6 }, });