From 49132a60fbefab9c67f8958a8ea810241ee0a1f9 Mon Sep 17 00:00:00 2001 From: NeOMakinG <14963751+NeOMakinG@users.noreply.github.com> Date: Tue, 18 Feb 2025 19:20:10 +0800 Subject: [PATCH] feat: mobile wallet keystore (#8848) --- src/components/FileUpload/FileUpload.tsx | 98 +++++++++ .../routes/ImportWallet/ImportKeystore.tsx | 163 +++++++++++++++ .../routes/ImportWallet/ImportRouter.tsx | 8 + .../routes/ImportWallet/ImportSeedPhrase.tsx | 158 ++++++++++++++ .../routes/ImportWallet/ImportWallet.tsx | 194 +++++++----------- src/components/MobileWalletDialog/types.ts | 2 + src/components/MobileWalletDialog/utils.ts | 60 ++++++ .../components/NativeImportKeystore.tsx | 99 +-------- 8 files changed, 561 insertions(+), 221 deletions(-) create mode 100644 src/components/FileUpload/FileUpload.tsx create mode 100644 src/components/MobileWalletDialog/routes/ImportWallet/ImportKeystore.tsx create mode 100644 src/components/MobileWalletDialog/routes/ImportWallet/ImportSeedPhrase.tsx create mode 100644 src/components/MobileWalletDialog/utils.ts diff --git a/src/components/FileUpload/FileUpload.tsx b/src/components/FileUpload/FileUpload.tsx new file mode 100644 index 00000000000..a2660fe0269 --- /dev/null +++ b/src/components/FileUpload/FileUpload.tsx @@ -0,0 +1,98 @@ +import { Box, FormControl, Icon, Input, Text as CText } from '@chakra-ui/react' +import { useColorModeValue } from '@chakra-ui/system' +import { useCallback, useState } from 'react' +import { FaFile } from 'react-icons/fa' +import { Text } from 'components/Text' + +const hoverSx = { borderColor: 'blue.500' } + +// TODO(gomes): use https://www.chakra-ui.com/docs/components/file-upload if/when we migrate to chakra@3 +export const FileUpload = ({ onFileSelect }: { onFileSelect: (file: File) => void }) => { + const borderColor = useColorModeValue('gray.200', 'gray.600') + const [isDragging, setIsDragging] = useState(false) + const [filename, setFilename] = useState(null) + + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(true) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + }, []) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + }, []) + + const processFile = useCallback( + (file: File) => { + setFilename(file.name) + onFileSelect(file) + }, + [onFileSelect], + ) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + + const files = e.dataTransfer.files + if (files?.[0]) { + processFile(files[0]) + } + }, + [processFile], + ) + + const handleFileInput = useCallback( + (e: React.ChangeEvent) => { + const files = e.target.files + if (files?.[0]) { + processFile(files[0]) + } + }, + [processFile], + ) + + return ( + + + + + {filename ? ( + {filename} + ) : ( + + )} + + + ) +} diff --git a/src/components/MobileWalletDialog/routes/ImportWallet/ImportKeystore.tsx b/src/components/MobileWalletDialog/routes/ImportWallet/ImportKeystore.tsx new file mode 100644 index 00000000000..e5a06f11cf6 --- /dev/null +++ b/src/components/MobileWalletDialog/routes/ImportWallet/ImportKeystore.tsx @@ -0,0 +1,163 @@ +import { + Box, + Button, + FormControl, + FormErrorMessage, + Input, + Text as CText, + VStack, +} from '@chakra-ui/react' +import { useCallback, useState } from 'react' +import type { FieldValues } from 'react-hook-form' +import { useForm } from 'react-hook-form' +import { useTranslate } from 'react-polyglot' +import { useHistory } from 'react-router-dom' +import { FileUpload } from 'components/FileUpload/FileUpload' +import { MobileWalletDialogRoutes } from 'components/MobileWalletDialog/types' +import { decryptFromKeystore } from 'components/MobileWalletDialog/utils' +import { DialogBackButton } from 'components/Modal/components/DialogBackButton' +import { DialogBody } from 'components/Modal/components/DialogBody' +import { DialogCloseButton } from 'components/Modal/components/DialogCloseButton' +import { DialogFooter } from 'components/Modal/components/DialogFooter' +import { + DialogHeader, + DialogHeaderLeft, + DialogHeaderRight, +} from 'components/Modal/components/DialogHeader' +import { SlideTransition } from 'components/SlideTransition' +import { Text } from 'components/Text' +import { addWallet } from 'context/WalletProvider/MobileWallet/mobileMessageHandlers' +import type { NativeWalletValues } from 'context/WalletProvider/NativeWallet/types' +import { getMixPanel } from 'lib/mixpanel/mixPanelSingleton' +import { MixPanelEvent } from 'lib/mixpanel/types' + +export const ImportKeystore = () => { + const history = useHistory() + const [keystoreFile, setKeystoreFile] = useState(null) + const mixpanel = getMixPanel() + + const translate = useTranslate() + + const { + setError, + handleSubmit, + formState: { errors, isSubmitting, isValid }, + register, + } = useForm({ + mode: 'onChange', + shouldUnregister: true, + }) + + const onSubmit = useCallback( + async (values: FieldValues) => { + if (!keystoreFile) { + throw new Error('No keystore uploaded') + } + const parsedKeystore = JSON.parse(keystoreFile) + + try { + const mnemonic = await decryptFromKeystore(parsedKeystore, values.keystorePassword) + + const revocableVault = await addWallet({ + mnemonic, + label: values.name.trim(), + }) + history.push(MobileWalletDialogRoutes.ImportSuccess, { vault: revocableVault }) + mixpanel?.track(MixPanelEvent.NativeImportKeystore) + } catch (e) { + setError('keystorePassword', { + type: 'manual', + message: translate('walletProvider.shapeShift.import.invalidKeystorePassword'), + }) + } + }, + [history, keystoreFile, mixpanel, setError, translate], + ) + + const handleFileSelect = useCallback((file: File) => { + const reader = new FileReader() + reader.onload = e => { + if (!e?.target) return + if (typeof e.target.result !== 'string') return + setKeystoreFile(e.target.result) + } + reader.readAsText(file) + }, []) + + const handleGoBack = useCallback(() => { + history.goBack() + }, [history]) + + return ( + + + + + + + + + +
+ + + + + + + + + + + + + + {errors.name?.message} + + + + + {errors.keystorePassword?.message} + + + + + + {keystoreFile && ( + + )} + +
+
+ ) +} diff --git a/src/components/MobileWalletDialog/routes/ImportWallet/ImportRouter.tsx b/src/components/MobileWalletDialog/routes/ImportWallet/ImportRouter.tsx index bb1e66b379b..278d9a98965 100644 --- a/src/components/MobileWalletDialog/routes/ImportWallet/ImportRouter.tsx +++ b/src/components/MobileWalletDialog/routes/ImportWallet/ImportRouter.tsx @@ -4,6 +4,8 @@ import { MemoryRouter, Redirect, Route, Switch, useHistory } from 'react-router' import { MobileWalletDialogRoutes } from 'components/MobileWalletDialog/types' import { SlideTransition } from 'components/SlideTransition' +import { ImportKeystore } from './ImportKeystore' +import { ImportSeedPhrase } from './ImportSeedPhrase' import { ImportSuccess } from './ImportSuccess' import { ImportWallet } from './ImportWallet' @@ -29,6 +31,12 @@ export const ImportRouter = ({ onClose, defaultRoute }: ImportRouterProps) => { + + + + + + { + const { + setError, + handleSubmit, + register, + formState: { errors, isSubmitting }, + } = useForm({ shouldUnregister: true }) + const queryClient = useQueryClient() + const translate = useTranslate() + + const history = useHistory() + + const handleBack = useCallback(() => { + history.push(MobileWalletDialogRoutes.Import) + }, [history]) + + const onSubmit = useCallback( + async (values: FormValues) => { + try { + const vault = await addWallet({ + mnemonic: values.mnemonic.toLowerCase().trim(), + label: values.name.trim(), + }) + + history.push(MobileWalletDialogRoutes.ImportSuccess, { vault }) + queryClient.invalidateQueries({ queryKey: ['listWallets'] }) + } catch (e) { + console.log(e) + setError('mnemonic', { type: 'manual', message: 'walletProvider.shapeShift.import.header' }) + } + }, + [history, queryClient, setError], + ) + + const textareaFormProps = useMemo(() => { + return register('mnemonic', { + required: translate('walletProvider.shapeShift.import.secretRecoveryPhraseRequired'), + minLength: { + value: 47, + message: translate('walletProvider.shapeShift.import.secretRecoveryPhraseTooShort'), + }, + validate: { + validMnemonic: value => + bip39.validateMnemonic(value.toLowerCase().trim()) || + translate('walletProvider.shapeShift.import.secretRecoveryPhraseError'), + }, + }) + }, [register, translate]) + + const inputFormProps = useMemo(() => { + return register('name', { + required: translate('modals.shapeShift.password.error.walletNameRequired'), + maxLength: { + value: 64, + message: translate('modals.shapeShift.password.error.maxLength', { length: 64 }), + }, + }) + }, [register, translate]) + + const handleFormSubmit = useMemo(() => handleSubmit(onSubmit), [handleSubmit, onSubmit]) + + return ( + + + + + + + + + +
+ + + + + + + + + + + + +