/* * 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 { 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 { avatarUrl, getMe, getOtpStatus, uploadAvatar } from '../lib/profile'; import { prefGet, prefSet } from '../lib/secureStore'; import type { EventRole, MePrincipal, OtpStatusDto } from '../lib/types'; import { colors, radius, spacing, touch, type } from '../theme'; const roleLabel: Record = { ORGANIZER: '주최자', EXHIBITOR: '참가업체', CONTRACTOR: '장치·시공업체', HALL_MANAGER: '홀매니저', }; // 환경설정(이 기기 로컬 저장 — 서버 동기화 계약 부재, 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 { 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 ?? '사용자'; 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 : '내정보를 불러오지 못했습니다.'); } finally { setLoading(false); } try { setOtp(await getOtpStatus()); } catch { setOtp(null); // 상태 조회 실패는 무시(섹션은 안내로 degrade) } }, []); // 환경설정 로드 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('생체인식', '생체 인증에 실패했습니다. 다시 시도해 주세요.'); return; } markSessionUnlocked(); // 방금 인증했으므로 이 세션은 통과 처리(즉시 재프롬프트 방지). } await setBiometricEnabled(next); setBioEnabled(next); }; // ── 사진 선택(미리보기용) — 업로드 호출 없음 ── const pickForPreview = async (from: 'library' | 'camera') => { if (photoBusy) return; if (!isPickerAvailable()) { Alert.alert('프로필 사진', '이미지 선택 기능을 사용할 수 없는 기기입니다.'); 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 : '사진 업로드에 실패했습니다.'); } } finally { setPhotoBusy(false); } }; const cancelPreview = () => { if (photoBusy) return; setPreviewImg(null); setPreviewErr(''); }; const onChangePhoto = () => { if (photoBusy) return; Alert.alert('프로필 사진', '사진을 선택하세요', [ { text: '갤러리', onPress: () => pickForPreview('library') }, { text: '카메라', onPress: () => pickForPreview('camera') }, { text: '취소', style: 'cancel' }, ]); }; const onSignOut = () => { Alert.alert('로그아웃', '로그아웃하시겠습니까?', [ { text: '취소', style: 'cancel' }, { text: '로그아웃', style: 'destructive', onPress: async () => { await signOut(); router.replace('/login'); }, }, ]); }; // 생체 잠금 게이트 — 미통과 시 콘텐츠 차단 화면. if (locked) { return ( 생체인식 잠금 지문 또는 Face로 잠금을 해제하세요. 다시 시도 ); } const roles = me?.eventRoles ? Object.values(me.eventRoles) : []; return ( {/* ── 프로필 ── */} 내 정보 {loading ? ( ) : ( {photoBusy ? ( ) : ( )} {displayName} {me?.userId ?? user?.userId ?? '-'} {me?.roleCode ? ` · ${me.roleCode}` : ''} {photoPending ? ( 선택한 사진은 이 기기에만 표시됩니다 — 서버 반영은 준비 중입니다. ) : null} {loadErr ? {loadErr} : null} )} {/* ── 참여 행사 역할 ── */} {roles.length > 0 ? ( <> 참여 행사 역할 {Object.entries(me!.eventRoles).map(([eventId, r]) => ( {eventId} {roleLabel[r] ?? r} ))} ) : null} {/* ── 계정·보안(2차 인증) ── */} 계정 · 보안 2차 인증(OTP) {otp?.otpEnabled ? `활성화됨 · ${otp.verifyMethod === 'OTP' ? 'Authenticator(OTP)' : otp.verifyMethod || 'OTP'}` : '미설정 — 로그인 시 인증 앱(TOTP) 2단계 인증을 사용합니다.'} {otp?.otpEnabled ? '켜짐' : '꺼짐'} {/* ── 생체인식 잠금 ── */} 생체인식 잠금 {bioSupported ? ( 지문 · Face 잠금 켜면 내정보 진입 시 지문 또는 Face로 재인증합니다. 로그인 2차 인증은 그대로 유지됩니다. ) : ( 이 기기는 생체인식을 지원하지 않거나 지문/Face가 등록되어 있지 않습니다. 기기 설정에서 생체 정보를 등록한 뒤 다시 시도해 주세요. )} {/* ── 환경설정(알림) ── */} 알림 설정 {( [ ['notifyDeadline', '마감 D-데이 알림'], ['notifyApproval', '승인·검수 알림'], ['notifyPayment', '결제·정산 알림'], ] as const ).map(([key, label]) => ( {label} savePrefs({ ...prefs, [key]: v })} trackColor={{ false: colors.neutral200, true: colors.primary600 }} thumbColor={colors.white} /> ))} 알림 설정은 이 기기에 저장됩니다. 서버 동기화는 준비 중입니다. {/* ── 로그아웃 ── */} 로그아웃 KINTEX AI 전시관리 · 내정보 {/* ── 프로필 사진 미리보기 모달(선택→미리보기→적용 확정) ── */} {}}> 프로필 사진 변경 {previewImg ? ( ) : null} {photoBusy ? ( ) : null} 원형으로 표시됩니다. pickForPreview('library')} disabled={photoBusy} > 갤러리 pickForPreview('camera')} disabled={photoBusy} > 카메라 {previewErr ? {previewErr} : null} {photoBusy ? ( 적용 중… ) : ( 적용 )} 취소 ); } 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 }, });