diff --git a/packages/ui/public/locales/en/translation.json b/packages/ui/public/locales/en/translation.json index 03232e56..b14c9a0f 100644 --- a/packages/ui/public/locales/en/translation.json +++ b/packages/ui/public/locales/en/translation.json @@ -33,16 +33,19 @@ "Finally, back up your secret recovery phrase": "", "Finish": "", "First, choose your wallet password": "", + "First, enter your secret recovery phrase": "", "I have backed up my recovery phrase": "", "If you open this page by accident, it's safe to close it now.": "", "InternalError": "Internal error", "Invalid Request": "", "Invalid request": "", + "Invalid secret recovery phrase": "", "InvalidMessageFormat": "Invalid message format", "KeypairNotFound": "Keypair not found", "KeyringLocked": "The keyring is locked, please unlock the wallet first", "KeyringNotInitialized": "Keyring is not initialized", "Language": "", + "Lastly, confirm your wallet password": "", "Light": "", "Lock the wallet": "", "Make sure you are in a safe place.": "", @@ -50,6 +53,7 @@ "New Account": "", "New account name": "", "Next": "", + "Next, choose your wallet password": "", "Next, confirm your wallet password": "", "No accounts found in wallet": "", "No accounts meet search query:": "", @@ -61,8 +65,10 @@ "PasswordIncorrect": "Password incorrect", "PasswordRequired": "Password required", "Reset wallet": "", - "Restore existing wallet": "", + "Restore Existing Wallet": "", "Search by name": "", + "Secret recovery phrase": "", + "Secret recovery phrase or password are missing": "", "Select all": "", "Select the accounts you'd like to connect": "", "Set up new wallet": "", diff --git a/packages/ui/src/components/pages/NewWallet/index.tsx b/packages/ui/src/components/pages/NewWallet/index.tsx deleted file mode 100644 index 96625e5f..00000000 --- a/packages/ui/src/components/pages/NewWallet/index.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { FC } from 'react'; -import { useSelector } from 'react-redux'; -import { useNavigate } from 'react-router-dom'; -import { useEffectOnce } from 'react-use'; -import BackupSecretRecoveryPhrase from 'components/pages/NewWallet/BackupSecretRecoveryPhrase'; -import ChooseWalletPassword from 'components/pages/NewWallet/ChooseWalletPassword'; -import ConfirmWalletPassword from 'components/pages/NewWallet/ConfirmWalletPassword'; -import { NewWalletScreenStep } from 'components/pages/NewWallet/types'; -import { useWalletState } from 'providers/WalletStateProvider'; -import { RootState } from 'redux/store'; -import { Props } from 'types'; - -interface NewWalletProps extends Props { - onWalletSetup?: () => void; -} - -const ScreenStep: FC = ({ onWalletSetup }) => { - const { newWalletScreenStep } = useSelector((state: RootState) => state.setupWallet); - - switch (newWalletScreenStep) { - case NewWalletScreenStep.ConfirmWalletPassword: - return ; - case NewWalletScreenStep.BackupSecretRecoveryPhrase: - return ; - default: - return ; - } -}; - -const NewWallet: FC = ({ className = '', onWalletSetup }) => { - const { keyring } = useWalletState(); - const navigate = useNavigate(); - - useEffectOnce(() => { - keyring.initialized().then((initialized) => { - if (initialized) { - if (onWalletSetup) { - onWalletSetup(); - } else { - navigate('/'); - } - } - }); - }); - - return ( -
- -
- ); -}; - -export default NewWallet; diff --git a/packages/ui/src/components/pages/NewWallet/types.ts b/packages/ui/src/components/pages/NewWallet/types.ts deleted file mode 100644 index e1c35d44..00000000 --- a/packages/ui/src/components/pages/NewWallet/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum NewWalletScreenStep { - ChooseWalletPassword, - ConfirmWalletPassword, - BackupSecretRecoveryPhrase, -} diff --git a/packages/ui/src/components/pages/Request/RequestAccess/SetupWalletButton.tsx b/packages/ui/src/components/pages/Request/RequestAccess/SetupWalletButton.tsx index fb261dfa..bbcc496f 100644 --- a/packages/ui/src/components/pages/Request/RequestAccess/SetupWalletButton.tsx +++ b/packages/ui/src/components/pages/Request/RequestAccess/SetupWalletButton.tsx @@ -4,10 +4,9 @@ import { useDispatch } from 'react-redux'; import { useToggle } from 'react-use'; import { Close } from '@mui/icons-material'; import { AppBar, Button, Container, Dialog, DialogContent, IconButton, Toolbar } from '@mui/material'; -import { NewWalletScreenStep } from 'components/pages/NewWallet/types'; import SetupWalletDialogContent from 'components/pages/Request/RequestAccess/SetupWalletDialogContent'; import { setupWalletActions } from 'redux/slices/setup-wallet'; -import { Props } from 'types'; +import { Props, NewWalletScreenStep, RestoreWalletScreenStep } from 'types'; const SetupWalletButton: FC = ({ className = '' }) => { const [open, toggleOpen] = useToggle(false); @@ -20,7 +19,7 @@ const SetupWalletButton: FC = ({ className = '' }) => { const doClose = () => { toggleOpen(false); - dispatch(setupWalletActions.setStep(NewWalletScreenStep.ChooseWalletPassword)); + dispatch(setupWalletActions.resetState()); }; return ( diff --git a/packages/ui/src/components/pages/Request/RequestAccess/SetupWalletDialogContent.tsx b/packages/ui/src/components/pages/Request/RequestAccess/SetupWalletDialogContent.tsx index 240b1953..da8d7d65 100644 --- a/packages/ui/src/components/pages/Request/RequestAccess/SetupWalletDialogContent.tsx +++ b/packages/ui/src/components/pages/Request/RequestAccess/SetupWalletDialogContent.tsx @@ -1,5 +1,6 @@ import { FC, useState } from 'react'; -import NewWallet from 'components/pages/NewWallet'; +import NewWallet from 'components/pages/SetupWallet/NewWallet'; +import RestoreWallet from 'components/pages/SetupWallet/RestoreWallet'; import Welcome from 'components/pages/Welcome'; import { Props } from 'types'; @@ -12,11 +13,20 @@ enum ViewStep { const SetupWalletDialogContent: FC = () => { const [viewStep, setViewStep] = useState(ViewStep.WELCOME); + const onWalletSetup = () => {}; + switch (viewStep) { case ViewStep.CREATE_NEW_WALLET: - return {}} />; + return ; + case ViewStep.RESTORE_WALLET: + return ; default: - return setViewStep(ViewStep.CREATE_NEW_WALLET)} />; + return ( + setViewStep(ViewStep.CREATE_NEW_WALLET)} + onRestoreExistingWallet={() => setViewStep(ViewStep.RESTORE_WALLET)} + /> + ); } }; diff --git a/packages/ui/src/components/pages/Request/RequestAccess/__tests__/RequestAccess.spec.tsx b/packages/ui/src/components/pages/Request/RequestAccess/__tests__/RequestAccess.spec.tsx index 216bae4e..59143566 100644 --- a/packages/ui/src/components/pages/Request/RequestAccess/__tests__/RequestAccess.spec.tsx +++ b/packages/ui/src/components/pages/Request/RequestAccess/__tests__/RequestAccess.spec.tsx @@ -200,8 +200,8 @@ describe('RequestAccess', () => { expect(await screen.findByRole('dialog')).toBeVisible(); expect(await screen.findByText(/Set up new wallet/)).toBeVisible(); - expect(await screen.findByRole('button', { name: /Create New Wallet/ })).toBeVisible(); - expect(await screen.findByRole('button', { name: /Restore Existing Wallet/ })).toBeDisabled(); + expect(await screen.findByRole('button', { name: /Create New Wallet/ })).toBeEnabled(); + expect(await screen.findByRole('button', { name: /Restore Existing Wallet/ })).toBeEnabled(); }); }); }); diff --git a/packages/ui/src/components/pages/NewWallet/ChooseWalletPassword.tsx b/packages/ui/src/components/pages/SetupWallet/ChooseWalletPassword.tsx similarity index 70% rename from packages/ui/src/components/pages/NewWallet/ChooseWalletPassword.tsx rename to packages/ui/src/components/pages/SetupWallet/ChooseWalletPassword.tsx index b0276d4c..a3b8920a 100644 --- a/packages/ui/src/components/pages/NewWallet/ChooseWalletPassword.tsx +++ b/packages/ui/src/components/pages/SetupWallet/ChooseWalletPassword.tsx @@ -6,7 +6,12 @@ import EmptySpace from 'components/shared/misc/EmptySpace'; import { setupWalletActions } from 'redux/slices/setup-wallet'; import { Props } from 'types'; -const ChooseWalletPassword: FC = ({ className = '' }: Props) => { +interface ChooseWalletPasswordProps extends Props { + nextStep: () => void; + prevStep?: () => void; +} + +const ChooseWalletPassword: FC = ({ className = '', nextStep, prevStep }) => { const dispatch = useDispatch(); const [password, setPassword] = useState(''); const [validation, setValidation] = useState(''); @@ -28,6 +33,8 @@ const ChooseWalletPassword: FC = ({ className = '' }: Props) => { } dispatch(setupWalletActions.setPassword(password)); + + nextStep && nextStep(); }; const handleChange = (event: ChangeEvent) => { @@ -36,7 +43,9 @@ const ChooseWalletPassword: FC = ({ className = '' }: Props) => { return (
-

{t('First, choose your wallet password')}

+

+ {!prevStep ? t('First, choose your wallet password') : t('Next, choose your wallet password')} +

Your password will be used to encrypt accounts as well as unlock the wallet, make sure to pick a strong & @@ -55,9 +64,16 @@ const ChooseWalletPassword: FC = ({ className = '' }: Props) => { error={!!validation} helperText={validation || } /> - +

+ {prevStep && ( + + )} + +
); diff --git a/packages/ui/src/components/pages/NewWallet/ConfirmWalletPassword.tsx b/packages/ui/src/components/pages/SetupWallet/ConfirmWalletPassword.tsx similarity index 66% rename from packages/ui/src/components/pages/NewWallet/ConfirmWalletPassword.tsx rename to packages/ui/src/components/pages/SetupWallet/ConfirmWalletPassword.tsx index 6d0425b7..44de8c50 100644 --- a/packages/ui/src/components/pages/NewWallet/ConfirmWalletPassword.tsx +++ b/packages/ui/src/components/pages/SetupWallet/ConfirmWalletPassword.tsx @@ -1,15 +1,26 @@ import { ChangeEvent, FC, FormEvent, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; +import { LoadingButton } from '@mui/lab'; import { Button, TextField } from '@mui/material'; -import { NewWalletScreenStep } from 'components/pages/NewWallet/types'; import EmptySpace from 'components/shared/misc/EmptySpace'; -import { setupWalletActions } from 'redux/slices/setup-wallet'; import { RootState } from 'redux/store'; import { Props } from 'types'; -const ConfirmWalletPassword: FC = ({ className = '' }: Props) => { - const dispatch = useDispatch(); +interface ConfirmWalletPasswordProps extends Props { + nextStep: () => void; + nextStepLabel?: string; + nextStepLoading?: boolean; + prevStep: () => void; +} + +const ConfirmWalletPassword: FC = ({ + className = '', + nextStep, + nextStepLabel = 'Next', + nextStepLoading, + prevStep, +}) => { const { password } = useSelector((state: RootState) => state.setupWallet); const [passwordConfirmation, setPasswordConfirmation] = useState(''); const [notMatch, setNotMatch] = useState(true); @@ -26,11 +37,7 @@ const ConfirmWalletPassword: FC = ({ className = '' }: Props) => { return; } - dispatch(setupWalletActions.setStep(NewWalletScreenStep.BackupSecretRecoveryPhrase)); - }; - - const back = () => { - dispatch(setupWalletActions.setStep(NewWalletScreenStep.ChooseWalletPassword)); + nextStep && nextStep(); }; const handleChange = (event: ChangeEvent) => { @@ -39,7 +46,11 @@ const ConfirmWalletPassword: FC = ({ className = '' }: Props) => { return (
-

{t('Next, confirm your wallet password')}

+

+ {nextStepLabel === 'Next' + ? t('Next, confirm your wallet password') + : t('Lastly, confirm your wallet password')} +

{t('Type again your chosen password to ensure you remember it.')}

@@ -54,12 +65,18 @@ const ConfirmWalletPassword: FC = ({ className = '' }: Props) => { helperText={!!passwordConfirmation && notMatch ? t('Password does not match') : } />
- - + + {t(nextStepLabel)} +
diff --git a/packages/ui/src/components/pages/NewWallet/BackupSecretRecoveryPhrase.tsx b/packages/ui/src/components/pages/SetupWallet/NewWallet/BackupSecretRecoveryPhrase.tsx similarity index 69% rename from packages/ui/src/components/pages/NewWallet/BackupSecretRecoveryPhrase.tsx rename to packages/ui/src/components/pages/SetupWallet/NewWallet/BackupSecretRecoveryPhrase.tsx index 6b811584..5a083ecb 100644 --- a/packages/ui/src/components/pages/NewWallet/BackupSecretRecoveryPhrase.tsx +++ b/packages/ui/src/components/pages/SetupWallet/NewWallet/BackupSecretRecoveryPhrase.tsx @@ -1,31 +1,26 @@ import { ChangeEvent, FC, FormEvent, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; -import { useNavigate } from 'react-router-dom'; import { useEffectOnce } from 'react-use'; import { generateMnemonic } from '@polkadot/util-crypto/mnemonic/bip39'; import { LoadingButton } from '@mui/lab'; -import { Button, Checkbox, FormControlLabel, FormGroup, styled } from '@mui/material'; -import { NewWalletScreenStep } from 'components/pages/NewWallet/types'; -import { useWalletState } from 'providers/WalletStateProvider'; -import { appActions } from 'redux/slices/app'; +import { Button, Checkbox, FormControlLabel, FormGroup } from '@mui/material'; +import useSetupWallet from 'hooks/wallet/useSetupWallet'; import { setupWalletActions } from 'redux/slices/setup-wallet'; import { RootState } from 'redux/store'; -import { Props } from 'types'; +import { Props, NewWalletScreenStep } from 'types'; interface BackupSecretRecoveryPhraseProps extends Props { onWalletSetup?: () => void; } const BackupSecretRecoveryPhrase: FC = ({ className = '', onWalletSetup }) => { - const { keyring } = useWalletState(); const dispatch = useDispatch(); - const navigate = useNavigate(); const { password } = useSelector((state: RootState) => state.setupWallet); const [checked, setChecked] = useState(false); - const [loading, setLoading] = useState(); const [secretPhrase, setSecretPhrase] = useState(); const { t } = useTranslation(); + const { setup, loading } = useSetupWallet({ secretPhrase, password, onWalletSetup }); useEffectOnce(() => { setSecretPhrase(generateMnemonic(12)); @@ -34,29 +29,11 @@ const BackupSecretRecoveryPhrase: FC = ({ class const doSetupWallet = (e: FormEvent) => { e.preventDefault(); - setLoading(true); - - setTimeout(async () => { - if (!password) { - return; - } - - await keyring.initialize(secretPhrase!, password); - await keyring.createNewAccount(t('My first account'), password); - - dispatch(appActions.seedReady()); - dispatch(appActions.unlock()); - - if (onWalletSetup) { - onWalletSetup(); - } else { - navigate('/'); - } - }, 500); // intentionally! + setup(); }; const back = () => { - dispatch(setupWalletActions.setStep(NewWalletScreenStep.ChooseWalletPassword)); + dispatch(setupWalletActions.setNewWalletScreenStep(NewWalletScreenStep.ChooseWalletPassword)); }; const handleCheckbox = (event: ChangeEvent) => { diff --git a/packages/ui/src/components/pages/NewWallet/__tests__/NewWallet.spec.tsx b/packages/ui/src/components/pages/SetupWallet/NewWallet/__tests__/NewWallet.spec.tsx similarity index 99% rename from packages/ui/src/components/pages/NewWallet/__tests__/NewWallet.spec.tsx rename to packages/ui/src/components/pages/SetupWallet/NewWallet/__tests__/NewWallet.spec.tsx index 3585296a..bae15072 100644 --- a/packages/ui/src/components/pages/NewWallet/__tests__/NewWallet.spec.tsx +++ b/packages/ui/src/components/pages/SetupWallet/NewWallet/__tests__/NewWallet.spec.tsx @@ -1,7 +1,7 @@ import { UserEvent } from '@testing-library/user-event/setup/setup'; import { initializeKeyring, newUser, render, screen, waitFor } from '__tests__/testUtils'; +import { NewWalletScreenStep } from 'types'; import NewWallet from '../index'; -import { NewWalletScreenStep } from '../types'; const navigate = vi.fn(); diff --git a/packages/ui/src/components/pages/SetupWallet/NewWallet/index.tsx b/packages/ui/src/components/pages/SetupWallet/NewWallet/index.tsx new file mode 100644 index 00000000..d93cb39e --- /dev/null +++ b/packages/ui/src/components/pages/SetupWallet/NewWallet/index.tsx @@ -0,0 +1,48 @@ +import { FC } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import ChooseWalletPassword from 'components/pages/SetupWallet/ChooseWalletPassword'; +import ConfirmWalletPassword from 'components/pages/SetupWallet/ConfirmWalletPassword'; +import BackupSecretRecoveryPhrase from 'components/pages/SetupWallet/NewWallet/BackupSecretRecoveryPhrase'; +import useOnWalletInitialized from 'hooks/wallet/useOnWalletInitialized'; +import { setupWalletActions } from 'redux/slices/setup-wallet'; +import { RootState } from 'redux/store'; +import { Props, NewWalletScreenStep } from 'types'; + +interface NewWalletProps extends Props { + onWalletSetup?: () => void; +} + +const ScreenStep: FC = ({ onWalletSetup }) => { + const dispatch = useDispatch(); + const { newWalletScreenStep } = useSelector((state: RootState) => state.setupWallet); + + const goto = (step: NewWalletScreenStep) => { + return () => dispatch(setupWalletActions.setNewWalletScreenStep(step)); + }; + + switch (newWalletScreenStep) { + case NewWalletScreenStep.ConfirmWalletPassword: + return ( + + ); + case NewWalletScreenStep.BackupSecretRecoveryPhrase: + return ; + default: + return ; + } +}; + +const NewWallet: FC = ({ className = '', onWalletSetup }) => { + useOnWalletInitialized(onWalletSetup); + + return ( +
+ +
+ ); +}; + +export default NewWallet; diff --git a/packages/ui/src/components/pages/SetupWallet/RestoreWallet/ImportSecretRecoveryPhrase.tsx b/packages/ui/src/components/pages/SetupWallet/RestoreWallet/ImportSecretRecoveryPhrase.tsx new file mode 100644 index 00000000..177a35c0 --- /dev/null +++ b/packages/ui/src/components/pages/SetupWallet/RestoreWallet/ImportSecretRecoveryPhrase.tsx @@ -0,0 +1,67 @@ +import { ChangeEvent, FormEvent, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; +import { validateMnemonic } from '@polkadot/util-crypto/mnemonic/bip39'; +import { Button, TextField } from '@mui/material'; +import EmptySpace from 'components/shared/misc/EmptySpace'; +import { setupWalletActions } from 'redux/slices/setup-wallet'; +import { Props, RestoreWalletScreenStep } from 'types'; + +export default function ImportSecretRecoveryPhrase({ className = '' }: Props): JSX.Element { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const [secretPhrase, setSecretPhrase] = useState(''); + const [validation, setValidation] = useState(''); + + useEffect(() => { + if (!secretPhrase) { + setValidation(''); + } else { + if (!validateMnemonic(secretPhrase)) { + setValidation(t('Invalid secret recovery phrase')); + } else { + setValidation(''); + } + } + }, [secretPhrase]); + + const next = (e: FormEvent) => { + e.preventDefault(); + if (!secretPhrase) { + return; + } + + dispatch(setupWalletActions.setSecretPhrase(secretPhrase)); + dispatch(setupWalletActions.setRestoreWalletScreenStep(RestoreWalletScreenStep.ChooseWalletPassword)); + }; + + const handleChange = (event: ChangeEvent) => { + setSecretPhrase(event.target.value); + }; + + return ( +
+

{t('First, enter your secret recovery phrase')}

+

+ Please enter your secret recovery phrase (12 or 24 words), make sure no-one can see you entering the recovery + phrase. +

+
+ ('Secret recovery phrase')} + fullWidth + autoFocus + multiline + rows={4} + onChange={handleChange} + value={secretPhrase} + error={!!validation} + helperText={validation || } + /> + + +
+ ); +} diff --git a/packages/ui/src/components/pages/SetupWallet/RestoreWallet/__tests__/RestoreWallet.spec.tsx b/packages/ui/src/components/pages/SetupWallet/RestoreWallet/__tests__/RestoreWallet.spec.tsx new file mode 100644 index 00000000..1f82fbc0 --- /dev/null +++ b/packages/ui/src/components/pages/SetupWallet/RestoreWallet/__tests__/RestoreWallet.spec.tsx @@ -0,0 +1,188 @@ +import { generateMnemonic } from '@polkadot/util-crypto/mnemonic/bip39'; +import { UserEvent } from '@testing-library/user-event/setup/setup'; +import { initializeKeyring, newUser, render, screen, waitFor } from '__tests__/testUtils'; +import { RestoreWalletScreenStep } from 'types'; +import RestoreWallet from '../index'; + +const navigate = vi.fn(); + +vi.mock('react-router-dom', async () => { + const reactRouter: any = await vi.importActual('react-router-dom'); + + return { ...reactRouter, useNavigate: () => navigate }; +}); + +beforeEach(() => { + navigate.mockReset(); +}); + +describe('RestoreWallet', () => { + let user: UserEvent, randomSecretPhrase: string; + beforeEach(() => { + user = newUser(); + randomSecretPhrase = generateMnemonic(12); + }); + + describe('keyring is initialized', () => { + beforeEach(() => { + initializeKeyring(); + }); + + it('should navigate to homepage', async () => { + render(); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith('/'); + }); + }); + + it('should trigger onWalletSetup callback', async () => { + const onWalletSetup = vi.fn(); + render(); + + await waitFor(() => { + expect(onWalletSetup).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('keyring is not initialized', () => { + describe('EnterSecretRecoveryPhrase', () => { + beforeEach(() => { + render(); + }); + + it('should render the page correctly', async () => { + expect(await screen.findByText('First, enter your secret recovery phrase')).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /Next/ })).toBeDisabled(); + }); + + it('should go to ChooseWalletPassword page', async () => { + const secretPhraseField = await screen.findByLabelText('Secret recovery phrase'); + await user.type(secretPhraseField, randomSecretPhrase); + + await user.click(await screen.findByRole('button', { name: /Next/ })); + + expect(await screen.findByText('Next, choose your wallet password')).toBeInTheDocument(); + }); + + it('should show error for invalid secret phrase', async () => { + const secretPhraseField = await screen.findByLabelText('Secret recovery phrase'); + await user.type(secretPhraseField, 'randomly secret phrase'); + + expect(await screen.findByText('Invalid secret recovery phrase')).toBeInTheDocument(); + }); + }); + + describe('ChooseWalletPassword', () => { + beforeEach(() => { + render(, { + preloadedState: { + setupWallet: { + restoreWalletScreenStep: RestoreWalletScreenStep.ChooseWalletPassword, + }, + }, + }); + }); + + it('should render the page correctly', async () => { + expect(await screen.findByText('Next, choose your wallet password')).toBeInTheDocument(); + const passwordField = await screen.findByLabelText('Wallet password'); + expect(passwordField).toBeEnabled(); + expect(passwordField).toHaveFocus(); + + expect(await screen.findByRole('button', { name: /Next/ })).toBeDisabled(); + }); + + it('should show error message if password is less than 6 chars', async () => { + const passwordField = await screen.findByLabelText('Wallet password'); + await user.type(passwordField, 'short'); + + expect(await screen.findByRole('button', { name: /Next/ })).toBeDisabled(); + expect(await screen.findByText("Password's too short")).toBeInTheDocument(); + }); + + it('should render ConfirmWalletPassword view after choosing password', async () => { + const passwordField = await screen.findByLabelText('Wallet password'); + await user.type(passwordField, 'valid-password'); + + const nextButton = await screen.findByRole('button', { name: /Next/ }); + expect(nextButton).toBeEnabled(); + await user.click(nextButton); + + expect(await screen.findByText('Lastly, confirm your wallet password')).toBeInTheDocument(); + }); + }); + + describe('ConfirmWalletPassword', () => { + const renderView = (onWalletSetup?: () => void) => { + render(, { + preloadedState: { + setupWallet: { + password: 'random-password', + secretPhrase: randomSecretPhrase, + restoreWalletScreenStep: RestoreWalletScreenStep.ConfirmWalletPassword, + }, + }, + }); + }; + + it('should render the page correctly', async () => { + renderView(); + expect(await screen.findByText('Lastly, confirm your wallet password')).toBeInTheDocument(); + + const passwordField = await screen.findByLabelText('Confirm wallet password'); + expect(passwordField).toBeEnabled(); + expect(passwordField).toHaveFocus(); + + expect(await screen.findByRole('button', { name: /Finish/ })).toBeDisabled(); + expect(await screen.findByRole('button', { name: /Back/ })).toBeEnabled(); + }); + + it('should go back to ChooseWalletPassword', async () => { + renderView(); + await user.click(await screen.findByRole('button', { name: /Back/ })); + + expect(await screen.findByText('Next, choose your wallet password')).toBeInTheDocument(); + }); + + it('should show error message if password does not match', async () => { + renderView(); + const passwordField = await screen.findByLabelText('Confirm wallet password'); + await user.type(passwordField, 'wrong-password'); + + expect(await screen.findByText('Password does not match')).toBeInTheDocument(); + }); + + it('should go to homepage(/) page after confirming the password', async () => { + renderView(); + const passwordField = await screen.findByLabelText('Confirm wallet password'); + await user.type(passwordField, 'random-password'); + + const finishButton = await screen.findByRole('button', { name: /Finish/ }); + expect(finishButton).toBeEnabled(); + await user.click(finishButton); + + await waitFor(() => { + expect(navigate).toBeCalledWith('/'); + }); + }); + + it('should trigger onWalletSetup callback after confirming password', async () => { + const onWalletSetup = vi.fn(); + renderView(onWalletSetup); + + const passwordField = await screen.findByLabelText('Confirm wallet password'); + await user.type(passwordField, 'random-password'); + + const finishButton = await screen.findByRole('button', { name: /Finish/ }); + expect(finishButton).toBeEnabled(); + await user.click(finishButton); + + await waitFor(() => { + expect(onWalletSetup).toBeCalled(); + }); + }); + }); + }); +}); diff --git a/packages/ui/src/components/pages/SetupWallet/RestoreWallet/index.tsx b/packages/ui/src/components/pages/SetupWallet/RestoreWallet/index.tsx new file mode 100644 index 00000000..d955a833 --- /dev/null +++ b/packages/ui/src/components/pages/SetupWallet/RestoreWallet/index.tsx @@ -0,0 +1,57 @@ +import React, { FC } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import ChooseWalletPassword from 'components/pages/SetupWallet/ChooseWalletPassword'; +import ConfirmWalletPassword from 'components/pages/SetupWallet/ConfirmWalletPassword'; +import ImportSecretRecoveryPhrase from 'components/pages/SetupWallet/RestoreWallet/ImportSecretRecoveryPhrase'; +import useOnWalletInitialized from 'hooks/wallet/useOnWalletInitialized'; +import useSetupWallet from 'hooks/wallet/useSetupWallet'; +import { setupWalletActions } from 'redux/slices/setup-wallet'; +import { RootState } from 'redux/store'; +import { Props, RestoreWalletScreenStep } from 'types'; + +interface RestoreWalletProps extends Props { + onWalletSetup?: () => void; +} + +const ScreenStep: FC = ({ onWalletSetup }) => { + const dispatch = useDispatch(); + const { secretPhrase, password, restoreWalletScreenStep } = useSelector((state: RootState) => state.setupWallet); + const { setup, loading } = useSetupWallet({ secretPhrase, password, onWalletSetup }); + + const goto = (step: RestoreWalletScreenStep) => { + return () => dispatch(setupWalletActions.setRestoreWalletScreenStep(step)); + }; + + switch (restoreWalletScreenStep) { + case RestoreWalletScreenStep.ConfirmWalletPassword: + return ( + + ); + case RestoreWalletScreenStep.ChooseWalletPassword: + return ( + + ); + default: + return ; + } +}; + +const RestoreWallet: FC = ({ className = '', onWalletSetup }) => { + useOnWalletInitialized(onWalletSetup); + + return ( +
+ +
+ ); +}; + +export default RestoreWallet; diff --git a/packages/ui/src/components/pages/Welcome.tsx b/packages/ui/src/components/pages/Welcome.tsx index c7cd4e09..b366406f 100644 --- a/packages/ui/src/components/pages/Welcome.tsx +++ b/packages/ui/src/components/pages/Welcome.tsx @@ -1,15 +1,15 @@ import React from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import { toast } from 'react-toastify'; import { Button } from '@mui/material'; import { Props } from 'types'; interface WelcomeProps extends Props { onCreateNewWallet?: () => void; + onRestoreExistingWallet?: () => void; } -const Welcome: React.FC = ({ className = '', onCreateNewWallet }) => { +const Welcome: React.FC = ({ className = '', onCreateNewWallet, onRestoreExistingWallet }) => { const navigate = useNavigate(); const { t } = useTranslation(); @@ -18,7 +18,7 @@ const Welcome: React.FC = ({ className = '', onCreateNewWallet }) }; const doRestoreWallet = () => { - toast.info(`${t('Coming soon')}!`); + onRestoreExistingWallet ? onRestoreExistingWallet() : navigate('/restore-wallet'); }; return ( @@ -36,9 +36,8 @@ const Welcome: React.FC = ({ className = '', onCreateNewWallet }) - diff --git a/packages/ui/src/hooks/wallet/useOnWalletInitialized.ts b/packages/ui/src/hooks/wallet/useOnWalletInitialized.ts new file mode 100644 index 00000000..5240188e --- /dev/null +++ b/packages/ui/src/hooks/wallet/useOnWalletInitialized.ts @@ -0,0 +1,20 @@ +import { useNavigate } from 'react-router-dom'; +import { useEffectOnce } from 'react-use'; +import { useWalletState } from 'providers/WalletStateProvider'; + +export default function useOnWalletInitialized(onWalletSetup?: () => void) { + const { keyring } = useWalletState(); + const navigate = useNavigate(); + + useEffectOnce(() => { + keyring.initialized().then((initialized) => { + if (initialized) { + if (onWalletSetup) { + onWalletSetup(); + } else { + navigate('/'); + } + } + }); + }); +} diff --git a/packages/ui/src/hooks/wallet/useSetupWallet.ts b/packages/ui/src/hooks/wallet/useSetupWallet.ts new file mode 100644 index 00000000..485d8654 --- /dev/null +++ b/packages/ui/src/hooks/wallet/useSetupWallet.ts @@ -0,0 +1,49 @@ +import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; +import { useToggle } from 'react-use'; +import { StandardCoongError } from '@coong/utils'; +import { useWalletState } from 'providers/WalletStateProvider'; +import { appActions } from 'redux/slices/app'; + + +interface SetupWalletOptions { + secretPhrase?: string; + password?: string; + onWalletSetup?: () => void; +} + +export default function useSetupWallet({ secretPhrase, password, onWalletSetup }: SetupWalletOptions) { + const { t } = useTranslation(); + const { keyring } = useWalletState(); + const dispatch = useDispatch(); + const [loading, setLoading] = useToggle(false); + const navigate = useNavigate(); + + const setup = () => { + if (!secretPhrase || !password) { + throw new StandardCoongError('Secret recovery phrase or password are missing'); + } + + setLoading(true); + + setTimeout(async () => { + await keyring.initialize(secretPhrase!, password); + await keyring.createNewAccount(t('My first account'), password); + + dispatch(appActions.seedReady()); + dispatch(appActions.unlock()); + + if (onWalletSetup) { + onWalletSetup(); + } else { + navigate('/'); + } + }, 500); // intentionally! + }; + + return { + setup, + loading, + }; +} diff --git a/packages/ui/src/redux/slices/setup-wallet.ts b/packages/ui/src/redux/slices/setup-wallet.ts index 99517255..6ec2d1a4 100644 --- a/packages/ui/src/redux/slices/setup-wallet.ts +++ b/packages/ui/src/redux/slices/setup-wallet.ts @@ -1,8 +1,9 @@ -import { createSlice } from '@reduxjs/toolkit'; -import { NewWalletScreenStep } from 'components/pages/NewWallet/types'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { NewWalletScreenStep, RestoreWalletScreenStep } from 'types'; export interface SetupWalletState { newWalletScreenStep: NewWalletScreenStep; + restoreWalletScreenStep: RestoreWalletScreenStep; password?: string; passwordConfirmation?: string; secretPhrase?: string; @@ -10,18 +11,30 @@ export interface SetupWalletState { const initialState: SetupWalletState = { newWalletScreenStep: NewWalletScreenStep.ChooseWalletPassword, + restoreWalletScreenStep: RestoreWalletScreenStep.EnterSecretRecoveryPhrase, }; const setupWalletSlice = createSlice({ name: 'setupWallet', initialState, reducers: { - setStep: (state, action) => { + setNewWalletScreenStep: (state, action: PayloadAction) => { state.newWalletScreenStep = action.payload; }, - setPassword: (state, action) => { + setPassword: (state, action: PayloadAction) => { state.password = action.payload; - state.newWalletScreenStep = NewWalletScreenStep.ConfirmWalletPassword; + }, + setRestoreWalletScreenStep: (state, action: PayloadAction) => { + state.restoreWalletScreenStep = action.payload; + }, + setSecretPhrase: (state, action: PayloadAction) => { + state.secretPhrase = action.payload; + }, + resetState: (state) => { + state.newWalletScreenStep = NewWalletScreenStep.ChooseWalletPassword; + state.restoreWalletScreenStep = RestoreWalletScreenStep.EnterSecretRecoveryPhrase; + state.password = ''; + state.secretPhrase = ''; }, }, }); diff --git a/packages/ui/src/router.tsx b/packages/ui/src/router.tsx index 68a5cd33..bd8aa807 100644 --- a/packages/ui/src/router.tsx +++ b/packages/ui/src/router.tsx @@ -2,14 +2,16 @@ import { createBrowserRouter, createRoutesFromElements, Route } from 'react-rout import MainLayout from 'components/layouts/MainLayout'; import Embed from 'components/pages/Embed'; import MainScreen from 'components/pages/MainScreen'; -import NewWallet from 'components/pages/NewWallet'; import Request from 'components/pages/Request'; +import NewWallet from 'components/pages/SetupWallet/NewWallet'; +import RestoreWallet from 'components/pages/SetupWallet/RestoreWallet'; export default createBrowserRouter( createRoutesFromElements([ }> } /> } /> + } /> , } />, }> diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index 66c68979..1e9c7604 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -31,3 +31,15 @@ export enum SettingsDialogScreen { SettingsWallet, BackupSecretPhrase, } + +export enum NewWalletScreenStep { + ChooseWalletPassword, + ConfirmWalletPassword, + BackupSecretRecoveryPhrase, +} + +export enum RestoreWalletScreenStep { + EnterSecretRecoveryPhrase, + ChooseWalletPassword, + ConfirmWalletPassword, +}