add import wallet flow (#693)

Co-authored-by: Jakub Kotula <520927+jkbktl@users.noreply.github.com>
Co-authored-by: Felicio <felicio@users.noreply.github.com>
This commit is contained in:
Stanislav
2025-06-24 19:57:45 +09:00
committed by GitHub
co-authored by Jakub Kotula Felicio
parent d354913420
commit 0a14da44e7
23 changed files with 661 additions and 362 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@status-im/components': minor
'@status-im/wallet': minor
'wallet': minor
---
add import wallet flow
+11
View File
@@ -1,6 +1,9 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"npm.packageManager": "pnpm",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.useESLintClass": true,
"eslint.workingDirectories": [
"./packages/colors",
@@ -17,6 +20,14 @@
"#comment": "See https://github.com/microsoft/vscode-eslint/issues/1161 for reason (i.e. multiple .eslintrc config files)"
}
],
"eslint.validate": [
// "markdown",
// "mdx",
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
+3
View File
@@ -43,6 +43,9 @@
"@trpc/server": "10.45.2",
"@trustwallet/wallet-core": "^4.2.10",
"@wxt-dev/storage": "^1.1.0",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
"bitcoinjs-lib": "^6.1.7",
"bitcore-lib": "^10.8.0",
"buffer": "^6.0.3",
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

+3 -3
View File
@@ -82,7 +82,7 @@ test('should add wallet', async () => {
})
expect(addedWallet.mnemonic).toBeDefined()
}, 6000)
}, 7000)
test('should import wallet', async () => {
await createAPI()
@@ -98,7 +98,7 @@ test('should import wallet', async () => {
})
expect(importedWallet.mnemonic).toBe(mnemonic)
}, 6000)
}, 7000)
test('should get wallet', async () => {
await createAPI()
@@ -119,4 +119,4 @@ test('should get wallet', async () => {
})
expect(returnedWallet.mnemonic).toBe(mnemonic)
}, 6000)
}, 7000)
+23 -2
View File
@@ -10,6 +10,7 @@ import {
Link,
// Navigate,
Outlet,
redirect,
} from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
@@ -19,6 +20,7 @@ import { TanStackRouterDevtools } from '@tanstack/router-devtools'
// import { QueryClientProvider } from '../../../portfolio/src/app/_providers/query-client-provider'
// import { StatusProvider } from '../../../portfolio/src/app/_providers/status-provider'
import { WagmiProvider } from '../../../portfolio/src/app/_providers/wagmi-provider'
import { apiClient } from '../providers/api-client'
import { WalletProvider } from '../providers/wallet-context'
// import { Inter } from 'next/font/google'
@@ -34,6 +36,26 @@ import type { QueryClient } from '@tanstack/react-query'
export const Route = createRootRouteWithContext<{
queryClient: QueryClient
}>()({
beforeLoad: async ({ location }) => {
const wallets = await apiClient.wallet.all.query()
const hasWallets = wallets && wallets.length > 0
if (location.pathname === '/') {
if (hasWallets) {
throw redirect({ to: '/portfolio' })
} else {
throw redirect({ to: '/onboarding' })
}
}
if (location.pathname.startsWith('/portfolio') && !hasWallets) {
throw redirect({ to: '/onboarding' })
}
if (location.pathname.startsWith('/onboarding') && hasWallets) {
throw redirect({ to: '/portfolio' })
}
},
head: () => ({
meta: [
{
@@ -52,7 +74,6 @@ export const Route = createRootRouteWithContext<{
})
function RootComponent() {
const pathname = window.location.pathname
return (
<>
{/* <div className="min-h-screen bg-neutral-100 text-white-100">
@@ -79,7 +100,7 @@ function RootComponent() {
{/* <ConnectKitProvider> */}
<WalletProvider>
<div className="flex min-h-[56px] items-center px-2">
<Navbar pathname={pathname} />
<Navbar />
</div>
<div className="px-1">
<div className="flex-1 flex-col 2md:flex xl:pb-1">
+1 -9
View File
@@ -1,6 +1,4 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { apiClient } from '../providers/api-client'
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
component: RouteComponent,
@@ -11,12 +9,6 @@ export const Route = createFileRoute('/')({
},
],
}),
beforeLoad: async () => {
const wallets = await apiClient.wallet.all.query()
if (wallets && wallets.length > 0) {
throw redirect({ to: '/portfolio' })
} else throw redirect({ to: '/onboarding' })
},
})
function RouteComponent() {
+23 -11
View File
@@ -1,21 +1,33 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { apiClient } from '../../providers/api-client'
import { BlurredCircle } from '@status-im/wallet/components'
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/onboarding')({
component: RouteComponent,
beforeLoad: async () => {
const wallets = await apiClient.wallet.all.query()
if (wallets && wallets.length > 0) {
throw redirect({ to: '/portfolio' })
}
},
})
function RouteComponent() {
return (
<div className="m-auto min-h-[650px] w-full max-w-[440px] rounded-[24px] border border-neutral-5 p-5 shadow-2">
<Outlet />
<div className="relative flex h-screen min-h-[650px] bg-neutral-5">
<BlurredCircle
color="purple"
className="absolute left-1/4 top-1/4 z-0 translate-y-[-100px]"
/>
<BlurredCircle
color="sky"
className="absolute left-2/4 top-1/4 z-0 translate-y-[-130px]"
/>
<BlurredCircle
color="yellow"
className="absolute left-1/4 top-2/4 z-0 translate-y-[-50px]"
/>
<BlurredCircle
color="orange"
className="absolute left-2/4 top-2/4 z-0 translate-y-[-80px]"
/>
<div className="relative z-10 m-auto flex min-h-[650px] w-full max-w-[440px] flex-col rounded-[24px] border border-neutral-5 bg-white-100 p-5 shadow-2">
<Outlet />
</div>
</div>
)
}
+96 -85
View File
@@ -1,11 +1,19 @@
import { useState } from 'react'
import { useState, useTransition } from 'react'
import { Button, Input } from '@status-im/components'
import { createFileRoute } from '@tanstack/react-router'
import { useForm } from 'react-hook-form'
import { Button, Text } from '@status-im/components'
import { ArrowLeftIcon } from '@status-im/icons/20'
import {
CreatePasswordForm,
type CreatePasswordFormValues,
ImportRecoveryPhraseForm,
type ImportRecoveryPhraseFormValues,
} from '@status-im/wallet/components'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useImportWallet } from '../../hooks/use-import-wallet'
import type { SubmitHandler } from 'react-hook-form'
export const Route = createFileRoute('/onboarding/import')({
component: RouteComponent,
})
@@ -27,112 +35,115 @@ function RouteComponent() {
})
return (
<div>
<div className="flex h-full flex-1">
{onboardingState.type === 'import-wallet' && (
<ImportWallet
onNext={mnemonic =>
onNext={(mnemonic: string) => {
setOnboardingState({ type: 'create-password', mnemonic })
}
}}
/>
)}
{onboardingState.type === 'create-password' && (
<CreatePassword mnemonic={onboardingState.mnemonic} />
<CreatePassword
mnemonic={onboardingState.mnemonic}
onBack={() =>
setOnboardingState({
type: 'import-wallet',
mnemonic: '',
})
}
/>
)}
</div>
)
}
function ImportWallet({ onNext }: { onNext: (mnemonic: string) => void }) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
defaultValues: {
mnemonic: '',
},
})
const [isPending, startTransition] = useTransition()
const onSubmit = handleSubmit(data => {
onNext(data.mnemonic)
})
const onSubmit: SubmitHandler<
ImportRecoveryPhraseFormValues
> = async data => {
try {
startTransition(async () => {
onNext(data.mnemonic)
})
} catch (error) {
console.error(error)
}
}
return (
<div className="flex flex-col gap-4">
<textarea
className="h-32 rounded-12 border border-neutral-80 bg-neutral-90 p-2 text-white-100 placeholder:text-neutral-40 dark:border-neutral-60 dark:bg-neutral-100"
placeholder="Enter your recovery phrase"
{...register('mnemonic', {
required: 'Recovery phrase is required',
})}
/>
{errors.mnemonic && (
<p className="text-13 text-danger-50">{errors.mnemonic.message}</p>
)}
<Button variant="danger" onClick={onSubmit}>
Continue
</Button>
<div className="flex flex-1 flex-col gap-1">
<div className="pb-4">
<Button
href="/onboarding"
variant="grey"
icon={<ArrowLeftIcon color="$neutral-100" />}
aria-label="Back"
size="32"
/>
</div>
<Text size={27} weight="semibold">
Import via recovery phrase
</Text>
<Text size={15} color="$neutral-50" className="mb-4">
Type or paste your 12, 15, 18, 21 or 24 words Ethereum recovery phrase
</Text>
<ImportRecoveryPhraseForm onSubmit={onSubmit} loading={isPending} />
</div>
)
}
function CreatePassword({ mnemonic }: { mnemonic: string }) {
const {
register,
handleSubmit,
formState: { errors },
watch,
} = useForm({
defaultValues: {
password: '',
confirmPassword: '',
},
})
function CreatePassword({
mnemonic,
onBack,
}: {
mnemonic: string
onBack: () => void
}) {
const { importWalletAsync } = useImportWallet()
const navigate = useNavigate()
const [isPending, startTransition] = useTransition()
const onSubmit = handleSubmit(async data => {
await importWalletAsync({
mnemonic,
password: data.password,
})
})
const handleSubmit: SubmitHandler<CreatePasswordFormValues> = async data => {
try {
startTransition(async () => {
await importWalletAsync({
mnemonic,
password: data.password,
})
navigate({ to: '/portfolio' })
})
} catch (error) {
console.error(error)
}
}
return (
<div className="flex flex-col gap-4">
{/* @ts-expect-error: fixme: Types of property 'onChange' are incompatible. */}
<Input
type="password"
placeholder="New password"
{...register('password', {
required: 'Password is required',
minLength: {
value: 8,
message: 'Password must be at least 8 characters',
},
})}
<div>
<Button
onClick={onBack}
variant="grey"
icon={<ArrowLeftIcon color="$neutral-100" />}
aria-label="Back"
size="32"
/>
</div>
<h1 className="text-27 font-600">Create password</h1>
<div className="text-15 text-neutral-50">
To unlock the extension and sign transactions, the password is stored
only on your device. Status can't recover it.
</div>
<CreatePasswordForm
onSubmit={handleSubmit}
loading={isPending}
confirmButtonLabel="Import Wallet"
/>
{errors.password && (
<p className="text-13 text-danger-50">{errors.password.message}</p>
)}
{/* @ts-expect-error: fixme: Types of property 'onChange' are incompatible. */}
<Input
type="password"
placeholder="Confirm password"
{...register('confirmPassword', {
required: 'Please confirm your password',
validate: value =>
value === watch('password') || 'Passwords do not match',
})}
/>
{errors.confirmPassword && (
<p className="text-13 text-danger-50">
{errors.confirmPassword.message}
</p>
)}
<Button variant="danger" onClick={onSubmit}>
Import Wallet
</Button>
</div>
)
}
+1 -1
View File
@@ -31,7 +31,7 @@ function RouteComponent() {
}
return (
<div className="flex h-full">
<div className="flex h-full flex-1">
<div className="flex flex-col gap-4">
<div>
<Button
+1 -8
View File
@@ -2,11 +2,10 @@
import { AssetsList, PinExtension } from '@status-im/wallet/components'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute, redirect } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
import { usePinExtension } from '@/hooks/use-pin-extension'
import { apiClient } from '../../providers/api-client'
import { useWallet } from '../../providers/wallet-context'
// import { DetailDrawer } from '../../../../portfolio/src/app/[address]/@detail/_drawer'
@@ -14,12 +13,6 @@ import { useWallet } from '../../providers/wallet-context'
export const Route = createFileRoute('/portfolio/')({
component: RouteComponent,
beforeLoad: async () => {
const wallets = await apiClient.wallet.all.query()
if (!wallets || wallets.length === 0) {
throw redirect({ to: '/onboarding' })
}
},
head: () => ({
meta: [
{
-2
View File
@@ -10,8 +10,6 @@ export default {
// todo: extend @status-im/components too
presets: [config],
darkMode: 'media',
future: {
hoverOnlyWhenSupported: true,
},
+1
View File
@@ -17,6 +17,7 @@ export { Switch } from './switch'
export { Tabs } from './tabs'
export { Tag } from './tag'
export { Text } from './text'
export { Textarea } from './textarea'
export { Toast, ToastContainer, useToast } from './toast'
export { Tooltip } from './tooltip'
export type * from './types'
@@ -0,0 +1 @@
export { Textarea, type TextareaProps } from './textarea'
@@ -0,0 +1,49 @@
import { Textarea } from './textarea'
import type { Meta, StoryObj } from '@storybook/react'
type Component = typeof Textarea
const meta: Meta<Component> = {
title: 'Components/Textarea',
component: Textarea,
args: {
placeholder: 'Type something...',
},
render: props => (
<div className="flex w-[300px] flex-col gap-3">
<h3 className="mb-2 text-15 font-medium">Basic Textarea</h3>
<Textarea {...props} />
<h3 className="mb-2 mt-4 text-15 font-medium">Clearable Textarea</h3>
<Textarea {...props} clearable />
<h3 className="mb-2 mt-4 text-15 font-medium">Special States</h3>
<Textarea {...props} isDisabled />
<Textarea {...props} isInvalid />
<h3 className="mb-2 mt-4 text-15 font-medium">
Textarea with Label and Meta
</h3>
<Textarea {...props} label="Label" />
<Textarea {...props} label="Label" maxLength={280} />
</div>
),
}
type Story = StoryObj<Component>
export const Light: Story = {
args: {},
}
export const Dark: Story = {
parameters: {
backgrounds: {
default: 'dark',
},
},
}
export default meta
@@ -0,0 +1,104 @@
'use client'
import { forwardRef } from 'react'
import { ClearIcon } from '@status-im/icons/20'
import { cva } from 'cva'
import * as Aria from 'react-aria-components'
import type { VariantProps } from 'cva'
type Variants = VariantProps<typeof textareaStyles>
type Props = Aria.TextFieldProps & {
size?: Variants['size']
label?: string
placeholder?: string
clearable?: boolean
}
const Textarea = (props: Props, ref: React.Ref<HTMLTextAreaElement>) => {
const {
size = '40',
label,
placeholder,
inputMode,
clearable = false,
maxLength,
...fieldProps
} = props
return (
<Aria.TextField {...fieldProps} className="grid gap-2">
{(label || maxLength) && (
<div className="grid grid-cols-2">
<Aria.Label className="text-13 font-medium text-neutral-50 dark:text-neutral-40">
{label}
</Aria.Label>
{maxLength && (
<Aria.Label className="text-right text-13 font-regular text-neutral-50 dark:text-neutral-40">
{fieldProps.value?.length}/{maxLength}
</Aria.Label>
)}
</div>
)}
<div className="relative">
<Aria.TextArea
ref={ref}
inputMode={inputMode}
placeholder={placeholder}
className={textareaStyles({ size, clearable })}
/>
{clearable && (
<Aria.Button
className={clearableButtonStyles({ size })}
aria-label="Clear input"
onPress={() => props.onChange?.('')}
>
<ClearIcon />
</Aria.Button>
)}
</div>
</Aria.TextField>
)
}
const textareaStyles = cva({
base: [
'block h-32 w-full min-w-0 flex-1 border border-neutral-20 bg-white-100 px-4 py-2 text-15 text-neutral-100 placeholder-neutral-40 max-sm:text-[1rem]',
'outline-none focus:border-neutral-40',
'disabled:border-neutral-20 disabled:opacity-[.3]',
'invalid:border-danger-50/40',
'resize-none',
// dark
'dark:border-neutral-70 dark:bg-neutral-95 dark:text-white-100 dark:placeholder-neutral-50',
'dark:invalid:border-danger-50/40',
],
variants: {
size: {
'40': 'rounded-12 px-3',
'32': 'rounded-10 px-2',
},
clearable: { true: '' },
},
})
const clearableButtonStyles = cva({
base: [
'absolute top-2 size-5 text-neutral-40 dark:text-neutral-60',
'outline-none',
],
variants: {
size: {
'40': 'right-3',
'32': 'right-2',
},
},
})
const _Textarea = forwardRef(Textarea)
export { _Textarea as Textarea }
export type { Props as TextareaProps }
+2 -1
View File
@@ -84,7 +84,8 @@
"superjson": "^2.2.1",
"ts-pattern": "^5.3.1",
"zod": "^3.23.8",
"zustand": "^4.3.7"
"zustand": "^4.3.7",
"@scure/bip39": "^1.2.0"
},
"devDependencies": {
"@hookform/devtools": "^4.3.1",
@@ -0,0 +1,15 @@
import type { ComponentProps } from 'react'
type Props = {
color: string
} & Pick<ComponentProps<'div'>, 'className'>
const BlurredCircle = ({ color, className }: Props) => {
return (
<div data-customisation={color} className={className}>
<div className="size-[468px] rounded-full bg-customisation-50 opacity-[6%] blur-[100px]" />
</div>
)
}
export { BlurredCircle }
@@ -54,11 +54,13 @@ type FormValues = z.infer<typeof createPasswordSchema>
type CreatePasswordFormProps = {
onSubmit: (data: FormValues) => void
loading: boolean
confirmButtonLabel?: string
}
const CreatePasswordForm = ({
onSubmit,
loading = false,
confirmButtonLabel = 'Continue',
}: CreatePasswordFormProps) => {
const form = useForm<FormValues>({
resolver: zodResolver(createPasswordSchema),
@@ -179,7 +181,7 @@ const CreatePasswordForm = ({
{loading ? (
<LoadingIcon className="animate-spin text-white-100" />
) : (
'Continue'
confirmButtonLabel
)}
</Button>
</div>
@@ -0,0 +1,78 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { validateMnemonic } from '@scure/bip39'
import { wordlist as english } from '@scure/bip39/wordlists/english'
import { Button } from '@status-im/components'
import { AlertIcon, LoadingIcon } from '@status-im/icons/20'
import { FormProvider, useForm } from 'react-hook-form'
import { z } from 'zod'
import { RecoveryPhraseTextarea } from '../recovery-phrase-textarea'
const mnemonicSchema = z.object({
mnemonic: z.string().refine(value => validateMnemonic(value, english), {
message: 'Invalid phrase. Check word count and spelling.',
}),
})
type FormValues = z.infer<typeof mnemonicSchema>
type ImportRecoveryPhraseFormProps = {
onSubmit: (data: FormValues) => void
loading: boolean
}
const ImportRecoveryPhraseForm = ({
onSubmit,
loading = false,
}: ImportRecoveryPhraseFormProps) => {
const form = useForm<FormValues>({
resolver: zodResolver(mnemonicSchema),
mode: 'onChange',
defaultValues: {
mnemonic: '',
},
})
const {
formState: { errors },
} = form
return (
<FormProvider {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex h-full flex-1 flex-col justify-between"
>
<RecoveryPhraseTextarea
label="Recovery phrase"
placeholder="Recovery phrase"
name="mnemonic"
/>
<div className="mt-auto flex flex-col gap-6">
{errors.mnemonic && (
<p className="flex items-center gap-1 text-danger-50">
<AlertIcon />
<span className="text-13">{errors.mnemonic.message}</span>
</p>
)}
<Button
variant="primary"
type="submit"
disabled={!form.formState.isValid}
>
{loading ? (
<LoadingIcon className="animate-spin text-white-100" />
) : (
'Continue'
)}
</Button>
</div>
</form>
</FormProvider>
)
}
export { ImportRecoveryPhraseForm }
export type { FormValues as ImportRecoveryPhraseFormValues }
+6
View File
@@ -3,6 +3,7 @@ export * from '../utils/variants'
export { AccountMenu } from './account-menu'
export { type Account, Address, type AddressProps } from './address'
export { AssetsList } from './assets-list'
export { BlurredCircle } from './blurred-circle'
export {
CreatePasswordForm,
type CreatePasswordFormValues,
@@ -10,12 +11,17 @@ export {
export { CurrencyAmount } from './currency-amount'
export { DeleteAddressAlert } from './delete-address-alert'
export { Image, type ImageProps } from './image'
export {
ImportRecoveryPhraseForm,
type ImportRecoveryPhraseFormValues,
} from './import-recovery-phrase-form'
export { Logo, type LogoProps } from './logo'
export { Navbar } from './nav-bar'
export { NetworkExplorerLogo } from './network-explorer-logo'
export { NetworkLogo } from './network-logo'
export { PercentageChange } from './percentage-change'
export { PinExtension } from './pin-extension'
export { RecoveryPhraseTextarea } from './recovery-phrase-textarea'
export { SettingsPopover } from './settings-popover'
export {
ShortenAddress,
@@ -0,0 +1,27 @@
import { Textarea } from '@status-im/components'
import { useController } from 'react-hook-form'
import type { UseControllerProps } from 'react-hook-form'
export type Props = React.ComponentPropsWithoutRef<'textarea'> &
UseControllerProps & {
label: string
placeholder?: string
}
function RecoveryPhraseTextarea(props: Props) {
const { label, placeholder } = props
const { field, fieldState } = useController(props)
const invalid = fieldState.invalid
return (
<Textarea
{...field}
placeholder={placeholder}
data-invalid={invalid}
aria-label={label}
/>
)
}
export { RecoveryPhraseTextarea }
+206 -239
View File
File diff suppressed because it is too large Load Diff