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
This commit is contained in:
Thibaut Sardan 2021-01-13 10:31:26 +01:00 committed by GitHub
parent a39d3ba696
commit 967b69ffde
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 504 additions and 284 deletions

View File

@ -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<void> => {
input.simulate('keyPress', { getModifierState: () => true });
await act(flushAllPromises);
wrapper.update();
};
const enterName = (name: string): Promise<void> => type(wrapper.find('input').first(), name);
const password = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').first(), password);
const repeat = (password: string) => (): Promise<void> => 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);
});
});
});

View File

@ -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 {
/>
)
: (
<AccountName
address={account.address}
<AccountNamePasswordCreation
buttonLabel={t<string>('Add the account with the generated seed')}
isBusy={isBusy}
onBackClick={_onPreviousStep}
onCreate={_onCreate}

View File

@ -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<void> => type(wrapper.find('input').first(), name);
const password = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').first(), password);
const repeat = (password: string) => (): Promise<void> => 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('/');
});
});
});
});

View File

@ -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<Props> {
export default function SelectParent ({ className, isLocked, onDerivationConfirmed, parentAddress, parentGenesis }: Props): React.ReactElement<Props> {
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)`
`;

View File

@ -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<Props> {
const [isBusy, setIsBusy] = useState(false);
const [account, setAccount] = useState<null | PathState>(null);
const [name, setName] = useState<string | null>(null);
const [password, setPassword] = useState<string | null>(null);
const [parentPassword, setParentPassword] = useState<string | null>(null);
const parentGenesis = useMemo(
@ -43,7 +42,7 @@ function Derive ({ isLocked }: Props): React.ReactElement<Props> {
[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<Props> {
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<Props> {
name={name}
/>
</div>
<Name
isFocused
onChange={setName}
<AccountNamePasswordCreation
buttonLabel={t<string>('Create derived account')}
isBusy={isBusy}
onBackClick={_onBackClick}
onCreate={_onCreate}
onNameChange={setName}
/>
<Password onChange={setPassword} />
<VerticalSpace/>
<ButtonArea>
<BackButton onClick={_onBackClick}/>
<NextStepButton
isBusy={isBusy}
isDisabled={!password}
onClick={_onCreate}
>
{t<string>('Create derived account')}
</NextStepButton>
</ButtonArea>
</>
)}
</>

View File

@ -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<Result<string>> {
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 | { address: string; suri: string }>(null);
const [name, setName] = useState<string | null>(null);
const [password, setPassword] = useState<string | null>(null);
useEffect((): void => {
!accounts.length && onAction();
}, [accounts, onAction]);
const _onChangeSeed = useCallback(
async (suri: string | null): Promise<void> => {
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 (
<>
<Header
showBackArrow
smallMargin
text={t<string>('Import account')}
/>
<div>
<Address
address={account?.address}
name={name}
/>
</div>
<ValidatedInput
// eslint-disable-next-line @typescript-eslint/no-use-before-define
component={SeedInput}
isFocused
label={t<string>('existing 12 or 24-word mnemonic seed')}
onValidatedChange={_onChangeSeed}
rowsCount={2}
validator={isSeedValid}
/>
{account && (
<>
<Name onChange={setName} />
<Password onChange={setPassword} />
</>
)}
{account && name && password && (
<>
<VerticalSpace />
<ButtonArea>
<NextStepButton
isBusy={isBusy}
onClick={_onCreate}
>
{t<string>('Add the account with the supplied seed')}
</NextStepButton>
</ButtonArea>
</>
)}
</>
);
}
const SeedInput = styled(TextAreaWithLabel)`
margin-bottom: 16px;
textarea {
height: unset;
}
`;

View File

@ -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<string | null>(null);
const [path, setPath] = useState<string | null>(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<string>('Invalid mnemonic seed or derivation path')
: t<string>('Invalid mnemonic seed')
);
});
}, [t, seed, path, onAccountChange]);
const _onToggleAdvanced = useCallback(() => {
setAdvances(!advanced);
}, [advanced]);
return (
<>
<div className={className}>
<TextAreaWithLabel
className='seedInput'
isError={!!error}
isFocused
label={t<string>('existing 12 or 24-word mnemonic seed')}
onChange={setSeed}
rowsCount={2}
value={seed || ''}
/>
{!!error && !seed && (
<Warning
className='seedError'
isBelowInput
isDanger
>
{t<string>('Mnemonic needs to contain 12, 15, 18, 21, 24 words')}
</Warning>
)}
<div
className='advancedToggle'
onClick={_onToggleAdvanced}
>
<FontAwesomeIcon icon={advanced ? faCaretDown : faCaretRight}/>
<span>{t<string>('advanced')}</span>
</div>
{ advanced && (
<InputWithLabel
className='derivationPath'
isError={!!path && !!error}
label={t<string>('derivation path')}
onChange={setPath}
value={path || ''}
/>
)}
{!!error && !!seed && (
<Warning
isDanger
>
{error}
</Warning>
)}
</div>
<VerticalSpace />
<ButtonArea>
<NextStepButton
isDisabled={!address || !!error}
onClick={onNextStep}
>
{t<string>('Next')}
</NextStepButton>
</ButtonArea>
</>
);
}
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;
}
`);

View File

@ -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 | { address: string; suri: string }>(null);
const [name, setName] = useState<string | null>(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 (
<>
<HeaderWithSteps
step={step1 ? 1 : 2}
text={t<string>('Import account')}
/>
<div>
<Address
address={account?.address}
name={name}
/>
</div>
{step1
? <SeedAndPath
onAccountChange={setAccount}
onNextStep={_onNextStep}
/>
: <AccountNamePasswordCreation
buttonLabel={t<string>('Add the account with the supplied seed')}
isBusy={isBusy}
onBackClick={_onBackClick}
onCreate={_onCreate}
onNameChange={setName}
/>
}
</>
);
}
export default ImportSeed;

View File

@ -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<void> => {
input.simulate('change', { target: { value } });
await act(flushAllPromises);
wrapper.update();
};
const capsLockOn = async (input: ReactWrapper): Promise<void> => {
input.simulate('keyPress', { getModifierState: () => true });
await act(flushAllPromises);
wrapper.update();
};
const enterName = (name: string): Promise<void> => type(wrapper.find('input').first(), name);
const password = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').first(), password);
const repeat = (password: string) => (): Promise<void> => type(wrapper.find('input[type="password"]').last(), password);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
const mountComponent = (isBusy = false): ReactWrapper => mount(
<AccountNamePasswordCreation
buttonLabel={buttonLabel}
isBusy={isBusy}
onBackClick={onBackClick}
onCreate={onCreate}
onNameChange={onNameChange}
/>
);
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);
});
});

View File

@ -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<void | boolean>;
onNameChange: (name: string) => void;
}
function AccountName ({ isBusy, onBackClick, onCreate, onNameChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
function AccountNamePasswordCreation ({ buttonLabel, isBusy, onBackClick, onCreate, onNameChange }: Props): React.ReactElement<Props> {
const [name, setName] = useState<string | null>(null);
const [password, setPassword] = useState<string | null>(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
<Password onChange={setPassword} />
<VerticalSpace />
<ButtonArea>
<BackButton onClick={onBackClick} />
<BackButton onClick={_onBackClick} />
<NextStepButton
data-button-action='add new root'
isBusy={isBusy}
isDisabled={!password || !name}
onClick={_onCreate}
>
{t<string>('Add the account with the generated seed')}
{buttonLabel}
</NextStepButton>
</ButtonArea>
</>
);
}
export default React.memo(AccountName);
export default React.memo(AccountNamePasswordCreation);

View File

@ -107,6 +107,8 @@ function Address ({ actions, address, children, className, genesisHash, isExtern
useEffect((): void => {
if (!address) {
setRecoded(defaultRecoded);
return;
}

View File

@ -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';