/**
* #30 PIN 코드 잠금 화면
*
* - 4자리 PIN 숫자 패드
* - PIN 저장: sha256(pin) → SecureStore 'grd_pin_hash' (평문 저장 금지)
* - 검증: sha256(입력) === 저장 해시
* - 5회 실패 시 onFail() 호출 (세션 종료)
*
* 사용:
* // PIN 신규 설정 (2회 확인)
* // 잠금 해제
*/
import { useState } from 'react'
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'
import * as SecureStore from 'expo-secure-store'
import { sha256 } from '../services/sha256'
import { COLORS } from '../constants/Config'
const PIN_HASH_KEY = 'grd_pin_hash'
const PIN_LENGTH = 4
const MAX_ATTEMPTS = 5
export const PIN_ENABLED_KEY = 'grd_pin_enabled'
export async function isPinSet(): Promise {
const h = await SecureStore.getItemAsync(PIN_HASH_KEY)
return !!h
}
export async function savePin(pin: string): Promise {
// 평문 저장 절대 금지 — SHA-256 해시만 저장
const hash = sha256(pin)
await SecureStore.setItemAsync(PIN_HASH_KEY, hash)
await SecureStore.setItemAsync(PIN_ENABLED_KEY, '1')
}
export async function verifyPin(pin: string): Promise {
const stored = await SecureStore.getItemAsync(PIN_HASH_KEY)
if (!stored) return false
return sha256(pin) === stored
}
export async function clearPin(): Promise {
await SecureStore.deleteItemAsync(PIN_HASH_KEY)
await SecureStore.deleteItemAsync(PIN_ENABLED_KEY)
}
export async function isPinEnabled(): Promise {
const v = await SecureStore.getItemAsync(PIN_ENABLED_KEY)
return v === '1' && (await isPinSet())
}
type Mode = 'set' | 'verify'
interface Props {
mode: Mode
onSuccess: () => void
onFail?: () => void
onCancel?: () => void
}
export default function PinLock({ mode, onSuccess, onFail, onCancel }: Props) {
const [pin, setPin] = useState('')
const [firstPin, setFirstPin] = useState(null) // set 모드 확인용
const [attempts, setAttempts] = useState(0)
const [error, setError] = useState('')
const title =
mode === 'set'
? firstPin == null
? 'PIN 설정'
: 'PIN 재입력'
: 'PIN 입력'
const subtitle =
mode === 'set'
? firstPin == null
? '4자리 PIN을 설정하세요'
: '확인을 위해 다시 입력하세요'
: '잠금을 해제하려면 PIN을 입력하세요'
const handleDigit = async (d: string) => {
if (pin.length >= PIN_LENGTH) return
setError('')
const next = pin + d
setPin(next)
if (next.length === PIN_LENGTH) {
await submit(next)
}
}
const handleDelete = () => {
setError('')
setPin((p) => p.slice(0, -1))
}
const submit = async (entered: string) => {
if (mode === 'set') {
if (firstPin == null) {
setFirstPin(entered)
setPin('')
return
}
if (firstPin === entered) {
await savePin(entered)
onSuccess()
} else {
setError('PIN이 일치하지 않습니다. 다시 설정하세요.')
setFirstPin(null)
setPin('')
}
return
}
// verify
const ok = await verifyPin(entered)
if (ok) {
setAttempts(0)
onSuccess()
} else {
const a = attempts + 1
setAttempts(a)
setPin('')
if (a >= MAX_ATTEMPTS) {
setError('5회 실패 — 보안을 위해 로그아웃됩니다.')
onFail?.()
} else {
setError(`PIN이 올바르지 않습니다. (${a}/${MAX_ATTEMPTS})`)
}
}
}
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '', '0', 'del']
return (
🛡️
{title}
{subtitle}
{Array.from({ length: PIN_LENGTH }).map((_, i) => (
))}
{!!error && {error}}
{keys.map((k, i) =>
k === '' ? (
) : k === 'del' ? (
⌫
) : (
handleDigit(k)}>
{k}
)
)}
{onCancel && (
취소
)}
)
}
const s = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.gnbBg,
alignItems: 'center',
justifyContent: 'center',
padding: 28,
},
logo: { fontSize: 44, marginBottom: 12 },
title: { fontSize: 22, fontWeight: '800', color: '#fff', marginBottom: 6 },
subtitle: { fontSize: 13, color: 'rgba(255,255,255,.6)', marginBottom: 28 },
dots: { flexDirection: 'row', gap: 18, marginBottom: 18 },
dot: {
width: 16, height: 16, borderRadius: 8,
borderWidth: 2, borderColor: COLORS.accent, backgroundColor: 'transparent',
},
dotFilled: { backgroundColor: COLORS.accent },
error: { color: '#fca5a5', fontSize: 13, marginBottom: 12, textAlign: 'center' },
pad: { flexDirection: 'row', flexWrap: 'wrap', width: 264, justifyContent: 'space-between' },
key: {
width: 78, height: 78, borderRadius: 39,
alignItems: 'center', justifyContent: 'center', marginVertical: 6,
},
keyText: { fontSize: 28, color: '#fff', fontWeight: '600' },
keyDel: { fontSize: 26, color: 'rgba(255,255,255,.7)' },
cancel: { marginTop: 24 },
cancelText: { color: COLORS.accent, fontSize: 15, fontWeight: '600' },
})