kintex/mobile/app/profile.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

684 lines
24 KiB
TypeScript

/*
* 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<MePrincipal | null>(null);
const [loading, setLoading] = useState(false);
const [loadErr, setLoadErr] = useState('');
const [otp, setOtp] = useState<OtpStatusDto | null>(null);
// 프로필 사진 — 선택 → 미리보기(적용/취소) → 적용 시에만 업로드(WISE 불변식).
const [photoUrl, setPhotoUrl] = useState<string | null>(null);
const [photoBusy, setPhotoBusy] = useState(false);
const [previewImg, setPreviewImg] = useState<PickedImage | null>(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<Prefs>(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<Prefs>) });
})
.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 (
<View style={styles.lockWrap}>
<Ionicons name="finger-print" size={56} color={colors.primary600} />
<Text style={styles.lockTitle}>{t('profile.lockTitle')}</Text>
<Text style={styles.lockHint}>{t('profile.lockHint')}</Text>
<Pressable
style={styles.lockBtn}
onPress={runLockGate}
accessibilityRole="button"
accessibilityLabel={t('common.retry')}
>
<Text style={styles.lockBtnText}>{t('common.retry')}</Text>
</Pressable>
</View>
);
}
const roles = me?.eventRoles ? Object.values(me.eventRoles) : [];
return (
<ScrollView style={styles.wrap} contentContainerStyle={styles.scroll}>
{/* ── 프로필 ── */}
<Text style={styles.section}>{t('profile.sectionInfo')}</Text>
{loading ? (
<ActivityIndicator color={colors.primary600} style={{ marginVertical: spacing.md }} />
) : (
<Card>
<View style={styles.profileHead}>
<Pressable
testID="profile-photo"
onPress={onChangePhoto}
disabled={photoBusy}
accessibilityLabel="프로필 사진 변경"
accessibilityRole="button"
>
<Avatar photoUrl={photoUrl} name={displayName} size={56} testID="profile-avatar" />
<View style={styles.photoEdit}>
{photoBusy ? (
<ActivityIndicator color={colors.white} size="small" />
) : (
<Ionicons name="camera" size={13} color={colors.white} />
)}
</View>
</Pressable>
<View style={{ flex: 1, marginLeft: 12 }}>
<Text style={styles.profileName}>{displayName}</Text>
<Text style={styles.profileId}>
{me?.userId ?? user?.userId ?? '-'}
{me?.roleCode ? ` · ${me.roleCode}` : ''}
</Text>
</View>
</View>
{photoPending ? (
<Text style={styles.pendingNote}>{t('profile.photoPending')}</Text>
) : null}
{loadErr ? <Banner tone="warning">{loadErr}</Banner> : null}
<InfoRow label={t('profile.labelName')} value={displayName} />
<InfoRow label={t('profile.labelUserId')} value={me?.userId ?? user?.userId ?? null} testID="profile-userid" />
<InfoRow label={t('profile.labelGlobalRole')} value={me?.roleCode ?? null} />
<InfoRow label={t('profile.labelTenant')} value={me?.tenantId ?? null} />
<InfoRow
label={t('profile.labelType')}
value={me?.hallManager ?? user?.hallManager ? t('profile.typeInternal') : t('profile.typeGeneral')}
/>
</Card>
)}
{/* ── 참여 행사 역할 ── */}
{roles.length > 0 ? (
<>
<Text style={styles.section}>{t('profile.sectionEventRoles')}</Text>
<Card>
{Object.entries(me!.eventRoles).map(([eventId, r]) => (
<View key={eventId} style={styles.infoRow}>
<Text style={styles.infoLabel} numberOfLines={1}>
{eventId}
</Text>
<View style={styles.rolePill}>
<Text style={styles.rolePillText}>{t(`roles.${r}` as const, { defaultValue: r })}</Text>
</View>
</View>
))}
</Card>
</>
) : null}
{/* ── 계정·보안(2차 인증) ── */}
<Text style={styles.section}>{t('profile.sectionSecurity')}</Text>
<Card>
<View style={styles.bioRow}>
<View style={{ flex: 1, paddingRight: 12 }}>
<Text style={styles.rowTitle}>{t('profile.otpTitle')}</Text>
<Text style={styles.hint}>
{otp?.otpEnabled
? otp.verifyMethod === 'OTP'
? t('profile.otpOnAuth')
: t('profile.otpOnMethod', { method: otp.verifyMethod || 'OTP' })
: t('profile.otpOff')}
</Text>
</View>
<View style={otp?.otpEnabled ? styles.badgeOn : styles.badgeOff}>
<Text style={otp?.otpEnabled ? styles.badgeOnText : styles.badgeOffText}>
{otp?.otpEnabled ? t('profile.on') : t('profile.off')}
</Text>
</View>
</View>
</Card>
{/* ── 생체인식 잠금 ── */}
<Text style={styles.section}>{t('profile.sectionBiometric')}</Text>
<Card>
{bioSupported ? (
<View style={styles.bioRow}>
<View style={{ flex: 1, paddingRight: 12 }}>
<Text style={styles.rowTitle}>{t('profile.bioTitle')}</Text>
<Text style={styles.hint}>{t('profile.bioDesc')}</Text>
</View>
<Switch
testID="profile-biometric-toggle"
value={bioEnabled}
onValueChange={toggleBiometric}
trackColor={{ false: colors.neutral200, true: colors.primary600 }}
thumbColor={colors.white}
/>
</View>
) : (
<Text style={styles.hint}>{t('profile.bioUnsupported')}</Text>
)}
</Card>
{/* ── 언어 설정 ── */}
<Text style={styles.section}>{t('lang.title')}</Text>
<LanguageSelector />
{/* ── 환경설정(알림) ── */}
<Text style={styles.section}>{t('profile.sectionNotify')}</Text>
<Card>
{(
[
['notifyDeadline', t('profile.notifyDeadline')],
['notifyApproval', t('profile.notifyApproval')],
['notifyPayment', t('profile.notifyPayment')],
] as const
).map(([key, label]) => (
<View key={key} style={styles.bioRow}>
<Text style={[styles.rowTitle, { flex: 1 }]}>{label}</Text>
<Switch
testID={`profile-pref-${key}`}
value={prefs[key as keyof Prefs]}
onValueChange={(v) => savePrefs({ ...prefs, [key]: v })}
trackColor={{ false: colors.neutral200, true: colors.primary600 }}
thumbColor={colors.white}
/>
</View>
))}
<Text style={styles.hint}>{t('profile.notifyNote')}</Text>
</Card>
{/* ── 로그아웃 ── */}
<Pressable
style={styles.logoutBtn}
onPress={onSignOut}
accessibilityRole="button"
accessibilityLabel={t('profile.signOut')}
>
<Ionicons name="log-out-outline" size={18} color={colors.error} />
<Text style={styles.logoutText}>{t('profile.signOut')}</Text>
</Pressable>
<Text style={styles.version}>{t('profile.version')}</Text>
{/* ── 프로필 사진 미리보기 모달(선택→미리보기→적용 확정) ── */}
<Modal visible={!!previewImg} transparent animationType="fade" onRequestClose={cancelPreview}>
<Pressable style={styles.modalBackdrop} onPress={cancelPreview}>
<Pressable style={styles.modalSheet} onPress={() => {}}>
<View style={styles.modalHeaderRow}>
<Text style={styles.modalTitle}>{t('profile.photoChange')}</Text>
<Pressable
testID="photo-cancel-x"
onPress={cancelPreview}
disabled={photoBusy}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
accessibilityLabel={t('common.close')}
>
<Ionicons name="close" size={22} color={colors.neutral500} />
</Pressable>
</View>
<View style={styles.previewWrap}>
{previewImg ? (
<Image
testID="photo-preview-image"
source={{ uri: previewImg.uri }}
style={styles.previewImg}
resizeMode="cover"
/>
) : null}
{photoBusy ? (
<View style={styles.previewOverlay}>
<ActivityIndicator color={colors.white} size="small" />
</View>
) : null}
</View>
<Text style={styles.previewCaption}>{t('profile.photoCaption')}</Text>
<View style={styles.modalPickRow}>
<Pressable
testID="photo-pick-library"
style={[styles.pickBtn, photoBusy && styles.disabled]}
onPress={() => pickForPreview('library')}
disabled={photoBusy}
>
<Ionicons name="image-outline" size={16} color={colors.neutral900} />
<Text style={styles.pickText}>{t('profile.photoLibrary')}</Text>
</Pressable>
<Pressable
testID="photo-pick-camera"
style={[styles.pickBtn, photoBusy && styles.disabled]}
onPress={() => pickForPreview('camera')}
disabled={photoBusy}
>
<Ionicons name="camera-outline" size={16} color={colors.neutral900} />
<Text style={styles.pickText}>{t('profile.photoCamera')}</Text>
</Pressable>
</View>
{previewErr ? <Text style={styles.err}>{previewErr}</Text> : null}
<Pressable
testID="photo-apply"
style={[styles.applyBtn, photoBusy && styles.disabled]}
onPress={applyPhoto}
disabled={photoBusy}
>
{photoBusy ? (
<View style={styles.applyBusyRow}>
<ActivityIndicator color={colors.white} size="small" />
<Text style={styles.applyText}>{t('profile.photoApplying')}</Text>
</View>
) : (
<Text style={styles.applyText}>{t('profile.photoApply')}</Text>
)}
</Pressable>
<Pressable
testID="photo-cancel"
style={[styles.cancelBtn, photoBusy && styles.disabled]}
onPress={cancelPreview}
disabled={photoBusy}
>
<Text style={styles.cancelText}>{t('common.cancel')}</Text>
</Pressable>
</Pressable>
</Pressable>
</Modal>
</ScrollView>
);
}
function InfoRow({ label, value, testID }: { label: string; value: string | null; testID?: string }) {
return (
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>{label}</Text>
<Text style={styles.infoValue} testID={testID} numberOfLines={1}>
{value && value.length ? value : '-'}
</Text>
</View>
);
}
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 },
});