201 lines
5.9 KiB
TypeScript
201 lines
5.9 KiB
TypeScript
/**
|
||
* #30 PIN 코드 잠금 화면
|
||
*
|
||
* - 4자리 PIN 숫자 패드
|
||
* - PIN 저장: sha256(pin) → SecureStore 'grd_pin_hash' (평문 저장 금지)
|
||
* - 검증: sha256(입력) === 저장 해시
|
||
* - 5회 실패 시 onFail() 호출 (세션 종료)
|
||
*
|
||
* 사용:
|
||
* <PinLock mode="set" onSuccess={...} /> // PIN 신규 설정 (2회 확인)
|
||
* <PinLock mode="verify" onSuccess={...} onFail={...} /> // 잠금 해제
|
||
*/
|
||
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<boolean> {
|
||
const h = await SecureStore.getItemAsync(PIN_HASH_KEY)
|
||
return !!h
|
||
}
|
||
|
||
export async function savePin(pin: string): Promise<void> {
|
||
// 평문 저장 절대 금지 — 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<boolean> {
|
||
const stored = await SecureStore.getItemAsync(PIN_HASH_KEY)
|
||
if (!stored) return false
|
||
return sha256(pin) === stored
|
||
}
|
||
|
||
export async function clearPin(): Promise<void> {
|
||
await SecureStore.deleteItemAsync(PIN_HASH_KEY)
|
||
await SecureStore.deleteItemAsync(PIN_ENABLED_KEY)
|
||
}
|
||
|
||
export async function isPinEnabled(): Promise<boolean> {
|
||
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<string | null>(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 (
|
||
<View style={s.container}>
|
||
<Text style={s.logo}>🛡️</Text>
|
||
<Text style={s.title}>{title}</Text>
|
||
<Text style={s.subtitle}>{subtitle}</Text>
|
||
|
||
<View style={s.dots}>
|
||
{Array.from({ length: PIN_LENGTH }).map((_, i) => (
|
||
<View key={i} style={[s.dot, i < pin.length && s.dotFilled]} />
|
||
))}
|
||
</View>
|
||
|
||
{!!error && <Text style={s.error}>{error}</Text>}
|
||
|
||
<View style={s.pad}>
|
||
{keys.map((k, i) =>
|
||
k === '' ? (
|
||
<View key={i} style={s.key} />
|
||
) : k === 'del' ? (
|
||
<TouchableOpacity key={i} style={s.key} onPress={handleDelete}>
|
||
<Text style={s.keyDel}>⌫</Text>
|
||
</TouchableOpacity>
|
||
) : (
|
||
<TouchableOpacity key={i} style={s.key} onPress={() => handleDigit(k)}>
|
||
<Text style={s.keyText}>{k}</Text>
|
||
</TouchableOpacity>
|
||
)
|
||
)}
|
||
</View>
|
||
|
||
{onCancel && (
|
||
<TouchableOpacity onPress={onCancel} style={s.cancel}>
|
||
<Text style={s.cancelText}>취소</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
)
|
||
}
|
||
|
||
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' },
|
||
})
|