diff --git a/apps/ext/src/ui/renderPassKeyPage.tsx b/apps/ext/src/ui/renderPassKeyPage.tsx index b343c8e8f71..390e3286a47 100644 --- a/apps/ext/src/ui/renderPassKeyPage.tsx +++ b/apps/ext/src/ui/renderPassKeyPage.tsx @@ -79,7 +79,7 @@ const usePassKeyOperations = () => { passwordVerifyStatus: { value: EPasswordVerifyStatus.ERROR, message: intl.formatMessage({ - id: ETranslations.auth_error_password_incorrect, + id: ETranslations.auth_error_passcode_incorrect, }), }, })); @@ -90,7 +90,7 @@ const usePassKeyOperations = () => { passwordVerifyStatus: { value: EPasswordVerifyStatus.ERROR, message: intl.formatMessage({ - id: ETranslations.auth_error_password_incorrect, + id: ETranslations.auth_error_passcode_incorrect, }), }, })); diff --git a/apps/mobile/ios/OneKeyWallet.xcodeproj/project.pbxproj b/apps/mobile/ios/OneKeyWallet.xcodeproj/project.pbxproj index 3a6b56ebbcf..de985da7c6f 100644 --- a/apps/mobile/ios/OneKeyWallet.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/OneKeyWallet.xcodeproj/project.pbxproj @@ -424,7 +424,6 @@ "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/RCTI18nStrings.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/Sentry/Sentry.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/TOCropViewController/TOCropViewControllerBundle.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle", ); name = "[CP] Copy Pods Resources"; outputPaths = ( @@ -436,7 +435,6 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCTI18nStrings.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Sentry.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/TOCropViewControllerBundle.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock index 6002da0ad5c..ea5db7ac513 100644 --- a/apps/mobile/ios/Podfile.lock +++ b/apps/mobile/ios/Podfile.lock @@ -1686,7 +1686,7 @@ SPEC CHECKSUMS: Burnt: dde5dd245f124a4594098e3938ba71aae4ec83c3 CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 CocoaLumberjack: 5c7e64cdb877770859bddec4d3d5a0d7c9299df9 - DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5 + DoubleConversion: fea03f2699887d960129cc54bba7e52542b6f953 EXApplication: 137189a3f149b4e8e546884629392c3efc94cbd3 EXBarCodeScanner: d59fd943cebee3f913ebf4ffde0d05d344da8b78 EXConstants: 988aa430ca0f76b43cd46b66e7fae3287f9cc2fc @@ -1713,7 +1713,7 @@ SPEC CHECKSUMS: FBLazyVector: 9f533d5a4c75ca77c8ed774aced1a91a0701781e FBReactNativeSpec: 2db5940ee4b58968274eec0a4f1c736fc4caefa3 fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 - glog: 69ef571f3de08433d766d614c73a9838a06bf7eb + glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2 hermes-engine: 39589e9c297d024e90fe68f6830ff86c4e01498a ImageColors: 88be684570585c07ae2750bff34eb7b78bfc53b4 IQKeyboardManagerSwift: c7955c0bdbf7b2eb29bb7daaa44e3d90f55a9a85 @@ -1817,7 +1817,7 @@ SPEC CHECKSUMS: SPIndicator: 93e0a4fb23de51294ac48e874c0f081a5e293e4f SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654 - Yoga: 6d01ccde54c9f8b92492beb05d468dbfed1d9881 + Yoga: 07db09965bc46c4902e20d3ae6990d95e53af8a8 ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 PODFILE CHECKSUM: 5b7f20a90e19262f325cab10e37056b7f3cd0ffb diff --git a/development/spellCheckerSkipWords.js b/development/spellCheckerSkipWords.js index e916c9bb87c..0a3c3f67065 100644 --- a/development/spellCheckerSkipWords.js +++ b/development/spellCheckerSkipWords.js @@ -776,4 +776,7 @@ module.exports = [ 'cacheable', 'benfen', 'bfc', + 'biometric', + 'biometrics', + 'Biometric', ]; diff --git a/package.json b/package.json index 19a2fdcfac7..10f56293026 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ "react-dom": "18.2.0", "react-mobile-cropper": "^0.10.0", "react-native": "0.73.7", + "react-native-confirmation-code-field": "^7.4.0", "react-native-draggable-flatlist": "4.0.1", "react-native-reanimated": "3.6.1", "react-native-web": "0.18.12", diff --git a/packages/components/src/forms/Form/index.tsx b/packages/components/src/forms/Form/index.tsx index d930dad4dc2..a960a3b27af 100644 --- a/packages/components/src/forms/Form/index.tsx +++ b/packages/components/src/forms/Form/index.tsx @@ -97,16 +97,27 @@ type IFieldProps = Omit, 'render'> & PropsWithChildren<{ testID?: string; label?: string; + display?: + | 'inherit' + | 'none' + | 'inline' + | 'block' + | 'contents' + | 'flex' + | 'inline-flex'; description?: string | ReactNode; horizontal?: boolean; optional?: boolean; labelAddon?: string | ReactElement; + errorMessageAlign?: 'left' | 'center' | 'right'; }>; function Field({ name, label, optional, + display, + errorMessageAlign, description, rules, children, @@ -139,7 +150,12 @@ function Field({ control={control} rules={rules} render={({ field }) => ( -
+
diff --git a/packages/kit-bg/src/services/ServicePassword/index.ts b/packages/kit-bg/src/services/ServicePassword/index.ts index 0ce4c387ebd..ace60dc3b38 100644 --- a/packages/kit-bg/src/services/ServicePassword/index.ts +++ b/packages/kit-bg/src/services/ServicePassword/index.ts @@ -50,7 +50,13 @@ import ServiceBase from '../ServiceBase'; import { checkExtUIOpen } from '../utils'; import { biologyAuthUtils } from './biologyAuthUtils'; -import { EPasswordPromptType } from './types'; +import { + EPasswordMode, + EPasswordPromptType, + PASSCODE_LENGTH, + PASSWORD_MAX_LENGTH, + PASSWORD_MIN_LENGTH, +} from './types'; import type { IPasswordRes } from './types'; @@ -273,20 +279,31 @@ export default class ServicePassword extends ServiceBase { } // validatePassword -------------------------------- - validatePasswordValidRules(password: string): void { + validatePasswordValidRules( + password: string, + passwordMode: EPasswordMode, + ): void { ensureSensitiveTextEncoded(password); const realPassword = decodePassword({ password }); // **** length matched - if (realPassword.length < 8 || realPassword.length > 128) { + if ( + passwordMode === EPasswordMode.PASSWORD && + (realPassword.length < PASSWORD_MIN_LENGTH || + realPassword.length > PASSWORD_MAX_LENGTH) + ) { throw new OneKeyErrors.PasswordStrengthValidationFailed(); } + if (passwordMode === EPasswordMode.PASSCODE) { + if (realPassword.length !== PASSCODE_LENGTH) { + throw new OneKeyErrors.PasswordStrengthValidationFailed(); + } + } // **** other rules .... } validatePasswordSame(password: string, newPassword: string) { ensureSensitiveTextEncoded(password); ensureSensitiveTextEncoded(newPassword); - const realPassword = decodePassword({ password }); const realNewPassword = decodePassword({ password: newPassword }); if (realPassword === realNewPassword) { @@ -296,10 +313,12 @@ export default class ServicePassword extends ServiceBase { async validatePassword({ password, + passwordMode, newPassword, skipDBVerify, }: { password: string; + passwordMode: EPasswordMode; newPassword?: string; skipDBVerify?: boolean; }): Promise { @@ -307,9 +326,10 @@ export default class ServicePassword extends ServiceBase { if (newPassword) { ensureSensitiveTextEncoded(newPassword); } - this.validatePasswordValidRules(password); - if (newPassword) { - this.validatePasswordValidRules(newPassword); + if (!newPassword) { + this.validatePasswordValidRules(password, passwordMode); + } else { + this.validatePasswordValidRules(newPassword, passwordMode); this.validatePasswordSame(password, newPassword); } if (!skipDBVerify) { @@ -336,20 +356,30 @@ export default class ServicePassword extends ServiceBase { return checkPasswordSet; } - async setPasswordSetStatus(isSet: boolean): Promise { - await passwordPersistAtom.set((v) => ({ ...v, isPasswordSet: isSet })); + async setPasswordSetStatus( + isSet: boolean, + passMode?: EPasswordMode, + ): Promise { + await passwordPersistAtom.set((v) => ({ + ...v, + isPasswordSet: isSet, + ...(passMode ? { passwordMode: passMode } : {}), + })); } // password actions -------------- @backgroundMethod() - async setPassword(password: string): Promise { + async setPassword( + password: string, + passwordMode: EPasswordMode, + ): Promise { ensureSensitiveTextEncoded(password); - await this.validatePassword({ password, skipDBVerify: true }); + await this.validatePassword({ password, passwordMode, skipDBVerify: true }); try { await this.unLockApp(); await this.saveBiologyAuthPassword(password); await this.setCachedPassword(password); - await this.setPasswordSetStatus(true); + await this.setPasswordSetStatus(true, passwordMode); await localDb.setPassword({ password }); return password; } catch (e) { @@ -362,16 +392,21 @@ export default class ServicePassword extends ServiceBase { async updatePassword( oldPassword: string, newPassword: string, + passwordMode: EPasswordMode, ): Promise { ensureSensitiveTextEncoded(oldPassword); ensureSensitiveTextEncoded(newPassword); - await this.validatePassword({ password: oldPassword, newPassword }); + await this.validatePassword({ + password: oldPassword, + newPassword, + passwordMode, + }); try { await this.backgroundApi.serviceAddressBook.updateHash(newPassword); await this.saveBiologyAuthPassword(newPassword); await this.setCachedPassword(newPassword); - await this.setPasswordSetStatus(true); + await this.setPasswordSetStatus(true, passwordMode); // update v5 db password await localDb.updatePassword({ oldPassword, newPassword }); // update v4 db password @@ -391,9 +426,11 @@ export default class ServicePassword extends ServiceBase { @backgroundMethod() async verifyPassword({ password, + passwordMode, isBiologyAuth, }: { password: string; + passwordMode: EPasswordMode; isBiologyAuth?: boolean; }): Promise { let verifyingPassword = password; @@ -401,7 +438,7 @@ export default class ServicePassword extends ServiceBase { verifyingPassword = await this.getBiologyAuthPassword(); } ensureSensitiveTextEncoded(verifyingPassword); - await this.validatePassword({ password: verifyingPassword }); + await this.validatePassword({ password: verifyingPassword, passwordMode }); await this.setCachedPassword(verifyingPassword); return verifyingPassword; } diff --git a/packages/kit-bg/src/services/ServicePassword/types.ts b/packages/kit-bg/src/services/ServicePassword/types.ts index 274a712f70d..4f7ba5b4ccc 100644 --- a/packages/kit-bg/src/services/ServicePassword/types.ts +++ b/packages/kit-bg/src/services/ServicePassword/types.ts @@ -6,3 +6,28 @@ export enum EPasswordPromptType { PASSWORD_SETUP = 'setup', PASSWORD_VERIFY = 'verify', } + +export enum EPasswordMode { + PASSCODE = 'passcode', + PASSWORD = 'password', +} + +export const PASSCODE_LENGTH = 6; +export const PASSCODE_PROTECTION_ATTEMPTS = 10; +export const PASSCODE_PROTECTION_ATTEMPTS_MESSAGE_SHOW_MAX = 5; +export const PASSCODE_PROTECTION_ATTEMPTS_PER_MINUTE_MAP: Record< + string, + number +> = { + '5': 2, + '6': 10, + '7': 30, + '8': 60, + '9': 180, +}; + +export const BIOLOGY_AUTH_ATTEMPTS_FACE = 1; +export const BIOLOGY_AUTH_ATTEMPTS_FINGERPRINT = 2; + +export const PASSWORD_MIN_LENGTH = 8; +export const PASSWORD_MAX_LENGTH = 128; diff --git a/packages/kit-bg/src/states/jotai/atoms/password.ts b/packages/kit-bg/src/states/jotai/atoms/password.ts index b4f5216be8e..f9ac2e29651 100644 --- a/packages/kit-bg/src/states/jotai/atoms/password.ts +++ b/packages/kit-bg/src/states/jotai/atoms/password.ts @@ -5,6 +5,7 @@ import { isSupportWebAuth } from '@onekeyhq/shared/src/webAuth'; import { EPasswordVerifyStatus } from '@onekeyhq/shared/types/password'; import { biologyAuthUtils } from '../../../services/ServicePassword/biologyAuthUtils'; +import { EPasswordMode } from '../../../services/ServicePassword/types'; import { EAtomNames } from '../atomNames'; import { globalAtom, globalAtomComputed } from '../utils'; @@ -60,12 +61,20 @@ export type IPasswordPersistAtom = { webAuthCredentialId: string; appLockDuration: number; enableSystemIdleLock: boolean; + passwordMode: EPasswordMode; + enablePasswordErrorProtection: boolean; + passwordErrorAttempts: number; + passwordErrorProtectionTime: number; }; export const passwordAtomInitialValue: IPasswordPersistAtom = { isPasswordSet: false, webAuthCredentialId: '', appLockDuration: 240, enableSystemIdleLock: true, + passwordMode: EPasswordMode.PASSWORD, + enablePasswordErrorProtection: false, + passwordErrorAttempts: 0, + passwordErrorProtectionTime: 0, }; export const { target: passwordPersistAtom, use: usePasswordPersistAtom } = globalAtom({ @@ -74,6 +83,15 @@ export const { target: passwordPersistAtom, use: usePasswordPersistAtom } = initialValue: passwordAtomInitialValue, }); +export const { target: passwordModeAtom, use: usePasswordModeAtom } = + globalAtomComputed((get) => { + const { passwordMode, isPasswordSet } = get(passwordPersistAtom.atom()); + if (platformEnv.isNative && !isPasswordSet) { + return EPasswordMode.PASSCODE; + } + return passwordMode; + }); + export const { target: systemIdleLockSupport, use: useSystemIdleLockSupport } = globalAtomComputed>(async (get) => { const platformSupport = platformEnv.isExtension || platformEnv.isDesktop; diff --git a/packages/kit/src/components/Password/components/PassCodeInput.tsx b/packages/kit/src/components/Password/components/PassCodeInput.tsx new file mode 100644 index 00000000000..1dc4ad6be3a --- /dev/null +++ b/packages/kit/src/components/Password/components/PassCodeInput.tsx @@ -0,0 +1,128 @@ +import { useEffect, useRef, useState } from 'react'; + +import { StyleSheet, Text } from 'react-native'; +import { + CodeField, + useClearByFocusCell, +} from 'react-native-confirmation-code-field'; + +import { YStack } from '@onekeyhq/components'; + +import type { TextInput } from 'react-native'; + +export const PIN_CELL_COUNT = 6; + +const PassCodeInput = ({ + onPinCodeChange, + onComplete, + disabledComplete, + pinCodeFocus, + enableAutoFocus, + editable, + // showMask, + testId, + clearCode, +}: { + onPinCodeChange?: (pin: string) => void; + onComplete?: () => void; + disabledComplete?: boolean; + pinCodeFocus?: boolean; + enableAutoFocus?: boolean; + editable?: boolean; + testId?: string; + clearCode?: boolean; + // showMask?: boolean; +}) => { + const [pinValue, setPinValue] = useState(''); + + const pinInputRef = useRef(null); + const [props, getCellOnLayoutHandler] = useClearByFocusCell({ + value: pinValue, + setValue: setPinValue, + }); + // const [enableMask, setEnableMask] = useState(true); + // const toggleMask = () => setEnableMask((f) => !f); + + const cellStyles = StyleSheet.create({ + cell: { + width: 16, + height: 16, + }, + }); + + const renderCell = ({ + index, + symbol, + }: // isFocused, + { + index: number; + symbol: string; + isFocused: boolean; + }) => ( + + + + ); + useEffect(() => { + if (pinCodeFocus) { + pinInputRef.current?.focus(); + } + }, [pinCodeFocus, pinInputRef]); + + useEffect(() => { + if (clearCode) { + setPinValue(''); + } + }, [clearCode]); + + return ( + { + setPinValue(text); + onPinCodeChange?.(text); + if (text.length === PIN_CELL_COUNT && !disabledComplete) { + onComplete?.(); + } + }} + cellCount={PIN_CELL_COUNT} + keyboardType="number-pad" + textContentType="oneTimeCode" + renderCell={renderCell} + {...props} + editable={editable} + /> + + // + // {showMask ? ( + // + // ) : null} + // + ); +}; + +export default PassCodeInput; diff --git a/packages/kit/src/components/Password/components/PassCodeProtectionDialogContent.tsx b/packages/kit/src/components/Password/components/PassCodeProtectionDialogContent.tsx new file mode 100644 index 00000000000..7fa0f4e4846 --- /dev/null +++ b/packages/kit/src/components/Password/components/PassCodeProtectionDialogContent.tsx @@ -0,0 +1,22 @@ +import { useIntl } from 'react-intl'; + +import { SizableText, XStack } from '@onekeyhq/components'; +import { ETranslations } from '@onekeyhq/shared/src/locale'; + +import PassCodeProtectionSwitch from '../container/PassCodeProtectionSwitch'; + +const PassCodeProtectionDialogContent = () => { + const intl = useIntl(); + return ( + + + {intl.formatMessage({ + id: ETranslations.Setting_Reset_app_description, + })} + + + + ); +}; + +export default PassCodeProtectionDialogContent; diff --git a/packages/kit/src/components/Password/components/PasswordSetup.tsx b/packages/kit/src/components/Password/components/PasswordSetup.tsx index 414572923d4..373f87c2183 100644 --- a/packages/kit/src/components/Password/components/PasswordSetup.tsx +++ b/packages/kit/src/components/Password/components/PasswordSetup.tsx @@ -1,19 +1,39 @@ -import { memo, useState } from 'react'; +import { memo, useEffect, useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; -import type { IButtonProps } from '@onekeyhq/components'; -import { Button, Form, Input, Unspaced, useForm } from '@onekeyhq/components'; +import { + Button, + Dialog, + Divider, + Form, + Heading, + Input, + Unspaced, + useForm, +} from '@onekeyhq/components'; +import { EPasswordMode } from '@onekeyhq/kit-bg/src/services/ServicePassword/types'; import { ETranslations } from '@onekeyhq/shared/src/locale'; +import platformEnv from '@onekeyhq/shared/src/platformEnv'; -import { PasswordRegex, getPasswordKeyboardType } from '../utils'; +import { + PassCodeRegex, + PasswordRegex, + getPasswordKeyboardType, +} from '../utils'; + +import PassCodeInput, { PIN_CELL_COUNT } from './PassCodeInput'; export interface IPasswordSetupForm { password: string; confirmPassword: string; + passwordMode: EPasswordMode; + passCode: string; + confirmPassCode: string; } interface IPasswordSetupProps { loading: boolean; + passwordMode: EPasswordMode; onSetupPassword: (data: IPasswordSetupForm) => void; biologyAuthSwitchContainer?: React.ReactNode; confirmBtnText?: string; @@ -21,151 +41,301 @@ interface IPasswordSetupProps { const PasswordSetup = ({ loading, + passwordMode, onSetupPassword, confirmBtnText, biologyAuthSwitchContainer, }: IPasswordSetupProps) => { const intl = useIntl(); + const [currentPasswordMode, setCurrentPasswordMode] = useState(passwordMode); + const [passCodeConfirm, setPassCodeConfirm] = useState(false); + useEffect(() => { + setCurrentPasswordMode(passwordMode); + }, [passwordMode]); const form = useForm({ mode: 'onSubmit', reValidateMode: 'onSubmit', defaultValues: { password: '', + passCode: '', confirmPassword: '', + confirmPassCode: '', + passwordMode: currentPasswordMode, }, }); const [secureEntry, setSecureEntry] = useState(true); const [secureReentry, setSecureReentry] = useState(true); + const passCodeFirstStep = useMemo( + () => currentPasswordMode === EPasswordMode.PASSCODE && !passCodeConfirm, + [currentPasswordMode, passCodeConfirm], + ); + const confirmBtnTextMemo = useMemo(() => { + if (passCodeFirstStep) { + return intl.formatMessage({ id: ETranslations.global_next }); + } + return ( + confirmBtnText ?? + intl.formatMessage({ id: ETranslations.auth_set_passcode }) + ); + }, [confirmBtnText, intl, passCodeFirstStep]); + const onPassCodeNext = () => { + setPassCodeConfirm(true); + }; return ( -
- { - form.clearErrors(); - }, - }} - > - text.replace(PasswordRegex, '')} - secureTextEntry={secureEntry} - addOns={[ - { - iconName: secureEntry ? 'EyeOutline' : 'EyeOffOutline', - onPress: () => { - setSecureEntry(!secureEntry); - }, - testID: `password-eye-${secureEntry ? 'off' : 'on'}`, - }, - ]} - testID="password" - /> - - { - const state = form.getFieldState('password'); - if (!state.error) { - return v !== values.password - ? intl.formatMessage({ - id: ETranslations.auth_error_password_not_match, - }) - : undefined; - } - return undefined; - }, - }, - onChange: () => { - form.clearErrors('confirmPassword'); - }, - }} - > - + {currentPasswordMode === EPasswordMode.PASSCODE && passCodeConfirm ? ( + + + + {intl.formatMessage({ + id: ETranslations.auth_confirm_passcode_form_label, + })} + + + + ) : null} + + {currentPasswordMode === EPasswordMode.PASSWORD ? ( + <> + { + form.clearErrors(); + }, + }} + > + text.replace(PasswordRegex, '')} + secureTextEntry={secureEntry} + addOns={[ + { + iconName: secureEntry ? 'EyeOutline' : 'EyeOffOutline', + onPress: () => { + setSecureEntry(!secureEntry); + }, + testID: `password-eye-${secureEntry ? 'off' : 'on'}`, + }, + ]} + testID="password" + /> + + { + const state = form.getFieldState('password'); + if (!state.error) { + return v !== values.password + ? intl.formatMessage({ + id: ETranslations.auth_error_passcode_not_match, + }) + : undefined; + } + return undefined; + }, + }, + onChange: () => { + form.clearErrors('confirmPassword'); + }, + }} + > + text.replace(PasswordRegex, '')} + secureTextEntry={secureReentry} + addOns={[ + { + iconName: secureReentry ? 'EyeOutline' : 'EyeOffOutline', + onPress: () => { + setSecureReentry(!secureReentry); + }, + testID: `confirm-password-eye-${ + secureReentry ? 'off' : 'on' + }`, + }, + ]} + testID="confirm-password" + /> + + + ) : ( + <> + + v + ? undefined + : intl.formatMessage({ + id: ETranslations.auth_error_passcode_empty, + }), + minLength: (v: string) => + v.length >= PIN_CELL_COUNT + ? undefined + : intl.formatMessage( + { id: ETranslations.auth_error_passwcode_too_short }, + { + length: PIN_CELL_COUNT, + }, + ), + regexCheck: (v: string) => + v.replace(PassCodeRegex, '') === v + ? undefined + : intl.formatMessage({ + id: ETranslations.global_hex_data_error, + }), + }, + onChange: () => { + form.clearErrors(); + }, + }} + > + { + form.setValue('passCode', pin); + form.clearErrors('passCode'); + }} + enableAutoFocus + testId="pass-code" + /> + + { + if (passCodeFirstStep) { + return undefined; + } + const state = form.getFieldState('passCode'); + if (!state.error) { + return v !== values.passCode + ? intl.formatMessage({ + id: ETranslations.auth_error_passcode_not_match, + }) + : undefined; + } + return undefined; + }, + }, + onChange: () => { + form.clearErrors('confirmPassCode'); + }, + }} + > + { + form.setValue('confirmPassCode', pin); + form.clearErrors('confirmPassCode'); + }} + enableAutoFocus={false} + pinCodeFocus={passCodeConfirm} + testId="confirm-pass-code" + /> + + + + )} + {!passCodeFirstStep ? ( + {biologyAuthSwitchContainer} + ) : null} + - + size: 'medium', + } as any + } + variant="primary" + loading={loading} + onPress={form.handleSubmit( + passCodeFirstStep ? onPassCodeNext : onSetupPassword, + )} + testID="set-password" + > + {confirmBtnTextMemo} + + {platformEnv.isNative ? ( + + ) : null} + + ); }; diff --git a/packages/kit/src/components/Password/components/PasswordVerify.tsx b/packages/kit/src/components/Password/components/PasswordVerify.tsx index 80ed4a86acc..fb1315227a9 100644 --- a/packages/kit/src/components/Password/components/PasswordVerify.tsx +++ b/packages/kit/src/components/Password/components/PasswordVerify.tsx @@ -12,7 +12,16 @@ import { AuthenticationType } from 'expo-local-authentication'; import { useIntl } from 'react-intl'; import type { IKeyOfIcons, IPropsWithTestId } from '@onekeyhq/components'; -import { Form, Input, useForm } from '@onekeyhq/components'; +import { + Form, + IconButton, + Input, + SizableText, + XStack, + YStack, + useForm, +} from '@onekeyhq/components'; +import { EPasswordMode } from '@onekeyhq/kit-bg/src/services/ServicePassword/types'; import { usePasswordAtom } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; @@ -21,9 +30,13 @@ import { EPasswordVerifyStatus } from '@onekeyhq/shared/types/password'; import { useHandleAppStateActive } from '../../../hooks/useHandleAppStateActive'; import { getPasswordKeyboardType } from '../utils'; +import PassCodeInput from './PassCodeInput'; + interface IPasswordVerifyProps { authType: AuthenticationType[]; isEnable: boolean; + disableInput?: boolean; + passwordMode: EPasswordMode; onPasswordChange: (e: any) => void; onBiologyAuth: () => void; onInputPasswordAuth: (data: IPasswordVerifyForm) => void; @@ -31,16 +44,23 @@ interface IPasswordVerifyProps { value: EPasswordVerifyStatus; message?: string; }; + alertText?: string; + confirmBtnDisabled?: boolean; } -interface IPasswordVerifyForm { +export interface IPasswordVerifyForm { password: string; + passCode: string; } const PasswordVerify = ({ authType, isEnable, + alertText, + confirmBtnDisabled, + disableInput, status, + passwordMode, onBiologyAuth, onPasswordChange, onInputPasswordAuth, @@ -49,7 +69,7 @@ const PasswordVerify = ({ const form = useForm({ mode: 'onSubmit', reValidateMode: 'onSubmit', - defaultValues: { password: '' }, + defaultValues: { password: '', passCode: '' }, }); const timeOutRef = useRef(null); const isEnableRef = useRef(isEnable); @@ -60,7 +80,9 @@ const PasswordVerify = ({ // enable first false should wait some logic to get final value timeOutRef.current = setTimeout(() => { if (!isEnableRef.current) { - form.setFocus('password'); + form.setFocus( + passwordMode === EPasswordMode.PASSWORD ? 'password' : 'passCode', + ); } }, 500); return () => { @@ -72,28 +94,34 @@ const PasswordVerify = ({ }, []); const [secureEntry, setSecureEntry] = useState(true); const lastTime = useRef(0); - const passwordInput = form.watch('password'); + const passwordInput = form.watch( + passwordMode === EPasswordMode.PASSWORD ? 'password' : 'passCode', + ); const [{ manualLocking }] = usePasswordAtom(); + const biologyAuthIconName = useMemo(() => { + let iconName: IKeyOfIcons = + authType && + (authType.includes(AuthenticationType.FACIAL_RECOGNITION) || + authType.includes(AuthenticationType.IRIS)) + ? 'FaceIdOutline' + : 'TouchId2Outline'; + if (platformEnv.isDesktopWin) { + iconName = 'WindowsHelloSolid'; + } else if (platformEnv.isExtension) { + iconName = 'PassKeySolid'; + } + return iconName; + }, [authType]); const rightActions = useMemo(() => { const actions: IPropsWithTestId<{ iconName?: IKeyOfIcons; onPress?: () => void; loading?: boolean; + disabled?: boolean; }>[] = []; if (isEnable && !passwordInput) { - let iconName: IKeyOfIcons = - authType && - (authType.includes(AuthenticationType.FACIAL_RECOGNITION) || - authType.includes(AuthenticationType.IRIS)) - ? 'FaceIdOutline' - : 'TouchId2Outline'; - if (platformEnv.isDesktopWin) { - iconName = 'WindowsHelloSolid'; - } else if (platformEnv.isExtension) { - iconName = 'PassKeySolid'; - } actions.push({ - iconName, + iconName: biologyAuthIconName, onPress: onBiologyAuth, loading: status.value === EPasswordVerifyStatus.VERIFYING, }); @@ -108,6 +136,7 @@ const PasswordVerify = ({ iconName: 'ArrowRightOutline', onPress: form.handleSubmit(onInputPasswordAuth), loading: status.value === EPasswordVerifyStatus.VERIFYING, + disabled: confirmBtnDisabled, testID: 'verifying-password', }); } @@ -116,22 +145,29 @@ const PasswordVerify = ({ }, [ isEnable, passwordInput, - authType, + biologyAuthIconName, onBiologyAuth, status.value, secureEntry, form, onInputPasswordAuth, + confirmBtnDisabled, ]); - + const [passCodeClear, setPassCodeClear] = useState(false); useEffect(() => { + const fieldName = + passwordMode === EPasswordMode.PASSWORD ? 'password' : 'passCode'; if (status.value === EPasswordVerifyStatus.ERROR) { - form.setError('password', { message: status.message }); - form.setFocus('password'); + form.setError(fieldName, { message: status.message }); + if (passwordMode === EPasswordMode.PASSCODE) { + setPassCodeClear(true); + } else { + form.setFocus(fieldName); + } } else { - form.clearErrors('password'); + form.clearErrors(fieldName); } - }, [form, status]); + }, [form, passwordMode, status, disableInput]); useLayoutEffect(() => { if ( @@ -165,38 +201,105 @@ const PasswordVerify = ({ return (
- - text.replace(PasswordRegex, '')} - onChangeText={(text) => text} - keyboardType={getPasswordKeyboardType(!secureEntry)} - secureTextEntry={secureEntry} - // fix Keyboard Flickering on TextInput with secureTextEntry #39411 - // https://github.com/facebook/react-native/issues/39411 - textContentType="oneTimeCode" - onSubmitEditing={form.handleSubmit(onInputPasswordAuth)} - addOns={rightActions} - testID="password-input" - /> - + {passwordMode === EPasswordMode.PASSWORD ? ( + <> + + text.replace(PasswordRegex, '')} + onChangeText={(text) => text} + keyboardType={getPasswordKeyboardType(!secureEntry)} + secureTextEntry={secureEntry} + // fix Keyboard Flickering on TextInput with secureTextEntry #39411 + // https://github.com/facebook/react-native/issues/39411 + textContentType="oneTimeCode" + onSubmitEditing={form.handleSubmit(onInputPasswordAuth)} + addOns={rightActions} + testID="password-input" + /> + + {alertText ? ( + + + {alertText} + + + ) : null} + + ) : ( + <> + + v + ? undefined + : intl.formatMessage({ + id: ETranslations.auth_error_passcode_empty, + }), + }, + onChange: onPasswordChange, + }} + > + { + form.setValue('passCode', pin); + form.clearErrors('passCode'); + setPassCodeClear(false); + }} + editable={Boolean( + status.value !== EPasswordVerifyStatus.VERIFYING && + !disableInput, + )} + onComplete={form.handleSubmit(onInputPasswordAuth)} + clearCode={passCodeClear} + disabledComplete={confirmBtnDisabled} + enableAutoFocus + testId="pass-code-input" + /> + + {alertText ? ( + + + {alertText} + + + ) : null} + {isEnable ? ( + + + + ) : null} + + )}
); }; diff --git a/packages/kit/src/components/Password/container/PassCodeProtectionSwitch.tsx b/packages/kit/src/components/Password/container/PassCodeProtectionSwitch.tsx new file mode 100644 index 00000000000..a5f87af78d1 --- /dev/null +++ b/packages/kit/src/components/Password/container/PassCodeProtectionSwitch.tsx @@ -0,0 +1,20 @@ +import { Switch } from '@onekeyhq/components'; +import { usePasswordPersistAtom } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; + +const PassCodeProtectionSwitch = () => { + const [{ enablePasswordErrorProtection }, setPasswordPersist] = + usePasswordPersistAtom(); + return ( + { + setPasswordPersist((v) => ({ + ...v, + enablePasswordErrorProtection: value, + })); + }} + /> + ); +}; + +export default PassCodeProtectionSwitch; diff --git a/packages/kit/src/components/Password/container/PasswordSetupContainer.tsx b/packages/kit/src/components/Password/container/PasswordSetupContainer.tsx index 44c49f2e991..4e2220d639f 100644 --- a/packages/kit/src/components/Password/container/PasswordSetupContainer.tsx +++ b/packages/kit/src/components/Password/container/PasswordSetupContainer.tsx @@ -2,11 +2,20 @@ import { Suspense, memo, useCallback, useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; -import { SizableText, Stack, Toast, XStack } from '@onekeyhq/components'; +import { + Dialog, + Icon, + SizableText, + Stack, + Toast, + XStack, +} from '@onekeyhq/components'; import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; +import { EPasswordMode } from '@onekeyhq/kit-bg/src/services/ServicePassword/types'; import { useSettingsPersistAtom } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { usePasswordBiologyAuthInfoAtom, + usePasswordModeAtom, usePasswordWebAuthInfoAtom, } from '@onekeyhq/kit-bg/src/states/jotai/atoms/password'; import { ETranslations } from '@onekeyhq/shared/src/locale'; @@ -15,6 +24,7 @@ import platformEnv from '@onekeyhq/shared/src/platformEnv'; import { useBiometricAuthInfo } from '../../../hooks/useBiometricAuthInfo'; import { UniversalContainerWithSuspense } from '../../BiologyAuthComponent/container/UniversalContainer'; import { useWebAuthActions } from '../../BiologyAuthComponent/hooks/useWebAuthActions'; +import PassCodeProtectionDialogContent from '../components/PassCodeProtectionDialogContent'; import PasswordSetup from '../components/PasswordSetup'; import type { IPasswordSetupForm } from '../components/PasswordSetup'; @@ -69,46 +79,72 @@ const PasswordSetupContainer = ({ onSetupRes }: IPasswordSetupProps) => { const [loading, setLoading] = useState(false); const [{ isSupport }] = usePasswordWebAuthInfoAtom(); const [{ isBiologyAuthSwitchOn }] = useSettingsPersistAtom(); + const [passwordMode] = usePasswordModeAtom(); const { setWebAuthEnable } = useWebAuthActions(); const onSetupPassword = useCallback( async (data: IPasswordSetupForm) => { - if (data.confirmPassword !== data.password) { + const { confirmPassword, confirmPassCode, passwordMode: mode } = data; + const finalPassword = + mode === EPasswordMode.PASSCODE ? confirmPassCode : confirmPassword; + setLoading(true); + try { + if (isBiologyAuthSwitchOn && isSupport) { + const res = await setWebAuthEnable(true); + if (!res) return; + } + const encodePassword = + await backgroundApiProxy.servicePassword.encodeSensitiveText({ + text: finalPassword, + }); + const setUpPasswordRes = + await backgroundApiProxy.servicePassword.setPassword( + encodePassword, + mode, + ); + Toast.success({ + title: intl.formatMessage({ id: ETranslations.auth_passcode_set }), + }); + onSetupRes(setUpPasswordRes); + Dialog.show({ + title: intl.formatMessage({ + id: ETranslations.auth_Passcode_protection, + }), + description: intl.formatMessage({ + id: ETranslations.auth_Passcode_protection_description, + }), + renderIcon: ( + + + + ), + renderContent: , + onConfirmText: intl.formatMessage({ + id: ETranslations.global_ok, + }), + showCancelButton: false, + }); + } catch (e) { + console.log('e.stack', (e as Error)?.stack); + console.error(e); Toast.error({ title: intl.formatMessage({ - id: ETranslations.auth_error_password_not_match, + id: ETranslations.feedback_passcode_set_failed, }), }); - } else { - setLoading(true); - try { - if (isBiologyAuthSwitchOn && isSupport) { - const res = await setWebAuthEnable(true); - if (!res) return; - } - const encodePassword = - await backgroundApiProxy.servicePassword.encodeSensitiveText({ - text: data.password, - }); - - const setUpPasswordRes = - await backgroundApiProxy.servicePassword.setPassword( - encodePassword, - ); - onSetupRes(setUpPasswordRes); - Toast.success({ - title: intl.formatMessage({ id: ETranslations.auth_password_set }), - }); - } catch (e) { - console.log('e.stack', (e as Error)?.stack); - console.error(e); - Toast.error({ - title: intl.formatMessage({ - id: ETranslations.feedback_password_set_failed, - }), - }); - } finally { - setLoading(false); - } + } finally { + setLoading(false); } }, [intl, isBiologyAuthSwitchOn, isSupport, onSetupRes, setWebAuthEnable], @@ -117,6 +153,7 @@ const PasswordSetupContainer = ({ onSetupRes }: IPasswordSetupProps) => { return ( diff --git a/packages/kit/src/components/Password/container/PasswordUpdateContainer.tsx b/packages/kit/src/components/Password/container/PasswordUpdateContainer.tsx index ef87c5d95c5..30116c25ddb 100644 --- a/packages/kit/src/components/Password/container/PasswordUpdateContainer.tsx +++ b/packages/kit/src/components/Password/container/PasswordUpdateContainer.tsx @@ -4,6 +4,8 @@ import { useIntl } from 'react-intl'; import { Toast } from '@onekeyhq/components'; import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; +import { EPasswordMode } from '@onekeyhq/kit-bg/src/services/ServicePassword/types'; +import { usePasswordModeAtom } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import PasswordSetup from '../components/PasswordSetup'; @@ -20,36 +22,33 @@ const PasswordUpdateContainer = ({ }: IPasswordUpdateContainerProps) => { const [loading, setLoading] = useState(false); const intl = useIntl(); + const [passwordMode] = usePasswordModeAtom(); const onUpdatePassword = useCallback( async (data: IPasswordSetupForm) => { + const { confirmPassword, confirmPassCode, passwordMode: mode } = data; + const finalPassword = + mode === EPasswordMode.PASSCODE ? confirmPassCode : confirmPassword; setLoading(true); try { - if (data.confirmPassword !== data.password) { - Toast.error({ - title: intl.formatMessage({ - id: ETranslations.auth_error_password_not_match, - }), - }); - return; - } const encodeNewPassword = await backgroundApiProxy.servicePassword.encodeSensitiveText({ - text: data.password, + text: finalPassword, }); const updatedPassword = await backgroundApiProxy.servicePassword.updatePassword( oldEncodedPassword, encodeNewPassword, + mode, ); onUpdateRes(updatedPassword); Toast.success({ - title: intl.formatMessage({ id: ETranslations.auth_password_set }), + title: intl.formatMessage({ id: ETranslations.auth_passcode_set }), }); } catch (e) { console.error(e); Toast.error({ title: intl.formatMessage({ - id: ETranslations.auth_new_password_same_as_old, + id: ETranslations.auth_new_passcode_same_as_old, }), }); } @@ -60,6 +59,7 @@ const PasswordUpdateContainer = ({ return ( diff --git a/packages/kit/src/components/Password/container/PasswordVerifyContainer.tsx b/packages/kit/src/components/Password/container/PasswordVerifyContainer.tsx index e95ffd92f01..d67b2f87674 100644 --- a/packages/kit/src/components/Password/container/PasswordVerifyContainer.tsx +++ b/packages/kit/src/components/Password/container/PasswordVerifyContainer.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { AuthenticationType } from 'expo-local-authentication'; import { useIntl } from 'react-intl'; @@ -6,21 +6,34 @@ import { useIntl } from 'react-intl'; import { Stack } from '@onekeyhq/components'; import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; import { biologyAuthUtils } from '@onekeyhq/kit-bg/src/services/ServicePassword/biologyAuthUtils'; +import { + BIOLOGY_AUTH_ATTEMPTS_FACE, + BIOLOGY_AUTH_ATTEMPTS_FINGERPRINT, + EPasswordMode, + PASSCODE_PROTECTION_ATTEMPTS, + PASSCODE_PROTECTION_ATTEMPTS_MESSAGE_SHOW_MAX, + PASSCODE_PROTECTION_ATTEMPTS_PER_MINUTE_MAP, +} from '@onekeyhq/kit-bg/src/services/ServicePassword/types'; import { useSettingsPersistAtom } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import { usePasswordAtom, usePasswordBiologyAuthInfoAtom, + usePasswordModeAtom, usePasswordPersistAtom, } from '@onekeyhq/kit-bg/src/states/jotai/atoms/password'; import { dismissKeyboard } from '@onekeyhq/shared/src/keyboard'; import { ETranslations } from '@onekeyhq/shared/src/locale'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; +import resetUtils from '@onekeyhq/shared/src/utils/resetUtils'; import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; import { EPasswordVerifyStatus } from '@onekeyhq/shared/types/password'; +import { useBiometricAuthInfo } from '../../../hooks/useBiometricAuthInfo'; import { useWebAuthActions } from '../../BiologyAuthComponent/hooks/useWebAuthActions'; import PasswordVerify from '../components/PasswordVerify'; +import usePasswordProtection from '../hooks/usePasswordProtection'; +import type { IPasswordVerifyForm } from '../components/PasswordVerify'; import type { LayoutChangeEvent } from 'react-native'; interface IPasswordVerifyProps { @@ -29,10 +42,6 @@ interface IPasswordVerifyProps { name?: 'lock'; } -interface IPasswordVerifyForm { - password: string; -} - const PasswordVerifyContainer = ({ onVerifyRes, onLayout, @@ -45,11 +54,23 @@ const PasswordVerifyContainer = ({ const [{ isBiologyAuthSwitchOn }] = useSettingsPersistAtom(); const [hasCachedPassword, setHasCachedPassword] = useState(false); const [hasSecurePassword, setHasSecurePassword] = useState(false); - + const [passwordMode] = usePasswordModeAtom(); + const { title } = useBiometricAuthInfo(); + const biologyAuthAttempts = useMemo( + () => + authType.includes(AuthenticationType.FACIAL_RECOGNITION) + ? BIOLOGY_AUTH_ATTEMPTS_FACE + : BIOLOGY_AUTH_ATTEMPTS_FINGERPRINT, + [authType], + ); + const isLock = useMemo(() => name === 'lock', [name]); const isExtLockAndNoCachePassword = Boolean( - platformEnv.isExtension && name === 'lock' && !hasCachedPassword, + platformEnv.isExtension && isLock && !hasCachedPassword, ); - + const [{ passwordVerifyStatus }, setPasswordAtom] = usePasswordAtom(); + const resetPasswordStatus = useCallback(() => { + void backgroundApiProxy.servicePassword.resetPasswordStatus(); + }, []); useEffect(() => { if (webAuthCredentialId && isBiologyAuthSwitchOn) { void (async () => { @@ -73,40 +94,58 @@ const PasswordVerifyContainer = ({ } }, [isEnable, isBiologyAuthSwitchOn]); + useEffect(() => { + setPasswordAtom((v) => ({ + ...v, + passwordVerifyStatus: { value: EPasswordVerifyStatus.DEFAULT }, + })); + return () => { + resetPasswordStatus(); + }; + }, [setPasswordAtom, resetPasswordStatus]); + + const { + verifyPeriodBiologyEnable, + verifyPeriodBiologyAuthAttempts, + unlockPeriodPasswordArray, + passwordErrorAttempts, + setVerifyPeriodBiologyEnable, + setVerifyPeriodBiologyAuthAttempts, + setPasswordErrorProtectionTimeMinutesSurplus, + setUnlockPeriodPasswordArray, + alertText, + setPasswordPersist, + isProtectionTime, + enablePasswordErrorProtection, + } = usePasswordProtection(isLock); + const isBiologyAuthEnable = useMemo( // both webAuth or biologyAuth are enabled () => { if (isExtLockAndNoCachePassword) { - return isBiologyAuthSwitchOn && !!webAuthCredentialId; + return ( + isBiologyAuthSwitchOn && + !!webAuthCredentialId && + verifyPeriodBiologyEnable + ); } return ( isBiologyAuthSwitchOn && + verifyPeriodBiologyEnable && ((isEnable && hasSecurePassword) || (!!webAuthCredentialId && !!hasCachedPassword)) ); }, [ - hasCachedPassword, - hasSecurePassword, + isExtLockAndNoCachePassword, + isBiologyAuthSwitchOn, + verifyPeriodBiologyEnable, isEnable, + hasSecurePassword, webAuthCredentialId, - isBiologyAuthSwitchOn, - isExtLockAndNoCachePassword, + hasCachedPassword, ], ); - const [{ passwordVerifyStatus }, setPasswordAtom] = usePasswordAtom(); - const resetPasswordStatus = useCallback(() => { - void backgroundApiProxy.servicePassword.resetPasswordStatus(); - }, []); - useEffect(() => { - setPasswordAtom((v) => ({ - ...v, - passwordVerifyStatus: { value: EPasswordVerifyStatus.DEFAULT }, - })); - return () => { - resetPasswordStatus(); - }; - }, [setPasswordAtom, resetPasswordStatus]); const onBiologyAuthenticateExtLockAndNoCachePassword = useCallback(async () => { @@ -129,33 +168,46 @@ const PasswordVerifyContainer = ({ })); onVerifyRes(''); } else { - setPasswordAtom((v) => ({ - ...v, - passwordVerifyStatus: { - value: EPasswordVerifyStatus.ERROR, - message: intl.formatMessage({ - id: ETranslations.auth_error_password_incorrect, - }), - }, - })); + throw new Error('biology auth verify error'); } } catch { + if (verifyPeriodBiologyAuthAttempts >= biologyAuthAttempts) { + setVerifyPeriodBiologyEnable(false); + } else { + setVerifyPeriodBiologyAuthAttempts((v) => v + 1); + } setPasswordAtom((v) => ({ ...v, passwordVerifyStatus: { value: EPasswordVerifyStatus.ERROR, - message: intl.formatMessage({ - id: ETranslations.auth_error_password_incorrect, - }), + message: intl.formatMessage( + { + id: + verifyPeriodBiologyAuthAttempts >= biologyAuthAttempts + ? ETranslations.auth_biometric_failed + : ETranslations.auth_error_passcode_incorrect, + }, + { + biometric: + verifyPeriodBiologyAuthAttempts >= biologyAuthAttempts + ? title + : undefined, + }, + ), }, })); } }, [ + passwordVerifyStatus.value, + setPasswordAtom, checkWebAuth, - passwordVerifyStatus, onVerifyRes, + verifyPeriodBiologyAuthAttempts, + biologyAuthAttempts, + setVerifyPeriodBiologyEnable, + setVerifyPeriodBiologyAuthAttempts, intl, - setPasswordAtom, + title, ]); const onBiologyAuthenticate = useCallback(async () => { @@ -179,6 +231,7 @@ const PasswordVerifyContainer = ({ await backgroundApiProxy.servicePassword.verifyPassword({ password: '', isBiologyAuth: true, + passwordMode, }); } if (biologyAuthRes) { @@ -188,36 +241,49 @@ const PasswordVerifyContainer = ({ })); onVerifyRes(biologyAuthRes); } else { - setPasswordAtom((v) => ({ - ...v, - passwordVerifyStatus: { - value: EPasswordVerifyStatus.ERROR, - message: intl.formatMessage({ - id: ETranslations.auth_error_password_incorrect, - }), - }, - })); throw new Error('biology auth verify error'); } } catch (e) { + if (verifyPeriodBiologyAuthAttempts >= biologyAuthAttempts) { + setVerifyPeriodBiologyEnable(false); + } else { + setVerifyPeriodBiologyAuthAttempts((v) => v + 1); + } setPasswordAtom((v) => ({ ...v, passwordVerifyStatus: { value: EPasswordVerifyStatus.ERROR, - message: intl.formatMessage({ - id: ETranslations.auth_error_password_incorrect, - }), + message: intl.formatMessage( + { + id: + verifyPeriodBiologyAuthAttempts >= biologyAuthAttempts + ? ETranslations.auth_biometric_failed + : ETranslations.auth_error_passcode_incorrect, + }, + { + biometric: + verifyPeriodBiologyAuthAttempts >= biologyAuthAttempts + ? title + : undefined, + }, + ), }, })); } }, [ + biologyAuthAttempts, intl, isBiologyAuthEnable, isEnable, onVerifyRes, + passwordMode, passwordVerifyStatus.value, setPasswordAtom, + setVerifyPeriodBiologyAuthAttempts, + setVerifyPeriodBiologyEnable, + title, verifiedPasswordWebAuth, + verifyPeriodBiologyAuthAttempts, ]); const onInputPasswordAuthenticate = useCallback( @@ -232,14 +298,17 @@ const PasswordVerifyContainer = ({ ...v, passwordVerifyStatus: { value: EPasswordVerifyStatus.VERIFYING }, })); + const finalPassword = + passwordMode === EPasswordMode.PASSWORD ? data.password : data.passCode; try { const encodePassword = await backgroundApiProxy.servicePassword.encodeSensitiveText({ - text: data.password, + text: finalPassword, }); const verifiedPassword = await backgroundApiProxy.servicePassword.verifyPassword({ password: encodePassword, + passwordMode, }); setPasswordAtom((v) => ({ ...v, @@ -250,24 +319,99 @@ const PasswordVerifyContainer = ({ await timerUtils.wait(0); } onVerifyRes(verifiedPassword); + if (isLock && enablePasswordErrorProtection) { + setPasswordPersist((v) => ({ + ...v, + passwordErrorAttempts: 0, + passwordErrorProtectionTime: 0, + })); + } } catch (e) { + let message = intl.formatMessage({ + id: ETranslations.auth_error_password_incorrect, + }); + if (isLock && enablePasswordErrorProtection) { + let nextAttempts = passwordErrorAttempts + 1; + if (!unlockPeriodPasswordArray.includes(finalPassword)) { + setPasswordPersist((v) => ({ + ...v, + passwordErrorAttempts: nextAttempts, + })); + setUnlockPeriodPasswordArray((v) => [...v, finalPassword]); + } else { + nextAttempts = passwordErrorAttempts; + } + if (nextAttempts >= PASSCODE_PROTECTION_ATTEMPTS) { + // reset app + try { + // disable setInterval on ext popup + if (platformEnv.isExtensionUiPopup) { + resetUtils.startResetting(); + } + await backgroundApiProxy.serviceApp.resetApp(); + } catch (error) { + console.error('failed to reset app with error', error); + } finally { + // able setInterval on ext popup + if (platformEnv.isExtensionUiPopup) { + resetUtils.endResetting(); + } + } + } else if ( + nextAttempts >= PASSCODE_PROTECTION_ATTEMPTS_MESSAGE_SHOW_MAX + ) { + const timeMinutes = + PASSCODE_PROTECTION_ATTEMPTS_PER_MINUTE_MAP[ + nextAttempts.toString() + ]; + // message = `${ + // PASSCODE_PROTECTION_ATTEMPTS - nextAttempts + // } more failed attempts will reset the device`; + message = intl.formatMessage( + { + id: ETranslations.auth_passcode_failed_alert, + }, + { + count: PASSCODE_PROTECTION_ATTEMPTS - nextAttempts, + }, + ); + setPasswordPersist((v) => ({ + ...v, + passwordErrorProtectionTime: Date.now() + timeMinutes * 60 * 1000, // 2s for animation + })); + setPasswordErrorProtectionTimeMinutesSurplus(timeMinutes); + } + } setPasswordAtom((v) => ({ ...v, passwordVerifyStatus: { value: EPasswordVerifyStatus.ERROR, - message: intl.formatMessage({ - id: ETranslations.auth_error_password_incorrect, - }), + message, }, })); } }, - [intl, onVerifyRes, passwordVerifyStatus.value, setPasswordAtom], + [ + enablePasswordErrorProtection, + intl, + isLock, + onVerifyRes, + passwordErrorAttempts, + passwordMode, + passwordVerifyStatus.value, + setPasswordAtom, + setPasswordErrorProtectionTimeMinutesSurplus, + setPasswordPersist, + setUnlockPeriodPasswordArray, + unlockPeriodPasswordArray, + ], ); - return ( { setPasswordAtom((v) => ({ ...v, diff --git a/packages/kit/src/components/Password/container/PasswordVerifyPromptMount.tsx b/packages/kit/src/components/Password/container/PasswordVerifyPromptMount.tsx index 4d1a43c52d4..5f0f0028aea 100644 --- a/packages/kit/src/components/Password/container/PasswordVerifyPromptMount.tsx +++ b/packages/kit/src/components/Password/container/PasswordVerifyPromptMount.tsx @@ -27,7 +27,7 @@ const PasswordVerifyPromptMount = () => { const showPasswordSetupPrompt = useCallback( (id: number) => { dialogRef.current = Dialog.show({ - title: intl.formatMessage({ id: ETranslations.global_set_password }), + title: intl.formatMessage({ id: ETranslations.global_set_passcode }), onClose() { onClose(id); }, @@ -55,7 +55,7 @@ const PasswordVerifyPromptMount = () => { dialogRef.current = Dialog.show({ ...dialogProps, title: intl.formatMessage({ - id: ETranslations.enter_password, + id: ETranslations.enter_passcode, }), onClose() { onClose(id); diff --git a/packages/kit/src/components/Password/hooks/usePasswordProtection.ts b/packages/kit/src/components/Password/hooks/usePasswordProtection.ts new file mode 100644 index 00000000000..00f4bd9bc31 --- /dev/null +++ b/packages/kit/src/components/Password/hooks/usePasswordProtection.ts @@ -0,0 +1,113 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useIntl } from 'react-intl'; + +import { PASSCODE_PROTECTION_ATTEMPTS_MESSAGE_SHOW_MAX } from '@onekeyhq/kit-bg/src/services/ServicePassword/types'; +import { usePasswordPersistAtom } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; +import { ETranslations } from '@onekeyhq/shared/src/locale'; + +const usePasswordProtection = (isLock: boolean) => { + const [unlockPeriodPasswordArray, setUnlockPeriodPasswordArray] = useState< + string[] + >([]); + const intl = useIntl(); + const [ + passwordErrorProtectionTimeMinutesSurplus, + setPasswordErrorProtectionTimeMinutesSurplus, + ] = useState(0); + const [verifyPeriodBiologyAuthAttempts, setVerifyPeriodBiologyAuthAttempts] = + useState(0); + const [verifyPeriodBiologyEnable, setVerifyPeriodBiologyEnable] = + useState(true); + const [ + { + passwordErrorAttempts, + enablePasswordErrorProtection, + passwordErrorProtectionTime, + }, + setPasswordPersist, + ] = usePasswordPersistAtom(); + + const isProtectionTime = useMemo( + () => + isLock && + enablePasswordErrorProtection && + passwordErrorAttempts >= PASSCODE_PROTECTION_ATTEMPTS_MESSAGE_SHOW_MAX && + passwordErrorProtectionTime > Date.now(), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + isLock, + enablePasswordErrorProtection, + passwordErrorAttempts, + passwordErrorProtectionTime, + passwordErrorProtectionTimeMinutesSurplus, + ], + ); + + const alertText = useMemo(() => { + if (isProtectionTime && passwordErrorProtectionTimeMinutesSurplus > 0) { + return intl.formatMessage( + { + id: ETranslations.auth_passcode_cooldown, + }, + { + cooldowntime: `${Math.floor( + passwordErrorProtectionTimeMinutesSurplus, + )} Min`, + }, + ); + } + return ''; + }, [isProtectionTime, passwordErrorProtectionTimeMinutesSurplus, intl]); + + const intervalRef = useRef>(); + + const protectionTimeRun = useCallback(() => { + if (passwordErrorProtectionTime < Date.now()) { + setPasswordErrorProtectionTimeMinutesSurplus(0); + } else { + const timeMinutes = + (passwordErrorProtectionTime - Date.now()) / 60_000 + 1; + setPasswordErrorProtectionTimeMinutesSurplus(timeMinutes); + } + }, [passwordErrorProtectionTime]); + + useEffect(() => { + if (isProtectionTime) { + protectionTimeRun(); + intervalRef.current = setInterval(() => { + protectionTimeRun(); + }, 1000 * 5); + } else if (intervalRef.current) { + clearInterval(intervalRef.current); + } + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [ + alertText, + isProtectionTime, + passwordErrorProtectionTime, + protectionTimeRun, + ]); + + return { + unlockPeriodPasswordArray, + passwordErrorProtectionTimeMinutesSurplus, + verifyPeriodBiologyAuthAttempts, + verifyPeriodBiologyEnable, + passwordErrorAttempts, + alertText, + setPasswordPersist, + setVerifyPeriodBiologyEnable, + setVerifyPeriodBiologyAuthAttempts, + setUnlockPeriodPasswordArray, + setPasswordErrorProtectionTimeMinutesSurplus, + enablePasswordErrorProtection, + isProtectionTime, + }; +}; + +export default usePasswordProtection; diff --git a/packages/kit/src/components/Password/utils.ts b/packages/kit/src/components/Password/utils.ts index 51e8dbfee13..2d6d22af493 100644 --- a/packages/kit/src/components/Password/utils.ts +++ b/packages/kit/src/components/Password/utils.ts @@ -5,6 +5,8 @@ import platformEnv from '@onekeyhq/shared/src/platformEnv'; export const PasswordRegex = /[^\x20-\x7E]/gm; +export const PassCodeRegex = /[^\d.]/gm; + export const getPasswordKeyboardType = (visible?: boolean) => { let keyboardType: ComponentProps['keyboardType'] = 'default'; if (platformEnv.isNativeIOS) { diff --git a/packages/kit/src/provider/Container/AppStateLockContainer/components/AppStateLock.tsx b/packages/kit/src/provider/Container/AppStateLockContainer/components/AppStateLock.tsx index 8ea6e158cca..4cb71b5a7c6 100644 --- a/packages/kit/src/provider/Container/AppStateLockContainer/components/AppStateLock.tsx +++ b/packages/kit/src/provider/Container/AppStateLockContainer/components/AppStateLock.tsx @@ -117,7 +117,7 @@ const AppStateLock = ({ v4migrationData?.isProcessing ? null : ( )} diff --git a/packages/kit/src/provider/Container/AppStateLockContainer/index.tsx b/packages/kit/src/provider/Container/AppStateLockContainer/index.tsx index 049533a9bdc..4efe66fa15f 100644 --- a/packages/kit/src/provider/Container/AppStateLockContainer/index.tsx +++ b/packages/kit/src/provider/Container/AppStateLockContainer/index.tsx @@ -1,11 +1,10 @@ import type { PropsWithChildren } from 'react'; import { Suspense, useCallback, useEffect, useRef, useState } from 'react'; -import { AnimatePresence } from '@onekeyhq/components'; +import { AnimatePresence, Spinner } from '@onekeyhq/components'; import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; import { useAppIsLockedAtom } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; import platformEnv from '@onekeyhq/shared/src/platformEnv'; -import extUtils, { EXT_HTML_FILES } from '@onekeyhq/shared/src/utils/extUtils'; import PasswordVerifyContainer from '../../../components/Password/container/PasswordVerifyContainer'; @@ -86,7 +85,7 @@ export function AppStateLockContainer({ opacity: 0, }} passwordVerifyContainer={ - + }> {}, @@ -27,7 +27,7 @@ function RestorePasswordVerify() { autoFocus size="large" placeholder={intl.formatMessage({ - id: ETranslations.auth_enter_your_password, + id: ETranslations.auth_enter_your_passcode, })} flex={1} keyboardType={getPasswordKeyboardType(!secureEntry)} @@ -56,7 +56,7 @@ export function useRestorePasswordVerifyDialog() { icon: 'InfoCircleOutline', title: intl.formatMessage({ id: ETranslations.backup_import_data }), description: intl.formatMessage({ - id: ETranslations.backup_verify_app_password_to_import_data, + id: ETranslations.backup_verify_app_passcode_to_import_data, }), renderContent: , onConfirmText: intl.formatMessage({ diff --git a/packages/kit/src/views/CloudBackup/pages/Detail/index.tsx b/packages/kit/src/views/CloudBackup/pages/Detail/index.tsx index 1f680e63d9d..66326189b7f 100644 --- a/packages/kit/src/views/CloudBackup/pages/Detail/index.tsx +++ b/packages/kit/src/views/CloudBackup/pages/Detail/index.tsx @@ -280,7 +280,7 @@ export default function Detail() { if (result === ERestoreResult.WRONG_PASSWORD) { Toast.error({ title: intl.formatMessage({ - id: ETranslations.auth_error_password_incorrect, + id: ETranslations.auth_error_passcode_incorrect, }), }); } else if (result === ERestoreResult.UNKNOWN_ERROR) { diff --git a/packages/kit/src/views/Onboarding/pages/CreateWalet/BeforeShowRecoveryPhrase.tsx b/packages/kit/src/views/Onboarding/pages/CreateWalet/BeforeShowRecoveryPhrase.tsx index 6dd59394b79..49c57c8d367 100644 --- a/packages/kit/src/views/Onboarding/pages/CreateWalet/BeforeShowRecoveryPhrase.tsx +++ b/packages/kit/src/views/Onboarding/pages/CreateWalet/BeforeShowRecoveryPhrase.tsx @@ -52,7 +52,7 @@ export function BeforeShowRecoveryPhrase() { iconColor: '$iconSuccess', iconContainerColor: '$bgSuccess', message: intl.formatMessage({ - id: ETranslations.onboarding_bullet_forgot_password_use_recovery, + id: ETranslations.onboarding_bullet_forgot_passcode_use_recovery, }), }, { diff --git a/packages/kit/src/views/Setting/pages/List/SecuritySection/index.tsx b/packages/kit/src/views/Setting/pages/List/SecuritySection/index.tsx index 66fcd02be7d..28ca7b11603 100644 --- a/packages/kit/src/views/Setting/pages/List/SecuritySection/index.tsx +++ b/packages/kit/src/views/Setting/pages/List/SecuritySection/index.tsx @@ -66,7 +66,7 @@ const SetPasswordItem = () => { void backgroundApiProxy.servicePassword.promptPasswordVerify(); }} icon="KeyOutline" - title={intl.formatMessage({ id: ETranslations.global_set_password })} + title={intl.formatMessage({ id: ETranslations.global_set_passcode })} drillIn /> ); @@ -80,7 +80,7 @@ const ChangePasswordItem = () => { reason: EReasonForNeedPassword.Security, }); const dialog = Dialog.show({ - title: intl.formatMessage({ id: ETranslations.global_change_password }), + title: intl.formatMessage({ id: ETranslations.global_change_passcode }), renderContent: ( { ); diff --git a/packages/kit/src/views/Setting/pages/Protection/index.tsx b/packages/kit/src/views/Setting/pages/Protection/index.tsx index cc5f9c57b55..da592528998 100644 --- a/packages/kit/src/views/Setting/pages/Protection/index.tsx +++ b/packages/kit/src/views/Setting/pages/Protection/index.tsx @@ -11,6 +11,7 @@ import { } from '@onekeyhq/components'; import backgroundApiProxy from '@onekeyhq/kit/src/background/instance/backgroundApiProxy'; import { ListItem } from '@onekeyhq/kit/src/components/ListItem'; +import PassCodeProtectionSwitch from '@onekeyhq/kit/src/components/Password/container/PassCodeProtectionSwitch'; import { useSettingsPersistAtom } from '@onekeyhq/kit-bg/src/states/jotai/atoms/settings'; import { ETranslations } from '@onekeyhq/shared/src/locale'; @@ -50,7 +51,7 @@ const SettingProtectionModal = () => { { {intl.formatMessage({ - id: ETranslations.settings_password_bypass_desc, + id: ETranslations.settings_passcode_bypass_desc, + })} + + + + + + + + {intl.formatMessage({ + id: ETranslations.Setting_Reset_app_description, })} diff --git a/packages/shared/src/errors/errors/appErrors.ts b/packages/shared/src/errors/errors/appErrors.ts index 908c0efd503..a118d63b579 100644 --- a/packages/shared/src/errors/errors/appErrors.ts +++ b/packages/shared/src/errors/errors/appErrors.ts @@ -24,7 +24,7 @@ export class IncorrectPassword extends OneKeyError { super( normalizeErrorProps(props, { defaultMessage: 'OneKeyError: IncorrectPassword', - defaultKey: ETranslations.auth_error_password_incorrect, + defaultKey: ETranslations.auth_error_passcode_incorrect, }), ); } @@ -169,7 +169,7 @@ export class WrongPassword extends OneKeyError { super( normalizeErrorProps(props, { defaultMessage: 'WrongPassword', - defaultKey: ETranslations.send_engine_incorrect_password, + defaultKey: ETranslations.send_engine_incorrect_passcode, defaultAutoToast: true, }), ); @@ -196,7 +196,7 @@ export class PasswordNotSet extends OneKeyError { super( normalizeErrorProps(props, { defaultMessage: 'PasswordNotSet', - defaultKey: ETranslations.send_engine_password_not_set, + defaultKey: ETranslations.send_engine_passcode_not_set, defaultAutoToast: true, }), ); @@ -208,7 +208,7 @@ export class PasswordStrengthValidationFailed extends OneKeyError { super( normalizeErrorProps(props, { defaultMessage: 'PasswordStrengthValidationFailed', - defaultKey: ETranslations.send_password_validation, + defaultKey: ETranslations.send_passcode_validation, }), ); } @@ -219,7 +219,7 @@ export class PasswordUpdateSameFailed extends OneKeyError { super( normalizeErrorProps(props, { defaultMessage: 'PasswordUpdateSameFailed', - defaultKey: ETranslations.auth_error_password_incorrect, + defaultKey: ETranslations.auth_error_passcode_incorrect, }), ); } @@ -241,7 +241,7 @@ export class PasswordAlreadySetFailed extends OneKeyError { super( normalizeErrorProps(props, { defaultMessage: 'PasswordAlreadySetFaield', - defaultKey: ETranslations.auth_error_password_incorrect, + defaultKey: ETranslations.auth_error_passcode_incorrect, }), ); } diff --git a/yarn.lock b/yarn.lock index 8b7cefd9cee..9b19099a605 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6749,6 +6749,7 @@ __metadata: react-dom: "npm:18.2.0" react-mobile-cropper: "npm:^0.10.0" react-native: "npm:0.73.7" + react-native-confirmation-code-field: "npm:^7.4.0" react-native-copy-asset: "npm:^3.0.2" react-native-draggable-flatlist: "npm:4.0.1" react-native-flipper: "npm:0.201.0" @@ -33602,6 +33603,21 @@ __metadata: languageName: node linkType: hard +"react-native-confirmation-code-field@npm:^7.4.0": + version: 7.4.0 + resolution: "react-native-confirmation-code-field@npm:7.4.0" + peerDependencies: + react: ">=16.4.0" + react-native: ">=0.64.0" + peerDependenciesMeta: + react: + optional: true + react-native: + optional: true + checksum: 10/ababeeb36067574f57c907f15d6c9ac6831e06076ed2882f382053ccf7c9912377787a6a56876cc6b63b2e8bab9ba179845046e8eed2cd9e2bf59bc00347ade5 + languageName: node + linkType: hard + "react-native-copy-asset@npm:^3.0.2": version: 3.0.2 resolution: "react-native-copy-asset@npm:3.0.2"