mirror of
https://github.com/status-im/status-web.git
synced 2026-08-31 14:21:18 +00:00
add recovery phrase backup flow (#719)
Co-authored-by: Felicio <felicio@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@status-im/wallet': patch
|
||||
'wallet': patch
|
||||
---
|
||||
|
||||
add wallet recovery flow
|
||||
@@ -41,7 +41,7 @@
|
||||
"@tanstack/react-router": "^1.109.2",
|
||||
"@trpc/client": "10.45.2",
|
||||
"@trpc/server": "10.45.2",
|
||||
"@trustwallet/wallet-core": "^4.2.10",
|
||||
"@trustwallet/wallet-core": "^4.3.6",
|
||||
"@wxt-dev/storage": "^1.1.0",
|
||||
"@zxcvbn-ts/core": "^3.0.4",
|
||||
"@zxcvbn-ts/language-common": "^3.0.4",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useToast } from '@status-im/components'
|
||||
import { NegativeStateIcon } from '@status-im/icons/20'
|
||||
import {
|
||||
RecoveryPhraseDialog,
|
||||
SignTransactionDialog,
|
||||
} from '@status-im/wallet/components'
|
||||
|
||||
import { useRecoveryPhraseBackup } from '@/hooks/use-recovery-phrase-backup'
|
||||
import { apiClient } from '@/providers/api-client'
|
||||
import { useWallet } from '@/providers/wallet-context'
|
||||
|
||||
export function RecoveryPhraseBackup() {
|
||||
const { currentWallet } = useWallet()
|
||||
const toast = useToast()
|
||||
|
||||
const [mnemonic, setMnemonic] = useState<string | null>(null)
|
||||
const {
|
||||
isRecoveryPhraseBackedUp,
|
||||
showRecoveryDialog,
|
||||
setShowRecoveryDialog,
|
||||
markAsBackedUp,
|
||||
} = useRecoveryPhraseBackup()
|
||||
|
||||
const onPasswordConfirm = async (password: string) => {
|
||||
if (!currentWallet?.id) {
|
||||
toast.negative('No wallet selected', { duration: 3000 })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { mnemonic } = await apiClient.wallet.get.query({
|
||||
password: password,
|
||||
walletId: currentWallet?.id,
|
||||
})
|
||||
setMnemonic(mnemonic)
|
||||
setShowRecoveryDialog(true)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.negative('Invalid password', {
|
||||
duration: 3000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onRecoveryClose = () => {
|
||||
setShowRecoveryDialog(false)
|
||||
}
|
||||
|
||||
const onComplete = async () => {
|
||||
await markAsBackedUp()
|
||||
toast.positive(
|
||||
'Your recovery phrase has been deleted from the wallet interface.',
|
||||
{ duration: 3000 },
|
||||
)
|
||||
}
|
||||
|
||||
if (isRecoveryPhraseBackedUp) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SignTransactionDialog
|
||||
onConfirm={onPasswordConfirm}
|
||||
description="To backup recovery phrase"
|
||||
>
|
||||
<button className="flex gap-1 rounded-20 border border-solid border-danger-50/20 bg-danger-50/10 px-2 py-[3px] text-13 text-danger-50">
|
||||
<NegativeStateIcon className="text-danger-50/20" /> Backup recovery
|
||||
phrase
|
||||
</button>
|
||||
</SignTransactionDialog>
|
||||
|
||||
<RecoveryPhraseDialog
|
||||
isOpen={showRecoveryDialog}
|
||||
onClose={onRecoveryClose}
|
||||
onComplete={onComplete}
|
||||
mnemonic={mnemonic}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Avatar } from '@status-im/components'
|
||||
import { Balance, StickyHeaderContainer } from '@status-im/wallet/components'
|
||||
|
||||
import { RecoveryPhraseBackup } from '../components/recovery-phrase-backup'
|
||||
|
||||
type Props = {
|
||||
list: React.ReactNode
|
||||
detail?: React.ReactNode
|
||||
@@ -139,6 +141,10 @@ const SplittedLayout = (props: Props) => {
|
||||
</div>
|
||||
|
||||
<ActionButtons {...actionsButtonsData} />
|
||||
|
||||
<div className="my-4 flex">
|
||||
<RecoveryPhraseBackup />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{list}
|
||||
@@ -149,9 +155,7 @@ const SplittedLayout = (props: Props) => {
|
||||
</div>
|
||||
|
||||
<div className="relative hidden basis-1/2 flex-col bg-white-100 2xl:flex">
|
||||
<div className="relative z-20 flex h-full items-center justify-center">
|
||||
{detail}
|
||||
</div>
|
||||
<div className="relative z-20 size-full">{detail}</div>
|
||||
|
||||
<div
|
||||
className="absolute z-10 size-full"
|
||||
|
||||
@@ -66,7 +66,7 @@ const apiRouter = router({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { walletCore, keyStore } = ctx
|
||||
|
||||
const wallet = walletCore.HDWallet.create(256, input.password)
|
||||
const wallet = walletCore.HDWallet.create(128, input.password)
|
||||
const mnemonic = wallet.mnemonic()
|
||||
const name = input.name
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export const useRecoveryPhraseBackup = () => {
|
||||
const [isRecoveryPhraseBackedUp, setIsRecoveryPhraseBackedUp] =
|
||||
useState<boolean>(true)
|
||||
const [showRecoveryDialog, setShowRecoveryDialog] = useState<boolean>(false)
|
||||
|
||||
const markAsBackedUp = async () => {
|
||||
setIsRecoveryPhraseBackedUp(true)
|
||||
|
||||
await chrome.storage.local.set({ recoveryPhraseBackedUp: true })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function checkSettings() {
|
||||
const { recoveryPhraseBackedUp } = await chrome.storage.local.get([
|
||||
'recoveryPhraseBackedUp',
|
||||
])
|
||||
if (recoveryPhraseBackedUp) {
|
||||
setIsRecoveryPhraseBackedUp(true)
|
||||
return
|
||||
}
|
||||
|
||||
setIsRecoveryPhraseBackedUp(false)
|
||||
}
|
||||
|
||||
checkSettings()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isRecoveryPhraseBackedUp,
|
||||
markAsBackedUp,
|
||||
showRecoveryDialog,
|
||||
setShowRecoveryDialog,
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,11 @@ export { Navbar } from './nav-bar'
|
||||
export { NetworkBreakdown } from './network-breakdown'
|
||||
export { NetworkExplorerLogo } from './network-explorer-logo'
|
||||
export { NetworkLogo } from './network-logo'
|
||||
export { PasswordInput } from './password-input'
|
||||
export { PercentageChange } from './percentage-change'
|
||||
export { PinExtension } from './pin-extension'
|
||||
export { ReceiveCryptoDrawer } from './receive-crypto-drawer'
|
||||
export { RecoveryPhraseDialog } from './recovery-phrase-dialog'
|
||||
export { RecoveryPhraseTextarea } from './recovery-phrase-textarea'
|
||||
export { SettingsPopover } from './settings-popover'
|
||||
export {
|
||||
@@ -34,6 +36,7 @@ export {
|
||||
shortenAddress,
|
||||
type ShortenAddressProps,
|
||||
} from './shorten-address'
|
||||
export { SignTransactionDialog } from './sign-transaction'
|
||||
export { Slider, type SliderProps } from './slider'
|
||||
export { StickyHeaderContainer } from './sticky-header-container'
|
||||
export { getTabLinkClassName, TabLink } from './tab-link'
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
import { CloseIcon } from '@status-im/icons/20'
|
||||
|
||||
import { RecoveryPhraseBox } from './recovery-phrase-box'
|
||||
import { RecoveryPhraseConfirmation } from './recovery-phrase-confirmation'
|
||||
|
||||
type RecoveryPhraseDialogProps = {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onComplete: () => void
|
||||
mnemonic: string | null
|
||||
}
|
||||
|
||||
export const RecoveryPhraseDialog = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onComplete,
|
||||
mnemonic,
|
||||
}: RecoveryPhraseDialogProps) => {
|
||||
if (!mnemonic) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={onClose}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-20 bg-blur-neutral-100/70 opacity-[30%] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-[0%] data-[state=open]:fade-in-[0%]" />
|
||||
<Dialog.Content
|
||||
data-customisation="blue"
|
||||
className="fixed left-1/2 top-1/2 z-50 flex max-h-[calc(100vh-32px)] w-full max-w-[448px] -translate-x-1/2 -translate-y-1/2 flex-col overflow-y-auto rounded-20 bg-white-100 p-4 pb-0 shadow-1 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-[0%] data-[state=open]:fade-in-[0%] data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]"
|
||||
>
|
||||
<Dialog.Close className="absolute right-3 top-3 z-40 rounded-10 border border-neutral-30 p-1.5 transition-colors active:border-neutral-50 hover:border-neutral-40 disabled:pointer-events-none">
|
||||
<span className="sr-only">Close</span>
|
||||
<CloseIcon className="text-neutral-100" />
|
||||
</Dialog.Close>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Dialog.Title className="text-27 font-600">
|
||||
Backup recovery phrase
|
||||
</Dialog.Title>
|
||||
|
||||
<RecoveryPhraseBox mnemonic={mnemonic} />
|
||||
|
||||
<div className="flex flex-col gap-4 text-15 text-neutral-100">
|
||||
<p>
|
||||
<strong>What is a Recovery Phrase?</strong>
|
||||
<br />A 12-word phrase that gives full access to your funds and
|
||||
is the only way to recover them.
|
||||
</p>
|
||||
<p>
|
||||
<strong>How do I save my Recovery Phrase?</strong>
|
||||
<br />
|
||||
Write it down and store it securely in a safe place.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Should I share my Recovery Phrase?</strong>
|
||||
<br />
|
||||
Never share your Secret Recovery Phrase. If someone asks for it,
|
||||
they're likely trying to scam you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RecoveryPhraseConfirmation onConfirm={onComplete} />
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Counter } from '@status-im/components'
|
||||
import { cx } from 'class-variance-authority'
|
||||
|
||||
type Props = {
|
||||
mnemonic: string
|
||||
}
|
||||
|
||||
function RecoveryPhraseBox({ mnemonic }: Props) {
|
||||
return (
|
||||
<div className="mt-10 grid grid-cols-2 gap-x-4 rounded-16 bg-neutral-2.5 px-3 py-2">
|
||||
{mnemonic.split(' ').map((word, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cx(
|
||||
'flex items-center gap-2 border-dashed py-2',
|
||||
i % 2 === 0 ? 'border-r border-neutral-80/10' : '',
|
||||
)}
|
||||
>
|
||||
<Counter value={i + 1} variant="grey" />
|
||||
<span className="font-mono">{word}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { RecoveryPhraseBox }
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button, Checkbox } from '@status-im/components'
|
||||
|
||||
type Props = {
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
function RecoveryPhraseConfirmation({ onConfirm }: Props) {
|
||||
const [isChecked, setIsChecked] = useState(false)
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticky bottom-0 mt-auto flex flex-col gap-4 border-t border-neutral-10 bg-white-100 py-4">
|
||||
<div className="flex items-center gap-2 text-15 text-neutral-100">
|
||||
<label
|
||||
htmlFor="recovery-phrase-backed"
|
||||
className="flex cursor-pointer select-none items-start gap-2 text-15"
|
||||
>
|
||||
<div className="mt-[3px]">
|
||||
<Checkbox
|
||||
id="recovery-phrase-backed"
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => setIsChecked(!isChecked)}
|
||||
/>
|
||||
</div>
|
||||
I've backed up my recovery phrase and understand that clicking “Finish
|
||||
Backup” will completely and permanently delete it from this wallet
|
||||
interface.
|
||||
</label>
|
||||
</div>
|
||||
<Button variant="primary" disabled={!isChecked} onClick={handleConfirm}>
|
||||
Finish backup
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { RecoveryPhraseConfirmation }
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
|
||||
import * as AlertDialog from '@radix-ui/react-alert-dialog'
|
||||
import { Button } from '@status-im/components'
|
||||
import { CloseIcon, LoadingIcon } from '@status-im/icons/20'
|
||||
import { FormProvider, useForm } from 'react-hook-form'
|
||||
|
||||
import { PasswordInput } from '../password-input'
|
||||
|
||||
type Props = {
|
||||
onConfirm: (password: string) => void
|
||||
children: React.ReactNode
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function SignTransactionDialog({
|
||||
children,
|
||||
onConfirm,
|
||||
description = 'To sign transaction',
|
||||
}: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const handleConfirm = async (data: { password: string }) => {
|
||||
try {
|
||||
startTransition(async () => {
|
||||
onConfirm(data.password)
|
||||
setIsOpen(false)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-blur-neutral-100/70 opacity-[30%] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-[0%] data-[state=open]:fade-in-[0%]" />
|
||||
<AlertDialog.Trigger asChild>{children}</AlertDialog.Trigger>
|
||||
|
||||
<AlertDialog.Content className="fixed left-1/2 top-1/2 z-50 flex min-h-[250px] w-full max-w-[440px] flex-1 -translate-x-1/2 -translate-y-1/2 flex-col rounded-20 bg-white-100 p-4 shadow-1 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-[0%] data-[state=open]:fade-in-[0%] data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]">
|
||||
<AlertDialog.Cancel className="absolute right-3 top-3 z-40 rounded-10 border border-neutral-30 p-1.5 transition-colors active:border-neutral-50 hover:border-neutral-40 disabled:pointer-events-none">
|
||||
<span className="sr-only">Close</span>
|
||||
<CloseIcon className="text-neutral-100" />
|
||||
</AlertDialog.Cancel>
|
||||
<div className="flex flex-col gap-1 pb-4">
|
||||
<AlertDialog.Title className="text-27 font-600 text-neutral-100">
|
||||
Enter password
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description className="text-15 text-neutral-50">
|
||||
{description}
|
||||
</AlertDialog.Description>
|
||||
</div>
|
||||
<SignTransactionForm onConfirm={handleConfirm} loading={isPending} />
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
)
|
||||
}
|
||||
|
||||
type SignTransactionFormProps = {
|
||||
onConfirm: (data: { password: string }) => Promise<void>
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
function SignTransactionForm({ onConfirm, loading }: SignTransactionFormProps) {
|
||||
const form = useForm({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
password: '',
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = async (data: { password: string }) => {
|
||||
await onConfirm(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="flex flex-1 flex-col"
|
||||
>
|
||||
<PasswordInput
|
||||
name="password"
|
||||
label="Password"
|
||||
placeholder="Type password"
|
||||
/>
|
||||
<div className="mt-auto flex flex-col">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!form.watch('password') || loading}
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingIcon className="animate-spin text-white-100" />
|
||||
) : (
|
||||
'Confirm'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
Generated
+5
-5
@@ -886,8 +886,8 @@ importers:
|
||||
specifier: 10.45.2
|
||||
version: 10.45.2
|
||||
'@trustwallet/wallet-core':
|
||||
specifier: ^4.2.10
|
||||
version: 4.3.3
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
'@wxt-dev/storage':
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.1
|
||||
@@ -7764,8 +7764,8 @@ packages:
|
||||
'@trpc/server@10.45.2':
|
||||
resolution: {integrity: sha512-wOrSThNNE4HUnuhJG6PfDRp4L2009KDVxsd+2VYH8ro6o/7/jwYZ8Uu5j+VaW+mOmc8EHerHzGcdbGNQSAUPgg==}
|
||||
|
||||
'@trustwallet/wallet-core@4.3.3':
|
||||
resolution: {integrity: sha512-42FXVM8NVcqvYSxs6uf1sWCd02u3aNUY26X5jwOWrGy9F1WBz89bkhbb4Kion0i3OxS8ImEHcNhsVvVt8bd2fA==}
|
||||
'@trustwallet/wallet-core@4.3.6':
|
||||
resolution: {integrity: sha512-X+n2CzDhIfUtnQtqqM3Su6XmBdzUijMu8uQEnA9yQWfOv7d33LfaQ9vNbzvpFk4I52K7n6AWLaylM1unuIuTJQ==}
|
||||
|
||||
'@tryghost/content-api@1.11.22':
|
||||
resolution: {integrity: sha512-sssy/4kV8P03GWhw63HTNTZao5OMamEjI3NjzGoCRK4B5U4V9yjF/WuMDSdhMWXLtx0rIcK5uFP8tgiArM86UA==}
|
||||
@@ -26930,7 +26930,7 @@ snapshots:
|
||||
|
||||
'@trpc/server@10.45.2': {}
|
||||
|
||||
'@trustwallet/wallet-core@4.3.3':
|
||||
'@trustwallet/wallet-core@4.3.6':
|
||||
dependencies:
|
||||
protobufjs: 7.2.5
|
||||
|
||||
|
||||
Reference in New Issue
Block a user