From 3a0b599276d6ea8bc9a98b50a9433aba6989ead3 Mon Sep 17 00:00:00 2001 From: Ivan Vershigora Date: Wed, 16 Oct 2024 12:01:45 +0100 Subject: [PATCH] fix: reduce PIN attempts in Send and ChangePIN flows --- .eslintrc.js | 3 + e2e/security.e2e.js | 7 +- ios/Podfile.lock | 2 +- src/components/AuthCheck.tsx | 4 +- src/components/Biometrics.tsx | 4 +- src/components/PinPad.tsx | 169 +++------------------- src/hooks/pin.tsx | 184 ++++++++++++++++++++++++ src/screens/Onboarding/CreateWallet.tsx | 20 +-- src/screens/Settings/PIN/ChangePin.tsx | 139 +++++------------- src/screens/Settings/PIN/ChangePin2.tsx | 17 +-- src/screens/Settings/Security/index.tsx | 9 +- src/screens/Wallets/Send/PinCheck.tsx | 10 +- src/screens/Wallets/Send/SendPinPad.tsx | 132 +++++------------ src/utils/i18n/convert.ts | 19 +-- src/utils/i18n/locales/en/security.json | 6 +- 15 files changed, 332 insertions(+), 393 deletions(-) create mode 100644 src/hooks/pin.tsx diff --git a/.eslintrc.js b/.eslintrc.js index 008304b86..2755f24c2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -100,6 +100,9 @@ module.exports = { additionalHooks: 'useDebouncedEffect', }, ], + + // prettier + 'no-mixed-spaces-and-tabs': 0, }, overrides: [ // Disable type-aware linting for .js files diff --git a/e2e/security.e2e.js b/e2e/security.e2e.js index 9285f2499..56406544c 100644 --- a/e2e/security.e2e.js +++ b/e2e/security.e2e.js @@ -188,11 +188,10 @@ d('Settings Security And Privacy', () => { await element(by.id('UseBiometryInstead')).tap(); await device.matchFace(); await sleep(1000); - await element(by.id('ChangePIN')).tap(); - await element(by.id('N1').withAncestor(by.id('PinPad'))).multiTap(4); - await sleep(1000); + await element(by.id('PINChange')).tap(); + await element(by.id('N3').withAncestor(by.id('ChangePIN'))).multiTap(4); + await expect(element(by.id('AttemptsRemaining'))).toBeVisible(); await element(by.id('N1').withAncestor(by.id('ChangePIN'))).multiTap(4); - await sleep(1000); await element(by.id('N2').withAncestor(by.id('ChangePIN2'))).multiTap(4); await element(by.id('N9').withAncestor(by.id('ChangePIN2'))).multiTap(4); await expect(element(by.id('WrongPIN'))).toBeVisible(); diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 67f10b7be..f21d002a6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2292,6 +2292,6 @@ SPEC CHECKSUMS: Yoga: 2a45d7e59592db061217551fd3bbe2dd993817ae ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 -PODFILE CHECKSUM: b1ff2276b558626bd07bddd66e26b06f3fc76609 +PODFILE CHECKSUM: cb153cb4a39e6c92c8b869eafab65a4bba7b869f COCOAPODS: 1.15.2 diff --git a/src/components/AuthCheck.tsx b/src/components/AuthCheck.tsx index d4ed31df4..e835ccf93 100644 --- a/src/components/AuthCheck.tsx +++ b/src/components/AuthCheck.tsx @@ -43,7 +43,7 @@ const AuthCheck = ({ onSuccess?.()} + onSuccess={onSuccess} onFailure={(): void => setBioEnabled(false)} /> @@ -58,7 +58,7 @@ const AuthCheck = ({ showLogoOnPIN={showLogoOnPIN} allowBiometrics={biometrics && !requirePin} onShowBiotmetrics={(): void => setBioEnabled(true)} - onSuccess={(): void => onSuccess?.()} + onSuccess={onSuccess} /> ); diff --git a/src/components/Biometrics.tsx b/src/components/Biometrics.tsx index 46f8b7e9a..49f57c1f2 100644 --- a/src/components/Biometrics.tsx +++ b/src/components/Biometrics.tsx @@ -41,7 +41,7 @@ const Biometrics = ({ style, children, }: { - onSuccess: () => void; + onSuccess?: () => void; onFailure?: () => void; style?: StyleProp; children?: ReactElement; @@ -101,7 +101,7 @@ const Biometrics = ({ .then(({ success }) => { if (success) { dispatch(updateSettings({ biometrics: true })); - onSuccess(); + onSuccess?.(); } else { vibrate(); onFailure?.(); diff --git a/src/components/PinPad.tsx b/src/components/PinPad.tsx index 02fa9e18c..aa2c9b6b7 100644 --- a/src/components/PinPad.tsx +++ b/src/components/PinPad.tsx @@ -1,31 +1,21 @@ -import React, { - memo, - ReactElement, - useState, - useEffect, - useCallback, -} from 'react'; -import { StyleSheet, View, Pressable } from 'react-native'; -import { FadeIn, FadeOut } from 'react-native-reanimated'; +import React, { ReactElement, memo, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { FadeIn, FadeOut } from 'react-native-reanimated'; -import { BodyS, Subtitle } from '../styles/text'; -import { View as ThemedView, AnimatedView } from '../styles/components'; +import BitkitLogo from '../assets/bitkit-logo.svg'; +import { PIN_ATTEMPTS } from '../constants/app'; +import usePIN from '../hooks/pin'; +import { showBottomSheet } from '../store/utils/ui'; +import { AnimatedView, View as ThemedView } from '../styles/components'; import { FaceIdIcon, TouchIdIcon } from '../styles/icons'; -import SafeAreaInset from './SafeAreaInset'; -import NavigationHeader from './NavigationHeader'; +import { BodyS, Subtitle } from '../styles/text'; +import rnBiometrics from '../utils/biometrics'; import { IsSensorAvailableResult } from './Biometrics'; +import NavigationHeader from './NavigationHeader'; import NumberPad from './NumberPad'; +import SafeAreaInset from './SafeAreaInset'; import Button from './buttons/Button'; -import useColors from '../hooks/colors'; -import { wipeApp } from '../store/utils/settings'; -import { showBottomSheet } from '../store/utils/ui'; -import { vibrate } from '../utils/helpers'; -import rnBiometrics from '../utils/biometrics'; -import { showToast } from '../utils/notifications'; -import { setKeychainValue, getKeychainValue } from '../utils/keychain'; -import BitkitLogo from '../assets/bitkit-logo.svg'; -import { PIN_ATTEMPTS } from '../constants/app'; const PinPad = ({ showLogoOnPIN, @@ -37,122 +27,22 @@ const PinPad = ({ showLogoOnPIN: boolean; allowBiometrics: boolean; showBackNavigation?: boolean; - onSuccess: () => void; + onSuccess?: () => void; onShowBiotmetrics?: () => void; }): ReactElement => { const { t } = useTranslation('security'); - const { brand, brand08 } = useColors(); - const [pin, setPin] = useState(''); - const [isLoading, setIsLoading] = useState(true); - const [attemptsRemaining, setAttemptsRemaining] = useState(0); const [biometryData, setBiometricData] = useState(); - - const handleOnPress = (key: string): void => { - vibrate(); - if (key === 'delete') { - setPin((p) => { - return p.length === 0 ? '' : p.slice(0, -1); - }); - } else { - setPin((p) => { - return p.length === 4 ? p : p + key; - }); - } - }; - - // Reduce the amount of pin attempts remaining. - const reducePinAttemptsRemaining = useCallback(async (): Promise => { - const _attemptsRemaining = attemptsRemaining - 1; - await setKeychainValue({ - key: 'pinAttemptsRemaining', - value: `${_attemptsRemaining}`, - }); - setAttemptsRemaining(_attemptsRemaining); - }, [attemptsRemaining]); - - // init view - useEffect(() => { - (async (): Promise => { - const attemptsRemainingResponse = await getKeychainValue({ - key: 'pinAttemptsRemaining', - }); - - if ( - !attemptsRemainingResponse.error && - Number(attemptsRemainingResponse.data) !== Number(attemptsRemaining) - ) { - let numAttempts = - attemptsRemainingResponse.data !== undefined - ? Number(attemptsRemainingResponse.data) - : 5; - setAttemptsRemaining(numAttempts); - } - })(); - }, [attemptsRemaining]); + const { attemptsRemaining, Dots, handleNumberPress, isLastAttempt, loading } = + usePIN(onSuccess); // on mount useEffect(() => { (async (): Promise => { - setIsLoading(true); - // wait for initial keychain read - await getKeychainValue({ key: 'pinAttemptsRemaining' }); - // get available biometrics const data = await rnBiometrics.isSensorAvailable(); setBiometricData(data); - setIsLoading(false); })(); }, []); - // submit pin - useEffect(() => { - const timer = setTimeout(async () => { - if (pin.length !== 4) { - return; - } - - const realPIN = await getKeychainValue({ key: 'pin' }); - - // error getting pin - if (realPIN.error) { - await reducePinAttemptsRemaining(); - vibrate(); - setPin(''); - return; - } - - // incorrect pin - if (pin !== realPIN?.data) { - if (attemptsRemaining <= 1) { - vibrate({ type: 'default' }); - await wipeApp(); - showToast({ - type: 'warning', - title: t('wiped_title'), - description: t('wiped_message'), - }); - } else { - await reducePinAttemptsRemaining(); - } - - vibrate(); - setPin(''); - return; - } - - // correct pin - await setKeychainValue({ - key: 'pinAttemptsRemaining', - value: PIN_ATTEMPTS, - }); - setPin(''); - onSuccess?.(); - }, 500); - - return (): void => clearTimeout(timer); - }, [pin, attemptsRemaining, onSuccess, reducePinAttemptsRemaining, t]); - - const isLastAttempt = attemptsRemaining === 1; - const biometricsName = biometryData?.biometryType === 'TouchID' ? t('bio_touch_id') @@ -172,7 +62,7 @@ const PinPad = ({ - {!isLoading && ( + {!loading && biometryData !== undefined && ( @@ -217,33 +108,18 @@ const PinPad = ({ ) } - onPress={onShowBiotmetrics} /> )} - {Array(4) - .fill(null) - .map((_, i) => ( - - ))} + )} @@ -302,13 +178,6 @@ const styles = StyleSheet.create({ marginBottom: 16, }, dots: { - flexDirection: 'row', - justifyContent: 'center', - marginBottom: 48, - }, - dot: { - width: 20, - height: 20, borderRadius: 10, marginHorizontal: 12, borderWidth: 1, diff --git a/src/hooks/pin.tsx b/src/hooks/pin.tsx new file mode 100644 index 000000000..92c4a5620 --- /dev/null +++ b/src/hooks/pin.tsx @@ -0,0 +1,184 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; + +import { PIN_ATTEMPTS } from '../constants/app'; +import { wipeApp } from '../store/utils/settings'; +import { vibrate } from '../utils/helpers'; +import { getKeychainValue, setKeychainValue } from '../utils/keychain'; +import { showToast } from '../utils/notifications'; +import useColors from './colors'; + +export type UsePIN = + | { + attemptsRemaining: undefined; + Dots: undefined; + handleNumberPress: undefined; + isLastAttempt: undefined; + loading: true; + pin: undefined; + } + | { + attemptsRemaining: number; + Dots: () => JSX.Element; + handleNumberPress: (key: string) => void; + isLastAttempt: boolean; + loading: false; + pin: string; + }; + +const PIN_LENGTH = 4; + +const usePIN = (onSuccess?: () => void): UsePIN => { + const { brand, brand08 } = useColors(); + const { t } = useTranslation('security'); + const [pin, setPin] = useState(''); + const [loading, setLoading] = useState(true); + const [attemptsRemaining, setAttemptsRemaining] = useState(0); + + const isLastAttempt = attemptsRemaining === 1; + + const handleNumberPress = useCallback((key: string): void => { + vibrate(); + if (key === 'delete') { + setPin((p) => { + return p.length === 0 ? '' : p.slice(0, -1); + }); + } else { + setPin((p) => { + return p.length === 4 ? p : p + key; + }); + } + }, []); + + // Reduce the amount of pin attempts remaining. + const reducePinAttemptsRemaining = useCallback(async (): Promise => { + const _attemptsRemaining = attemptsRemaining - 1; + await setKeychainValue({ + key: 'pinAttemptsRemaining', + value: `${_attemptsRemaining}`, + }); + setAttemptsRemaining(_attemptsRemaining); + }, [attemptsRemaining]); + + // on mount + useEffect(() => { + (async (): Promise => { + setLoading(true); + // wait for initial keychain read + const attemptsRemainingResponse = await getKeychainValue({ + key: 'pinAttemptsRemaining', + }); + setAttemptsRemaining(Number(attemptsRemainingResponse.data)); + // get available biometrics + setLoading(false); + })(); + }, []); + + // submit pin + useEffect(() => { + const timer = setTimeout(async () => { + if (pin.length !== PIN_LENGTH) { + return; + } + + const realPIN = await getKeychainValue({ key: 'pin' }); + + // error getting pin + if (realPIN.error) { + await reducePinAttemptsRemaining(); + vibrate(); + setPin(''); + return; + } + + // incorrect pin + if (pin !== realPIN?.data) { + if (attemptsRemaining <= 1) { + vibrate({ type: 'default' }); + await wipeApp(); + showToast({ + type: 'warning', + title: t('wiped_title'), + description: t('wiped_message'), + }); + } else { + await reducePinAttemptsRemaining(); + } + + vibrate(); + setPin(''); + return; + } + + // correct pin + await setKeychainValue({ + key: 'pinAttemptsRemaining', + value: PIN_ATTEMPTS, + }); + setPin(''); + onSuccess?.(); + }, 500); + + return (): void => clearTimeout(timer); + }, [attemptsRemaining, onSuccess, pin, reducePinAttemptsRemaining, t]); + + const Dots = useCallback( + () => ( + + {Array(PIN_LENGTH) + .fill(null) + .map((_, i) => ( + + ))} + + ), + [pin, brand, brand08], + ); + + // we don't want to show anything while loading + if (loading) { + return { + attemptsRemaining: undefined, + Dots: undefined, + handleNumberPress: undefined, + isLastAttempt: undefined, + loading, + pin: undefined, + }; + } + + return { + attemptsRemaining, + Dots, + handleNumberPress, + isLastAttempt, + loading, + pin, + }; +}; + +const styles = StyleSheet.create({ + dots: { + flexDirection: 'row', + justifyContent: 'center', + }, + dot: { + width: 20, + height: 20, + borderRadius: 10, + marginHorizontal: 12, + borderWidth: 1, + }, +}); + +export default usePIN; diff --git a/src/screens/Onboarding/CreateWallet.tsx b/src/screens/Onboarding/CreateWallet.tsx index 3392c942c..f0ea53888 100644 --- a/src/screens/Onboarding/CreateWallet.tsx +++ b/src/screens/Onboarding/CreateWallet.tsx @@ -29,15 +29,17 @@ import LoadingWalletScreen from './Loading'; const checkImageSrc = require('../../assets/illustrations/check.png'); const crossImageSrc = require('../../assets/illustrations/cross.png'); -// prettier-ignore -export type TCreateWalletParams = { - action: 'create'; - bip39Passphrase?: string; - } | { - action: 'restore'; - mnemonic: string; - bip39Passphrase?: string; - } | undefined; +export type TCreateWalletParams = + | { + action: 'create'; + bip39Passphrase?: string; + } + | { + action: 'restore'; + mnemonic: string; + bip39Passphrase?: string; + } + | undefined; const CreateWallet = ({ navigation, diff --git a/src/screens/Settings/PIN/ChangePin.tsx b/src/screens/Settings/PIN/ChangePin.tsx index a49ee5cde..d7a36d077 100644 --- a/src/screens/Settings/PIN/ChangePin.tsx +++ b/src/screens/Settings/PIN/ChangePin.tsx @@ -1,83 +1,32 @@ -import React, { - memo, - ReactElement, - useCallback, - useEffect, - useState, -} from 'react'; +import React, { memo, ReactElement, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { Pressable, StyleSheet, View } from 'react-native'; import { FadeIn, FadeOut } from 'react-native-reanimated'; -import { useFocusEffect } from '@react-navigation/native'; -import { useTranslation } from 'react-i18next'; -import { View as ThemedView, AnimatedView } from '../../../styles/components'; -import { BodyM, BodyS } from '../../../styles/text'; -import SafeAreaInset from '../../../components/SafeAreaInset'; import NavigationHeader from '../../../components/NavigationHeader'; import NumberPad from '../../../components/NumberPad'; -import useColors from '../../../hooks/colors'; -import { vibrate } from '../../../utils/helpers'; -import { getKeychainValue } from '../../../utils/keychain'; -import { showBottomSheet } from '../../../store/utils/ui'; +import SafeAreaInset from '../../../components/SafeAreaInset'; +import { PIN_ATTEMPTS } from '../../../constants/app'; +import usePIN from '../../../hooks/pin'; import type { SettingsScreenProps } from '../../../navigation/types'; +import { showBottomSheet } from '../../../store/utils/ui'; +import { AnimatedView, View as ThemedView } from '../../../styles/components'; +import { BodyM, BodyS } from '../../../styles/text'; const ChangePin = ({ navigation, }: SettingsScreenProps<'ChangePin'>): ReactElement => { const { t } = useTranslation('security'); - const [pin, setPin] = useState(''); - const [wrongPin, setWrongPin] = useState(false); - const { brand, brand08 } = useColors(); - - const handleOnPress = (key: string): void => { - vibrate(); - if (key === 'delete') { - setPin((p) => { - return p.length === 0 ? '' : p.slice(0, -1); - }); - } else { - setPin((p) => { - return p.length === 4 ? p : p + key; - }); - } - }; - - // reset pin on back - useFocusEffect(useCallback(() => setPin(''), [])); + const nextStep = useCallback(() => { + navigation.pop(); + navigation.navigate('ChangePin2'); + }, [navigation]); + const { attemptsRemaining, Dots, handleNumberPress, isLastAttempt, loading } = + usePIN(nextStep); - // submit pin - useEffect(() => { - const timer = setTimeout(async () => { - if (pin.length !== 4) { - return; - } - - const realPIN = await getKeychainValue({ key: 'pin' }); - - // error getting pin - if (realPIN.error) { - console.log('Error getting PIN: ', realPIN.error); - vibrate(); - setPin(''); - return; - } - - // incorrect pin - if (pin !== realPIN?.data) { - vibrate(); - setWrongPin(true); - setPin(''); - return; - } - - setPin(''); - navigation.navigate('ChangePin2'); - }, 500); - - return (): void => { - clearTimeout(timer); - }; - }, [pin, navigation]); + if (loading) { + return ; + } return ( @@ -85,7 +34,8 @@ const ChangePin = ({ { - navigation.navigate('Wallet'); + navigation.popToTop(); + navigation.pop(); }} /> @@ -94,14 +44,22 @@ const ChangePin = ({ - {wrongPin ? ( + {attemptsRemaining !== Number(PIN_ATTEMPTS) ? ( - { - showBottomSheet('forgotPIN'); - }}> - {t('cp_forgot')} - + {isLastAttempt ? ( + + {t('pin_last_attempt')} + + ) : ( + { + showBottomSheet('forgotPIN'); + }}> + + {t('pin_attempts', { attemptsRemaining })} + + + )} ) : ( @@ -109,26 +67,13 @@ const ChangePin = ({ - {Array(4) - .fill(null) - .map((_, i) => ( - - ))} + ); @@ -150,20 +95,14 @@ const styles = StyleSheet.create({ marginBottom: 16, }, dots: { - flexDirection: 'row', - justifyContent: 'center', marginBottom: 'auto', }, - dot: { - width: 20, - height: 20, - borderRadius: 10, - marginHorizontal: 12, - borderWidth: 1, - }, numberpad: { maxHeight: 350, }, + attemptsRemaining: { + textAlign: 'center', + }, }); export default memo(ChangePin); diff --git a/src/screens/Settings/PIN/ChangePin2.tsx b/src/screens/Settings/PIN/ChangePin2.tsx index 890115111..dd024ee6c 100644 --- a/src/screens/Settings/PIN/ChangePin2.tsx +++ b/src/screens/Settings/PIN/ChangePin2.tsx @@ -1,3 +1,4 @@ +import { useFocusEffect } from '@react-navigation/native'; import React, { memo, ReactElement, @@ -5,20 +6,19 @@ import React, { useEffect, useState, } from 'react'; -import { StyleSheet, View } from 'react-native'; -import { useFocusEffect } from '@react-navigation/native'; import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; +import { FadeIn, FadeOut } from 'react-native-reanimated'; -import { View as ThemedView, AnimatedView } from '../../../styles/components'; -import { BodyM, BodyS } from '../../../styles/text'; -import SafeAreaInset from '../../../components/SafeAreaInset'; import NavigationHeader from '../../../components/NavigationHeader'; import NumberPad from '../../../components/NumberPad'; +import SafeAreaInset from '../../../components/SafeAreaInset'; import useColors from '../../../hooks/colors'; -import { vibrate } from '../../../utils/helpers'; import type { SettingsScreenProps } from '../../../navigation/types'; +import { AnimatedView, View as ThemedView } from '../../../styles/components'; +import { BodyM, BodyS } from '../../../styles/text'; +import { vibrate } from '../../../utils/helpers'; import { editPin } from '../../../utils/settings'; -import { FadeIn, FadeOut } from 'react-native-reanimated'; const ChangePin2 = ({ navigation, @@ -76,7 +76,8 @@ const ChangePin2 = ({ { - navigation.navigate('Wallet'); + navigation.popToTop(); + navigation.pop(); }} /> diff --git a/src/screens/Settings/Security/index.tsx b/src/screens/Settings/Security/index.tsx index 351afc0b5..24f37cc0e 100644 --- a/src/screens/Settings/Security/index.tsx +++ b/src/screens/Settings/Security/index.tsx @@ -127,14 +127,9 @@ const SecuritySettings = ({ title: t('security.pin_change'), type: EItemType.button, hide: !pin, - testID: 'ChangePIN', + testID: 'PINChange', onPress: (): void => { - navigation.navigate('AuthCheck', { - onSuccess: () => { - navigation.pop(); - navigation.navigate('ChangePin'); - }, - }); + navigation.navigate('ChangePin'); }, }, { diff --git a/src/screens/Wallets/Send/PinCheck.tsx b/src/screens/Wallets/Send/PinCheck.tsx index ee7e91c66..0516bf0db 100644 --- a/src/screens/Wallets/Send/PinCheck.tsx +++ b/src/screens/Wallets/Send/PinCheck.tsx @@ -1,12 +1,12 @@ import React, { ReactElement, memo } from 'react'; -import { StyleSheet, View } from 'react-native'; import { useTranslation } from 'react-i18next'; +import { StyleSheet, View } from 'react-native'; import BottomSheetNavigationHeader from '../../../components/BottomSheetNavigationHeader'; import GradientView from '../../../components/GradientView'; -import { BodyM } from '../../../styles/text'; -import PinPad from './SendPinPad'; import type { SendScreenProps } from '../../../navigation/types'; +import { BodyM } from '../../../styles/text'; +import SendPinPad from './SendPinPad'; const PinCheck = ({ route }: SendScreenProps<'PinCheck'>): ReactElement => { const { onSuccess } = route.params; @@ -14,13 +14,13 @@ const PinCheck = ({ route }: SendScreenProps<'PinCheck'>): ReactElement => { return ( - + {t('pin_send')} - onSuccess()} /> + ); diff --git a/src/screens/Wallets/Send/SendPinPad.tsx b/src/screens/Wallets/Send/SendPinPad.tsx index 4e10d30b1..c48ac4326 100644 --- a/src/screens/Wallets/Send/SendPinPad.tsx +++ b/src/screens/Wallets/Send/SendPinPad.tsx @@ -1,107 +1,58 @@ -import React, { memo, ReactElement, useState, useEffect } from 'react'; -import { StyleSheet, View, Pressable } from 'react-native'; -import { FadeIn, FadeOut } from 'react-native-reanimated'; +import React, { ReactElement, memo } from 'react'; import { useTranslation } from 'react-i18next'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { FadeIn, FadeOut } from 'react-native-reanimated'; +import NumberPad from '../../../components/NumberPad'; +import { PIN_ATTEMPTS } from '../../../constants/app'; +import usePIN from '../../../hooks/pin'; +import { showBottomSheet } from '../../../store/utils/ui'; import { AnimatedView } from '../../../styles/components'; import { BodyS } from '../../../styles/text'; -import useColors from '../../../hooks/colors'; -import { vibrate } from '../../../utils/helpers'; -import { getKeychainValue } from '../../../utils/keychain'; -import { showBottomSheet } from '../../../store/utils/ui'; -import NumberPad from '../../../components/NumberPad'; const SendPinPad = ({ onSuccess }: { onSuccess: () => void }): ReactElement => { const { t } = useTranslation('security'); - const [pin, setPin] = useState(''); - const [wrongPin, setWrongPin] = useState(false); - const { brand, brand08 } = useColors(); - - const handleOnPress = (key: string): void => { - vibrate(); - if (key === 'delete') { - setPin((p) => { - return p.length === 0 ? '' : p.slice(0, -1); - }); - } else { - setPin((p) => { - return p.length === 4 ? p : p + key; - }); - } - }; - - // submit pin - useEffect(() => { - const timer = setTimeout(async () => { - if (pin.length !== 4) { - return; - } - - const realPIN = await getKeychainValue({ key: 'pin' }); - - // error getting pin - if (realPIN.error) { - vibrate(); - setPin(''); - return; - } - - // incorrect pin - if (pin !== realPIN?.data) { - vibrate(); - setWrongPin(true); - setPin(''); - return; - } - - setPin(''); - onSuccess(); - }, 500); + const { attemptsRemaining, Dots, handleNumberPress, isLastAttempt, loading } = + usePIN(onSuccess); - return (): void => { - clearTimeout(timer); - }; - }, [pin, onSuccess]); + if (loading) { + return <>; + } return ( - {wrongPin && ( + {attemptsRemaining !== Number(PIN_ATTEMPTS) && ( - { - showBottomSheet('forgotPIN'); - }}> - {t('cp_forgot')} - + {isLastAttempt ? ( + + {t('pin_last_attempt')} + + ) : ( + { + showBottomSheet('forgotPIN'); + }}> + + {t('pin_attempts', { attemptsRemaining })} + + + )} )} - {Array(4) - .fill(null) - .map((_, i) => ( - - ))} + @@ -116,28 +67,23 @@ const styles = StyleSheet.create({ marginTop: 42, flex: 1, }, - forgotPin: { - flexDirection: 'row', - flexWrap: 'wrap', - justifyContent: 'center', - }, dots: { - flexDirection: 'row', - justifyContent: 'center', marginTop: 16, marginBottom: 32, }, - dot: { - width: 20, - height: 20, - borderRadius: 10, - marginHorizontal: 12, - borderWidth: 1, - }, numberpad: { marginTop: 'auto', maxHeight: 350, }, + attempts: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + paddingHorizontal: 16, + }, + attemptsRemaining: { + textAlign: 'center', + }, }); export default memo(SendPinPad); diff --git a/src/utils/i18n/convert.ts b/src/utils/i18n/convert.ts index 8dfe7f0fa..6ea40835d 100644 --- a/src/utils/i18n/convert.ts +++ b/src/utils/i18n/convert.ts @@ -1,13 +1,14 @@ // https://help.transifex.com/en/articles/6220899-structured-json -// prettier-ignore -type SJItem = { - string: string; - context?: string; - developer_comment?: string; - character_limit?: number; -} | { - [key: string]: SJItem; -}; +type SJItem = + | { + string: string; + context?: string; + developer_comment?: string; + character_limit?: number; + } + | { + [key: string]: SJItem; + }; type StructuredJson = { [lang: string]: { diff --git a/src/utils/i18n/locales/en/security.json b/src/utils/i18n/locales/en/security.json index 4acf4f256..a31ffeb54 100644 --- a/src/utils/i18n/locales/en/security.json +++ b/src/utils/i18n/locales/en/security.json @@ -149,6 +149,9 @@ "pin_send": { "string": "Please enter your PIN code to confirm and send out this payment." }, + "pin_send_title": { + "string": "Enter PIN Code" + }, "pin_use_biometrics": { "string": "Use {biometricsName}" }, @@ -278,9 +281,6 @@ "cp_text": { "string": "You can change your PIN code to a new\n4-digit combination. Please enter your current PIN code first." }, - "cp_forgot": { - "string": "Forgot your PIN?" - }, "cp_retype_title": { "string": "Retype New PIN" },