From 967b69ffde3aa3c5555285ba6a0667fde998490b Mon Sep 17 00:00:00 2001 From: Thibaut Sardan <33178835+Tbaut@users.noreply.github.com> Date: Wed, 13 Jan 2021 10:31:26 +0100 Subject: [PATCH] Refactor seed import and add explicit derivation path field (#619) * refactor import from seed * stray import * clean up duplicates * remove unneeded global suri * wip use AccountNamePassword component * AccountNamePasswordCreation component and tests * use in Derive and enhance test * extract SeedAndPath in dedicated component * fix disabled button * pass address * keep address in component --- .../CreateAccount/CreateAccount.test.tsx | 112 +--------- .../src/Popup/CreateAccount/index.tsx | 7 +- .../src/Popup/Derive/Derive.test.tsx | 50 ++++- .../src/Popup/Derive/SelectParent.tsx | 10 +- .../extension-ui/src/Popup/Derive/index.tsx | 30 +-- .../extension-ui/src/Popup/ImportSeed.tsx | 121 ----------- .../src/Popup/ImportSeed/SeedAndPath.tsx | 148 +++++++++++++ .../src/Popup/ImportSeed/index.tsx | 73 +++++++ .../AccountNamePasswordCreation.test.tsx | 198 ++++++++++++++++++ .../AccountNamePasswordCreation.tsx} | 36 ++-- .../extension-ui/src/components/Address.tsx | 2 + packages/extension-ui/src/components/index.ts | 1 + 12 files changed, 504 insertions(+), 284 deletions(-) delete mode 100644 packages/extension-ui/src/Popup/ImportSeed.tsx create mode 100644 packages/extension-ui/src/Popup/ImportSeed/SeedAndPath.tsx create mode 100644 packages/extension-ui/src/Popup/ImportSeed/index.tsx create mode 100644 packages/extension-ui/src/components/AccountNamePasswordCreation.test.tsx rename packages/extension-ui/src/{Popup/CreateAccount/AccountName.tsx => components/AccountNamePasswordCreation.tsx} (62%) diff --git a/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.test.tsx b/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.test.tsx index ea2b8598..1e935e96 100644 --- a/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.test.tsx +++ b/packages/extension-ui/src/Popup/CreateAccount/CreateAccount.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { ThemeProvider } from 'styled-components'; -import { ActionContext, ActionText, Button, Input, InputWithLabel, themes } from '../../components'; +import { ActionContext, ActionText, Button, themes } from '../../components'; import * as messaging from '../../messaging'; import { Header } from '../../partials'; import { flushAllPromises } from '../../testHelpers'; @@ -42,12 +42,6 @@ describe('Create Account', () => { wrapper.update(); }; - const capsLockOn = async (input: ReactWrapper): Promise => { - input.simulate('keyPress', { getModifierState: () => true }); - await act(flushAllPromises); - wrapper.update(); - }; - const enterName = (name: string): Promise => type(wrapper.find('input').first(), name); const password = (password: string) => (): Promise => type(wrapper.find('input[type="password"]').first(), password); const repeat = (password: string) => (): Promise => type(wrapper.find('input[type="password"]').last(), password); @@ -100,77 +94,6 @@ describe('Create Account', () => { wrapper.update(); }); - it('only account name input is visible at first', () => { - expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input)).toHaveLength(1); - expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1); - expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0); - expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); - }); - - it('input should not be highlighted as error until first interaction', () => { - expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(false); - }); - - it('after typing less than 3 characters into name input, password input is not visible', async () => { - await enterName('ab'); - expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(true); - expect(wrapper.find('.warning-message').first().text()).toBe('Account name is too short'); - expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1); - expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0); - expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); - }); - - it('input should keep showing error when something has been typed but then erased', async () => { - await enterName('ab'); - await enterName(''); - expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(true); - }); - - it('after typing 3 characters into name input, the name is reflected in the account card', async () => { - const NAME = 'abc'; - - await enterName(NAME); - expect(wrapper.find('[data-field="name"]').first().text()).toBe(NAME); - }); - - it('after typing 3 characters into name input, first password input is visible', async () => { - await enterName('abc'); - expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).first().prop('withError')).toBe(false); - expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1); - expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0); - expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); - }); - - it('password with caps lock should show a warning', async () => { - await enterName('abc').then(password('abcde')); - await capsLockOn(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)); - - expect(wrapper.find('.warning-message').first().text()).toBe('Warning: Caps lock is on'); - }); - - it('password shorter than 6 characters should be not valid', async () => { - await enterName('abc').then(password('abcde')); - expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input).prop('withError')).toBe(true); - expect(wrapper.find('.warning-message').text()).toBe('Password is too short'); - expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1); - expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0); - expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); - }); - - it('submit button is not enabled until both passwords are equal', async () => { - await enterName('abc').then(password('abcdef')).then(repeat('abcdeg')); - expect(wrapper.find('.warning-message').text()).toBe('Passwords do not match'); - expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(true); - expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); - }); - - it('submit button is enabled when both passwords are equal', async () => { - await enterName('abc').then(password('abcdef')).then(repeat('abcdef')); - expect(wrapper.find('.warning-message')).toHaveLength(0); - expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(false); - expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false); - }); - it('saves account with provided name and password', async () => { await enterName('abc').then(password('abcdef')).then(repeat('abcdef')); wrapper.find('[data-button-action="add new root"] button').simulate('click'); @@ -180,37 +103,4 @@ describe('Create Account', () => { expect(onActionStub).toBeCalledWith('/'); }); }); - - describe('Both passwords are equal, but then', () => { - beforeEach(async () => { - check(wrapper.find('input[type="checkbox"]')); - wrapper.find('button').simulate('click'); - await act(flushAllPromises); - await enterName('abc').then(password('abcdef')).then(repeat('abcdef')); - }); - - it('first password input is cleared - second one disappears and button get disabled', async () => { - await type(wrapper.find('input[type="password"]').first(), ''); - expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0); - expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); - }); - - it('first password changes - button is disabled', async () => { - await type(wrapper.find('input[type="password"]').first(), 'aaaaaa'); - expect(wrapper.find('.warning-message').text()).toBe('Passwords do not match'); - expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); - }); - - it('first password changes, then second changes too - button is enabled', async () => { - await type(wrapper.find('input[type="password"]').first(), 'aaaaaa'); - await type(wrapper.find('input[type="password"]').last(), 'aaaaaa'); - expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false); - }); - - it('second password changes, then first changes too - button is enabled', async () => { - await type(wrapper.find('input[type="password"]').last(), 'aaaaaa'); - await type(wrapper.find('input[type="password"]').first(), 'aaaaaa'); - expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false); - }); - }); }); diff --git a/packages/extension-ui/src/Popup/CreateAccount/index.tsx b/packages/extension-ui/src/Popup/CreateAccount/index.tsx index 426814c6..c990060c 100644 --- a/packages/extension-ui/src/Popup/CreateAccount/index.tsx +++ b/packages/extension-ui/src/Popup/CreateAccount/index.tsx @@ -4,11 +4,11 @@ import React, { useCallback, useContext, useEffect, useState } from 'react'; import { ActionContext, Address, Loading } from '../../components'; +import AccountNamePasswordCreation from '../../components/AccountNamePasswordCreation'; import useTranslation from '../../hooks/useTranslation'; import { createAccountSuri, createSeed } from '../../messaging'; import { HeaderWithSteps } from '../../partials'; import { DEFAULT_TYPE } from '../../util/defaultType'; -import AccountName from './AccountName'; import Mnemonic from './Mnemonic'; export default function CreateAccount (): React.ReactElement { @@ -26,7 +26,6 @@ export default function CreateAccount (): React.ReactElement { .catch((error: Error) => console.error(error)); }, [type]); - // FIXME Duplicated between here and Import.tsx const _onCreate = useCallback( (name: string, password: string): void => { // this should always be the case @@ -69,8 +68,8 @@ export default function CreateAccount (): React.ReactElement { /> ) : ( - ('Add the account with the generated seed')} isBusy={isBusy} onBackClick={_onPreviousStep} onCreate={_onCreate} diff --git a/packages/extension-ui/src/Popup/Derive/Derive.test.tsx b/packages/extension-ui/src/Popup/Derive/Derive.test.tsx index 1b99ea83..a434ce1e 100644 --- a/packages/extension-ui/src/Popup/Derive/Derive.test.tsx +++ b/packages/extension-ui/src/Popup/Derive/Derive.test.tsx @@ -22,9 +22,14 @@ import Derive from '.'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call configure({ adapter: new Adapter() }); +const parentPassword = 'pass'; +const westendGenesis = '0xe143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e'; +const defaultDerivation = '//0'; +const derivedAddress = '5GYQRJj3NUznYDzCduENRcocMsyxmb6tjb5xW87ZMErBe9R7'; + const accounts = [ { address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', name: 'A', type: 'sr25519' }, - { address: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', name: 'B', type: 'sr25519' }, + { address: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', genesisHash: westendGenesis, name: 'B', type: 'sr25519' }, { address: '5D2TPhGEy2FhznvzaNYW9AkuMBbg3cyRemnPsBvBY4ZhkZXA', name: 'BB', parentAddress: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', type: 'sr25519' }, { address: '5GhGENSJBWQZ8d8mARKgqEkiAxiW3hHeznQDW2iG4XzNieb6', isExternal: true, name: 'C', type: 'sr25519' }, { address: '0xd5D81CD4236a43F48A983fc5B895975c511f634D', name: 'Ethereum', type: 'ethereum' }, @@ -70,6 +75,10 @@ describe('Derive', () => { input.update(); }; + const enterName = (name: string): Promise => type(wrapper.find('input').first(), name); + const password = (password: string) => (): Promise => type(wrapper.find('input[type="password"]').first(), password); + const repeat = (password: string) => (): Promise => type(wrapper.find('input[type="password"]').last(), password); + describe('Parent selection screen', () => { beforeEach(async () => { const mountedComponent = await mountComponent(); @@ -79,14 +88,14 @@ describe('Derive', () => { }); // eslint-disable-next-line @typescript-eslint/require-await - jest.spyOn(messaging, 'validateAccount').mockImplementation(async (_, pass: string) => pass === 'pass'); + jest.spyOn(messaging, 'validateAccount').mockImplementation(async (_, pass: string) => pass === parentPassword); // eslint-disable-next-line @typescript-eslint/require-await jest.spyOn(messaging, 'validateDerivationPath').mockImplementation(async (_, path) => { if (path === '//') { throw new Error('wrong suri'); } - return {} as ResponseDeriveValidate; + return { address: derivedAddress, suri: defaultDerivation } as ResponseDeriveValidate; }); it('Button is disabled and password field visible', () => { @@ -137,7 +146,7 @@ describe('Derive', () => { }); it('Button is enabled when password is set', async () => { - await type(wrapper.find('input[type="password"]'), 'pass'); + await type(wrapper.find('input[type="password"]'), parentPassword); const button = wrapper.find('[data-button-action="create derived account"] button'); @@ -146,7 +155,7 @@ describe('Derive', () => { }); it('An error is visible and the button is disabled when suri is incorrect', async () => { - await type(wrapper.find('input[type="password"]'), 'pass'); + await type(wrapper.find('input[type="password"]'), parentPassword); await type(wrapper.find('[data-input-suri] input'), '//'); wrapper.find('[data-button-action="create derived account"] button').simulate('click'); await act(flushAllPromises); @@ -160,7 +169,7 @@ describe('Derive', () => { }); it('The error disappears and "Create derived account" is enabled when typing a new suri', async () => { - await type(wrapper.find('input[type="password"]'), 'pass'); + await type(wrapper.find('input[type="password"]'), parentPassword); await type(wrapper.find('[data-input-suri] input'), '//'); wrapper.find('[data-button-action="create derived account"] button').simulate('click'); await act(flushAllPromises); @@ -192,7 +201,10 @@ describe('Derive', () => { describe('Locked parent selection', () => { beforeAll(async () => { - wrapper = (await mountComponent(true)).wrapper; + const mountedComponent = (await mountComponent(true)); + + wrapper = mountedComponent.wrapper; + onActionStub = mountedComponent.onActionStub; }); it('address dropdown does not exist', () => { @@ -202,5 +214,29 @@ describe('Derive', () => { it('parent is taken from URL', () => { expect(wrapper.find('[data-field="name"]').first().text()).toBe('B'); }); + + describe('Second phase', () => { + it('correctly creates the derived account', async () => { + const newAccount = { + name: 'newName', + password: 'somePassword' + }; + const deriveMock = jest.spyOn(messaging, 'deriveAccount'); + + await type(wrapper.find('input[type="password"]'), parentPassword); + wrapper.find('[data-button-action="create derived account"] button').simulate('click'); + await act(flushAllPromises); + wrapper.update(); + await enterName(newAccount.name).then(password(newAccount.password)).then(repeat(newAccount.password)); + await act(flushAllPromises); + wrapper.update(); + wrapper.find('[data-button-action="add new root"] button').simulate('click'); + await act(flushAllPromises); + wrapper.update(); + + expect(deriveMock).toBeCalledWith(accounts[1].address, defaultDerivation, parentPassword, newAccount.name, newAccount.password, westendGenesis); + expect(onActionStub).toBeCalledWith('/'); + }); + }); }); }); diff --git a/packages/extension-ui/src/Popup/Derive/SelectParent.tsx b/packages/extension-ui/src/Popup/Derive/SelectParent.tsx index d7d3f449..6ef92761 100644 --- a/packages/extension-ui/src/Popup/Derive/SelectParent.tsx +++ b/packages/extension-ui/src/Popup/Derive/SelectParent.tsx @@ -1,10 +1,7 @@ // Copyright 2019-2021 @polkadot/extension-ui authors & contributors // SPDX-License-Identifier: Apache-2.0 -import type { ThemeProps } from '../../types'; - import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import styled from 'styled-components'; import { canDerive } from '@polkadot/extension-base/utils'; import { assert } from '@polkadot/util'; @@ -16,7 +13,7 @@ import { nextDerivationPath } from '../../util/nextDerivationPath'; import AddressDropdown from './AddressDropdown'; import DerivationPath from './DerivationPath'; -interface Props extends ThemeProps { +interface Props { className?: string; isLocked?: boolean; parentAddress: string; @@ -24,7 +21,7 @@ interface Props extends ThemeProps { onDerivationConfirmed: (derivation: { account: { address: string; suri: string }; parentPassword: string }) => void; } -function SelectParent ({ className, isLocked, onDerivationConfirmed, parentAddress, parentGenesis }: Props): React.ReactElement { +export default function SelectParent ({ className, isLocked, onDerivationConfirmed, parentAddress, parentGenesis }: Props): React.ReactElement { const { t } = useTranslation(); const onAction = useContext(ActionContext); const [isBusy, setIsBusy] = useState(false); @@ -165,6 +162,3 @@ function SelectParent ({ className, isLocked, onDerivationConfirmed, parentAddre ); } - -export default styled(SelectParent)` -`; diff --git a/packages/extension-ui/src/Popup/Derive/index.tsx b/packages/extension-ui/src/Popup/Derive/index.tsx index 9fc0556b..1a22926c 100644 --- a/packages/extension-ui/src/Popup/Derive/index.tsx +++ b/packages/extension-ui/src/Popup/Derive/index.tsx @@ -4,10 +4,10 @@ import React, { useCallback, useContext, useMemo, useState } from 'react'; import { useParams } from 'react-router'; -import { AccountContext, ActionContext, Address, BackButton, ButtonArea, NextStepButton, VerticalSpace } from '../../components'; +import { AccountContext, AccountNamePasswordCreation, ActionContext, Address } from '../../components'; import useTranslation from '../../hooks/useTranslation'; import { deriveAccount } from '../../messaging'; -import { HeaderWithSteps, Name, Password } from '../../partials'; +import { HeaderWithSteps } from '../../partials'; import SelectParent from './SelectParent'; interface Props { @@ -35,7 +35,6 @@ function Derive ({ isLocked }: Props): React.ReactElement { const [isBusy, setIsBusy] = useState(false); const [account, setAccount] = useState(null); const [name, setName] = useState(null); - const [password, setPassword] = useState(null); const [parentPassword, setParentPassword] = useState(null); const parentGenesis = useMemo( @@ -43,7 +42,7 @@ function Derive ({ isLocked }: Props): React.ReactElement { [accounts, parentAddress] ); - const _onCreate = useCallback(() => { + const _onCreate = useCallback((name: string, password: string) => { if (!account || !name || !password || !parentPassword) { return; } @@ -55,7 +54,7 @@ function Derive ({ isLocked }: Props): React.ReactElement { setIsBusy(false); console.error(error); }); - }, [account, name, password, onAction, parentAddress, parentGenesis, parentPassword]); + }, [account, onAction, parentAddress, parentGenesis, parentPassword]); const _onDerivationConfirmed = useCallback(({ account, parentPassword }: ConfirmState) => { setAccount(account); @@ -89,22 +88,13 @@ function Derive ({ isLocked }: Props): React.ReactElement { name={name} /> - ('Create derived account')} + isBusy={isBusy} + onBackClick={_onBackClick} + onCreate={_onCreate} + onNameChange={setName} /> - - - - - - {t('Create derived account')} - - )} diff --git a/packages/extension-ui/src/Popup/ImportSeed.tsx b/packages/extension-ui/src/Popup/ImportSeed.tsx deleted file mode 100644 index 3e1d8229..00000000 --- a/packages/extension-ui/src/Popup/ImportSeed.tsx +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright 2019-2021 @polkadot/extension-ui authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import React, { useCallback, useContext, useEffect, useState } from 'react'; -import styled from 'styled-components'; - -import { AccountContext, ActionContext, Address, ButtonArea, NextStepButton, TextAreaWithLabel, ValidatedInput, VerticalSpace } from '../components'; -import useTranslation from '../hooks/useTranslation'; -import { createAccountSuri, validateSeed } from '../messaging'; -import { Header, Name, Password } from '../partials'; -import { allOf, isNotShorterThan, Result } from '../util/validators'; - -async function validate (suri: string): Promise> { - try { - await validateSeed(suri); - - return Result.ok(suri); - } catch (error) { - return Result.error((error as Error).message); - } -} - -const isSeedValid = allOf( - isNotShorterThan(1, 'Seed is empty'), - validate -); - -export default function Import (): React.ReactElement { - const { t } = useTranslation(); - const { accounts } = useContext(AccountContext); - const onAction = useContext(ActionContext); - const [isBusy, setIsBusy] = useState(false); - const [account, setAccount] = useState(null); - const [name, setName] = useState(null); - const [password, setPassword] = useState(null); - - useEffect((): void => { - !accounts.length && onAction(); - }, [accounts, onAction]); - - const _onChangeSeed = useCallback( - async (suri: string | null): Promise => { - if (suri) { - try { - return setAccount(await validateSeed(suri)); - } catch (error) { - console.error(error); - } - } - - setAccount(null); - }, - [] - ); - - // FIXME Duplicated between here and Create.tsx - const _onCreate = useCallback((): void => { - // this should always be the case - if (name && password && account) { - setIsBusy(true); - - createAccountSuri(name, password, account.suri) - .then(() => onAction('/')) - .catch((error): void => { - setIsBusy(false); - console.error(error); - }); - } - }, [account, name, onAction, password]); - - return ( - <> -
('Import account')} - /> -
-
-
- ('existing 12 or 24-word mnemonic seed')} - onValidatedChange={_onChangeSeed} - rowsCount={2} - validator={isSeedValid} - /> - {account && ( - <> - - - - )} - {account && name && password && ( - <> - - - - {t('Add the account with the supplied seed')} - - - - )} - - ); -} - -const SeedInput = styled(TextAreaWithLabel)` - margin-bottom: 16px; - textarea { - height: unset; - } -`; diff --git a/packages/extension-ui/src/Popup/ImportSeed/SeedAndPath.tsx b/packages/extension-ui/src/Popup/ImportSeed/SeedAndPath.tsx new file mode 100644 index 00000000..05d8a7cc --- /dev/null +++ b/packages/extension-ui/src/Popup/ImportSeed/SeedAndPath.tsx @@ -0,0 +1,148 @@ +// Copyright 2019-2021 @polkadot/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ThemeProps } from '../../types'; + +import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React, { useCallback, useEffect, useState } from 'react'; +import styled from 'styled-components'; + +import { validateSeed } from '@polkadot/extension-ui/messaging'; + +import { ButtonArea, InputWithLabel, NextStepButton, TextAreaWithLabel, VerticalSpace, Warning } from '../../components'; +import useTranslation from '../../hooks/useTranslation'; + +interface AccountInfo { + address: string; + suri: string; +} + +interface Props { + className? : string; + onNextStep: () => void; + onAccountChange: (account: AccountInfo | null) => void; +} + +function ImportSeed ({ className, onAccountChange, onNextStep }: Props): React.ReactElement { + const { t } = useTranslation(); + const [address, setAddress] = useState(''); + const [seed, setSeed] = useState(null); + const [path, setPath] = useState(null); + const [advanced, setAdvances] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + // No need to validate an empty seed + // we have a dedicated error for this + if (!seed) { + onAccountChange(null); + + return; + } + + const suri = `${seed || ''}${path || ''}`; + + validateSeed(suri).then((newAccount) => { + setError(''); + setAddress(newAccount.address); + onAccountChange(newAccount); + }).catch(() => { + setAddress(''); + onAccountChange(null); + setError(path + ? t('Invalid mnemonic seed or derivation path') + : t('Invalid mnemonic seed') + ); + }); + }, [t, seed, path, onAccountChange]); + + const _onToggleAdvanced = useCallback(() => { + setAdvances(!advanced); + }, [advanced]); + + return ( + <> +
+ ('existing 12 or 24-word mnemonic seed')} + onChange={setSeed} + rowsCount={2} + value={seed || ''} + /> + {!!error && !seed && ( + + {t('Mnemonic needs to contain 12, 15, 18, 21, 24 words')} + + )} +
+ + {t('advanced')} +
+ { advanced && ( + ('derivation path')} + onChange={setPath} + value={path || ''} + /> + )} + {!!error && !!seed && ( + + {error} + + )} +
+ + + + {t('Next')} + + + + ); +} + +export default styled(ImportSeed)(({ theme }: ThemeProps) => ` + .advancedToggle { + color: ${theme.textColor}; + cursor: pointer; + line-height: 14px; + letter-spacing: 0.04em; + opacity: 0.65; + text-transform: uppercase; + + > span { + font-size: 10px; + margin-left: .5rem; + vertical-align: middle; + } + } + + .seedInput { + margin-bottom: 16px; + textarea { + height: unset; + } + } + + .seedError { + margin-bottom: 1rem; + } +`); diff --git a/packages/extension-ui/src/Popup/ImportSeed/index.tsx b/packages/extension-ui/src/Popup/ImportSeed/index.tsx new file mode 100644 index 00000000..0f3184a7 --- /dev/null +++ b/packages/extension-ui/src/Popup/ImportSeed/index.tsx @@ -0,0 +1,73 @@ +// Copyright 2019-2021 @polkadot/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import React, { useCallback, useContext, useEffect, useState } from 'react'; + +import { AccountContext, ActionContext, Address } from '../../components'; +import AccountNamePasswordCreation from '../../components/AccountNamePasswordCreation'; +import useTranslation from '../../hooks/useTranslation'; +import { createAccountSuri } from '../../messaging'; +import { HeaderWithSteps } from '../../partials'; +import SeedAndPath from './SeedAndPath'; + +function ImportSeed (): React.ReactElement { + const { t } = useTranslation(); + const { accounts } = useContext(AccountContext); + const onAction = useContext(ActionContext); + const [isBusy, setIsBusy] = useState(false); + const [account, setAccount] = useState(null); + const [name, setName] = useState(null); + const [step1, setStep1] = useState(true); + + useEffect((): void => { + !accounts.length && onAction(); + }, [accounts, onAction]); + + const _onCreate = useCallback((name: string, password: string): void => { + // this should always be the case + if (name && password && account) { + setIsBusy(true); + + createAccountSuri(name, password, account.suri) + .then(() => onAction('/')) + .catch((error): void => { + setIsBusy(false); + console.error(error); + }); + } + }, [account, onAction]); + + const _onNextStep = useCallback(() => { setStep1(false); }, []); + const _onBackClick = useCallback(() => { setStep1(true); }, []); + + return ( + <> + ('Import account')} + /> +
+
+
+ {step1 + ? + : ('Add the account with the supplied seed')} + isBusy={isBusy} + onBackClick={_onBackClick} + onCreate={_onCreate} + onNameChange={setName} + /> + + } + + ); +} + +export default ImportSeed; diff --git a/packages/extension-ui/src/components/AccountNamePasswordCreation.test.tsx b/packages/extension-ui/src/components/AccountNamePasswordCreation.test.tsx new file mode 100644 index 00000000..e2a7b49c --- /dev/null +++ b/packages/extension-ui/src/components/AccountNamePasswordCreation.test.tsx @@ -0,0 +1,198 @@ +// Copyright 2019-2021 @polkadot/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import '../../../../__mocks__/chrome'; + +import type { ReactWrapper } from 'enzyme'; + +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import { configure, mount } from 'enzyme'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { flushAllPromises } from '../testHelpers'; +import BackButton from './BackButton'; +import { AccountNamePasswordCreation, Input, InputWithLabel, NextStepButton } from '.'; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call +configure({ adapter: new Adapter() }); + +const account = { + name: 'My Polkadot Account', + password: 'somepassword' +}; + +const buttonLabel = 'create account'; + +let wrapper: ReactWrapper; +const onBackClick = jest.fn(); +const onCreate = jest.fn(); +const onNameChange = jest.fn(); + +const type = async (input: ReactWrapper, value: string): Promise => { + input.simulate('change', { target: { value } }); + await act(flushAllPromises); + wrapper.update(); +}; + +const capsLockOn = async (input: ReactWrapper): Promise => { + input.simulate('keyPress', { getModifierState: () => true }); + await act(flushAllPromises); + wrapper.update(); +}; + +const enterName = (name: string): Promise => type(wrapper.find('input').first(), name); +const password = (password: string) => (): Promise => type(wrapper.find('input[type="password"]').first(), password); +const repeat = (password: string) => (): Promise => type(wrapper.find('input[type="password"]').last(), password); + +// eslint-disable-next-line @typescript-eslint/no-unsafe-return +const mountComponent = (isBusy = false): ReactWrapper => mount( + +); + +describe('AccountNamePasswordCreation', () => { + beforeEach(async () => { + wrapper = mountComponent(); + await act(flushAllPromises); + wrapper.update(); + }); + + it('only account name input is visible at first', () => { + expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input)).toHaveLength(1); + expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1); + expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); + }); + + it('next step button has the correct label', () => { + expect(wrapper.find(NextStepButton).text()).toBe(buttonLabel); + }); + + it('back button calls onBackClick', () => { + wrapper.find(BackButton).simulate('click'); + expect(onBackClick).toHaveBeenCalledTimes(1); + }); + + it('input should not be highlighted as error until first interaction', () => { + expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(false); + }); + + it('after typing less than 3 characters into name input, password input is not visible', async () => { + await enterName('ab'); + expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(true); + expect(wrapper.find('.warning-message').first().text()).toBe('Account name is too short'); + expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1); + expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); + }); + + it('input should keep showing error when something has been typed but then erased', async () => { + await enterName(account.name); + await enterName(''); + expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).prop('withError')).toBe(true); + }); + + it('after typing 3 characters into name input, onNameChange is called', async () => { + await enterName(account.name); + expect(onNameChange).toHaveBeenLastCalledWith(account.name); + }); + + it('after typing 3 characters into name input, first password input is visible', async () => { + await enterName(account.name); + expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input).first().prop('withError')).toBe(false); + expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1); + expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); + }); + + it('password with caps lock should show a warning', async () => { + await enterName('abc').then(password('abcde')); + await capsLockOn(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)); + + expect(wrapper.find('.warning-message').first().text()).toBe('Warning: Caps lock is on'); + }); + + it('password shorter than 6 characters should be not valid', async () => { + await enterName('abc').then(password('abcde')); + expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input).prop('withError')).toBe(true); + expect(wrapper.find('.warning-message').text()).toBe('Password is too short'); + expect(wrapper.find(InputWithLabel).find('[data-input-password]').find(Input)).toHaveLength(1); + expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); + }); + + it('submit button is not enabled until both passwords are equal', async () => { + await enterName('abc').then(password('abcdef')).then(repeat('abcdeg')); + expect(wrapper.find('.warning-message').text()).toBe('Passwords do not match'); + expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(true); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); + }); + + it('submit button is enabled when both passwords are equal', async () => { + await enterName('abc').then(password('abcdef')).then(repeat('abcdef')); + expect(wrapper.find('.warning-message')).toHaveLength(0); + expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(false); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false); + }); + + it('calls onCreate with provided name and password', async () => { + await enterName(account.name).then(password(account.password)).then(repeat(account.password)); + wrapper.find('[data-button-action="add new root"] button').simulate('click'); + await act(flushAllPromises); + + expect(onCreate).toBeCalledWith(account.name, account.password); + }); + + describe('All fields are filled correctly, but then', () => { + beforeEach(async () => { + await enterName(account.name).then(password(account.password)).then(repeat(account.password)); + }); + + it('first password input is cleared - second one disappears and button get disabled', async () => { + await type(wrapper.find('input[type="password"]').first(), ''); + expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); + }); + + it('first password changes - button is disabled', async () => { + await type(wrapper.find('input[type="password"]').first(), 'abcdef'); + expect(wrapper.find('.warning-message').text()).toBe('Passwords do not match'); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); + }); + + it('first password changes, then second changes too - button is enabled', async () => { + await type(wrapper.find('input[type="password"]').first(), 'abcdef'); + await type(wrapper.find('input[type="password"]').last(), 'abcdef'); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false); + }); + + it('second password changes, then first changes too - button is enabled', async () => { + await type(wrapper.find('input[type="password"]').last(), 'abcdef'); + await type(wrapper.find('input[type="password"]').first(), 'abcdef'); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false); + }); + + it('name is removed - button is disabled', async () => { + await enterName(''); + expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true); + }); + }); +}); + +describe('AccountNamePasswordCreation busy button', () => { + beforeAll(async () => { + wrapper = mountComponent(true); + await act(flushAllPromises); + wrapper.update(); + }); + + it('button is busy', () => { + expect(wrapper.find(NextStepButton).prop('isBusy')).toBe(true); + }); +}); diff --git a/packages/extension-ui/src/Popup/CreateAccount/AccountName.tsx b/packages/extension-ui/src/components/AccountNamePasswordCreation.tsx similarity index 62% rename from packages/extension-ui/src/Popup/CreateAccount/AccountName.tsx rename to packages/extension-ui/src/components/AccountNamePasswordCreation.tsx index 4704035d..89a422a4 100644 --- a/packages/extension-ui/src/Popup/CreateAccount/AccountName.tsx +++ b/packages/extension-ui/src/components/AccountNamePasswordCreation.tsx @@ -3,20 +3,18 @@ import React, { useCallback, useState } from 'react'; -import { BackButton, ButtonArea, NextStepButton, VerticalSpace } from '../../components'; -import useTranslation from '../../hooks/useTranslation'; -import { Name, Password } from '../../partials'; +import { Name, Password } from '../partials'; +import { BackButton, ButtonArea, NextStepButton, VerticalSpace } from '.'; interface Props { - address: string; + buttonLabel: string; isBusy: boolean; onBackClick: () => void; onCreate: (name: string, password: string) => void | Promise; onNameChange: (name: string) => void; } -function AccountName ({ isBusy, onBackClick, onCreate, onNameChange }: Props): React.ReactElement { - const { t } = useTranslation(); +function AccountNamePasswordCreation ({ buttonLabel, isBusy, onBackClick, onCreate, onNameChange }: Props): React.ReactElement { const [name, setName] = useState(null); const [password, setPassword] = useState(null); @@ -25,10 +23,22 @@ function AccountName ({ isBusy, onBackClick, onCreate, onNameChange }: Props): R [name, password, onCreate] ); - const _onNameChange = useCallback((name: string | null) => { - onNameChange(name || ''); - setName(name); - }, [onNameChange]); + const _onNameChange = useCallback( + (name: string | null) => { + onNameChange(name || ''); + setName(name); + }, + [onNameChange] + ); + + const _onBackClick = useCallback( + () => { + _onNameChange(null); + setPassword(null); + onBackClick(); + }, + [_onNameChange, onBackClick] + ); return ( <> @@ -39,18 +49,18 @@ function AccountName ({ isBusy, onBackClick, onCreate, onNameChange }: Props): R - + - {t('Add the account with the generated seed')} + {buttonLabel} ); } -export default React.memo(AccountName); +export default React.memo(AccountNamePasswordCreation); diff --git a/packages/extension-ui/src/components/Address.tsx b/packages/extension-ui/src/components/Address.tsx index b1e88660..e2bc4fac 100644 --- a/packages/extension-ui/src/components/Address.tsx +++ b/packages/extension-ui/src/components/Address.tsx @@ -107,6 +107,8 @@ function Address ({ actions, address, children, className, genesisHash, isExtern useEffect((): void => { if (!address) { + setRecoded(defaultRecoded); + return; } diff --git a/packages/extension-ui/src/components/index.ts b/packages/extension-ui/src/components/index.ts index c0b13270..c5339479 100644 --- a/packages/extension-ui/src/components/index.ts +++ b/packages/extension-ui/src/components/index.ts @@ -1,6 +1,7 @@ // Copyright 2019-2021 @polkadot/extension-ui authors & contributors // SPDX-License-Identifier: Apache-2.0 +export { default as AccountNamePasswordCreation } from './AccountNamePasswordCreation'; export { default as ActionBar } from './ActionBar'; export { default as ActionText } from './ActionText'; export { default as Address } from './Address';