Create new account as a child of a master account by default (#318)

* derive accounts for master account

* Add master field to account context (#75)

* add master field to account context

* add tests for buildHierarchy function

* Fix selection master account

* Add path numeration for derived accounts (#76)

* add path numeration for derived accounts

* Fix PR notices. Add util for Path generation.

* Correct test

Co-authored-by: Ivan Rukhavets <ivanruch@gmail.com>
Co-authored-by: VladStarostenko <vlad@DESKTOP-V12SRE8.localdomain>

* Add arrows to buttons (#77)

* Add parent label (#80)

* refactor parent label

* refactor parent label

* refactor parent label

Co-authored-by: Ivan Rukhavets <ivanruch@gmail.com>

* Implement new create account flow (#78)

* Add copied snackbar (#83)

* refactor parent label

* Add "Copied" snackbar

* Add "Copied" snackbar

* Hide suri input behind a spoiler (#84)

* Disallow changing parent account when 'Derive' option is clicked (#82)

* Add lock to path input (#85)

Co-authored-by: VladStarostenko <38181661+VladStarostenko@users.noreply.github.com>
Co-authored-by: VladStarostenko <vlad@DESKTOP-V12SRE8.localdomain>
This commit is contained in:
Ivan Rukhavets 2020-05-11 13:27:00 +03:00 committed by GitHub
parent 05d3f784ca
commit ed36ed24bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 873 additions and 216 deletions

View File

@ -29,6 +29,7 @@ export interface AccountJson extends KeyringPair$Meta {
isExternal?: boolean;
name?: string;
parentAddress?: string;
whenCreated?: number;
}
export type AccountWithChildren = AccountJson & {
@ -38,6 +39,7 @@ export type AccountWithChildren = AccountJson & {
export type AccountsContext = {
accounts: AccountJson[];
hierarchy: AccountWithChildren[];
master?: AccountJson;
}
export interface AuthorizeRequest {

View File

@ -41,7 +41,7 @@ function Account ({ address, className, isExternal, parentName }: Props): React.
<MenuGroup>
<MenuItem onClick={_toggleEdit}>Rename</MenuItem>
{!isExternal && (
<MenuItem to={`/account/derive/${address}`}>Derive New Account</MenuItem>
<MenuItem to={`/account/derive/${address}/locked`}>Derive New Account</MenuItem>
)}
</MenuGroup>
{!isExternal && (

View File

@ -5,15 +5,15 @@
import React, { useContext } from 'react';
import styled from 'styled-components';
import QrImage from '../../assets/qr.svg';
import { AccountContext, Button, ButtonArea, ButtonWithSubtitle, MediaContext, Svg } from '../../components';
import { AddAccount, Header } from '../../partials';
import AccountsTree from './AccountsTree';
import qrIcon from '../../assets/qr.svg';
type Props = {};
export default function Accounts (): React.ReactElement<Props> {
const { hierarchy } = useContext(AccountContext);
const { hierarchy, master } = useContext(AccountContext);
const mediaAllowed = useContext(MediaContext);
return (
@ -39,9 +39,9 @@ export default function Accounts (): React.ReactElement<Props> {
}
<ButtonArea>
<ButtonWithSubtitle
subTitle='With new seed'
subTitle={master ? 'Derive from master' : 'With new seed'}
title='Create New Account'
to='/account/create'
to={master ? `/account/derive/${master.address}` : '/account/create'}
/>
<ButtonWithSubtitle
subTitle='I have a pre-existing seed'
@ -50,7 +50,7 @@ export default function Accounts (): React.ReactElement<Props> {
/>
{mediaAllowed && (
<QrButton to='/account/import-qr'>
<Svg src={QrImage} />
<Svg src={qrIcon} />
</QrButton>
)}
</ButtonArea>

View File

@ -2,19 +2,22 @@
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import React, { useState } from 'react';
import { Address, Button, ButtonArea, VerticalSpace } from '@polkadot/extension-ui/components';
import { Name, Password } from '@polkadot/extension-ui/partials';
import React, { useCallback, useState } from 'react';
import { Address, BackButton, ButtonArea, NextStepButton, VerticalSpace } from '../..//components';
import { Name, Password } from '../../partials';
interface Props {
onCreate: (name: string, password: string) => void | Promise<void | boolean>;
address: string;
onBackClick: () => void;
onCreate: (name: string, password: string) => void | Promise<void | boolean>;
}
function AccountName ({ address, onCreate }: Props): React.ReactElement<Props> {
function AccountName ({ address, onBackClick, onCreate }: Props): React.ReactElement<Props> {
const [name, setName] = useState<string | null>(null);
const [password, setPassword] = useState<string | null>(null);
const _onCreate = useCallback(() => name && password && onCreate(name, password), [name, password, onCreate]);
return (
<>
<Name
@ -23,21 +26,22 @@ function AccountName ({ address, onCreate }: Props): React.ReactElement<Props> {
/>
{name && <Password onChange={setPassword} />}
{name && password && (
<>
<Address
address={address}
name={name}
/>
<VerticalSpace />
<ButtonArea>
<Button
onClick={(): void | Promise<void | boolean> => onCreate(name, password)}
>
Add the account with the generated seed
</Button>
</ButtonArea>
</>
<Address
address={address}
name={name}
/>
)}
<VerticalSpace />
<ButtonArea>
<BackButton onClick={onBackClick} />
<NextStepButton
data-button-action='add new root'
isDisabled={!password || !name}
onClick={_onCreate}
>
Add the account with the generated seed
</NextStepButton>
</ButtonArea>
</>
);
}

View File

@ -75,7 +75,7 @@ describe('Create Account', () => {
it('clicking on Next activates phase 2', () => {
check(wrapper.find('input[type="checkbox"]'));
wrapper.find('button').simulate('click');
expect(wrapper.find(Header).text()).toBe('Create an account 2/2Back');
expect(wrapper.find(Header).text()).toBe('Create an account: 2/2Cancel');
});
});
@ -90,7 +90,7 @@ describe('Create Account', () => {
expect(wrapper.find(InputWithLabel).find('[data-input-name]').find(Input)).toHaveLength(1);
expect(wrapper.find(InputWithLabel).find('[data-input-password]')).toHaveLength(0);
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
expect(wrapper.find(Button)).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', () => {
@ -103,7 +103,7 @@ describe('Create Account', () => {
expect(wrapper.find('ErrorMessage').text()).toBe('Account name is too short');
expect(wrapper.find(InputWithLabel).find('[data-input-password]')).toHaveLength(0);
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]')).toHaveLength(0);
expect(wrapper.find(Button)).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 () => {
@ -117,7 +117,7 @@ describe('Create Account', () => {
expect(wrapper.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(Button)).toHaveLength(0);
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
});
it('password shorter than 6 characters should be not valid', async () => {
@ -126,21 +126,21 @@ describe('Create Account', () => {
expect(wrapper.find('ErrorMessage').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(Button)).toHaveLength(0);
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
});
it('submit button is not visible until both passwords are equal', async () => {
await enterName('abc').then(password('abcdef')).then(repeat('abcdeg'));
expect(wrapper.find('ErrorMessage').text()).toBe('Passwords do not match');
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(true);
expect(wrapper.find(Button)).toHaveLength(0);
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
});
it('submit button is visible when both passwords are equal', async () => {
await enterName('abc').then(password('abcdef')).then(repeat('abcdef'));
expect(wrapper.find('ErrorMessage')).toHaveLength(0);
expect(wrapper.find(InputWithLabel).find('[data-input-repeat-password]').find(Input).prop('withError')).toBe(false);
expect(wrapper.find(Button)).toHaveLength(1);
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false);
});
it('saves account with provided name and password', async () => {
@ -161,28 +161,28 @@ describe('Create Account', () => {
await enterName('abc').then(password('abcdef')).then(repeat('abcdef'));
});
it('first password input is cleared - second one and button get hidden', async () => {
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(Button)).toHaveLength(0);
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
});
it('first password changes - button is not visible', async () => {
it('first password changes - button is not disabled', async () => {
await type(wrapper.find('input[type="password"]').first(), 'aaaaaa');
expect(wrapper.find('ErrorMessage').text()).toBe('Passwords do not match');
expect(wrapper.find(Button)).toHaveLength(0);
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(true);
});
it('first password changes, then second changes too - button is visible', async () => {
await type(wrapper.find('input[type="password"]').first(), 'aaaaaa');
await type(wrapper.find('input[type="password"]').last(), 'aaaaaa');
expect(wrapper.find(Button)).toHaveLength(1);
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false);
});
it('second password changes, then first changes too - button is visible', async () => {
await type(wrapper.find('input[type="password"]').last(), 'aaaaaa');
await type(wrapper.find('input[type="password"]').first(), 'aaaaaa');
expect(wrapper.find(Button)).toHaveLength(1);
expect(wrapper.find('[data-button-action="add new root"] button').prop('disabled')).toBe(false);
});
});
});

View File

@ -3,7 +3,7 @@
// of the Apache-2.0 license. See the LICENSE file for details.
import React, { useCallback, useState } from 'react';
import { Button, ButtonArea, Checkbox, MnemonicSeed, VerticalSpace, Warning } from '../../components';
import { ButtonArea, Checkbox, MnemonicSeed, NextStepButton, VerticalSpace, Warning } from '../../components';
import useToast from '../../hooks/useToast';
interface Props {
@ -45,12 +45,12 @@ function Mnemonic ({ onNextStep, seed }: Props): React.ReactElement<Props> {
onChange={setIsMnemonicSaved}
/>
<ButtonArea>
<Button
<NextStepButton
isDisabled={!isMnemonicSaved}
onClick={onNextStep}
>
Next step
</Button>
</NextStepButton>
</ButtonArea>
</>
);

View File

@ -3,11 +3,9 @@
// of the Apache-2.0 license. See the LICENSE file for details.
import React, { useContext, useEffect, useState } from 'react';
import styled from 'styled-components';
import { ActionContext, Loading, ActionText } from '../../components';
import { ActionContext, Loading } from '../../components';
import { createAccountSuri, createSeed } from '../../messaging';
import { Header } from '../../partials';
import { HeaderWithSteps } from '../../partials';
import AccountName from './AccountName';
import Mnemonic from './Mnemonic';
@ -33,29 +31,14 @@ export default function CreateAccount (): React.ReactElement {
};
const _onNextStep = (): void => setStep(step + 1);
const _onCancel = (): void => {
if (step === 2) {
setStep(step - 1);
} else {
onAction('/');
}
};
const _onPreviousStep = (): void => setStep(step - 1);
return (
<>
<Header text={'Create an account '}>
<CreationSteps>
<div>
<CurrentStep>{step}</CurrentStep>
<TotalSteps>/2</TotalSteps>
</div>
<ActionText
onClick={_onCancel}
text={step === 1 ? 'Cancel' : 'Back'}
/>
</CreationSteps>
</Header>
<HeaderWithSteps
step={step}
text='Create an account:&nbsp;'
/>
<Loading>{account && (step === 1
? (
<Mnemonic
@ -66,6 +49,7 @@ export default function CreateAccount (): React.ReactElement {
: (
<AccountName
address={account.address}
onBackClick={_onPreviousStep}
onCreate={_onCreate}
/>
)
@ -73,27 +57,3 @@ export default function CreateAccount (): React.ReactElement {
</>
);
}
const CreationSteps = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
flex-grow: 1;
padding-right: 24px;
margin-top: 3px;
`;
const CurrentStep = styled.span`
font-size: ${({ theme }): string => theme.labelFontSize};
line-height: ${({ theme }): string => theme.labelLineHeight};
color: ${({ theme }): string => theme.primaryColor};
font-weight: 800;
margin-left: 10px;
`;
const TotalSteps = styled.span`
font-size: ${({ theme }): string => theme.labelFontSize};
line-height: ${({ theme }): string => theme.labelLineHeight};
color: ${({ theme }): string => theme.textColor};
font-weight: 800;
`;

View File

@ -0,0 +1,93 @@
// Copyright 2019-2020 @polkadot/extension-ui authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import React, { useCallback, useRef, useState } from 'react';
import styled from 'styled-components';
import { Address, Svg } from '../../components';
import arrow from '../../assets/arrow-down.svg';
import useOutsideClick from '../../hooks/useOutsideClick';
interface Props {
allAddresses: string[];
className?: string;
onSelect: (address: string) => void;
selectedAddress: string;
}
function AddressDropdown ({ allAddresses, className, onSelect, selectedAddress }: Props): React.ReactElement<Props> {
const [isDropdownVisible, setDropdownVisible] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const hideDropdown = useCallback(() => setDropdownVisible(false), []);
const toggleDropdown = useCallback(() => setDropdownVisible(!isDropdownVisible), [isDropdownVisible]);
const selectParent = (newParent: string) => (): void => onSelect(newParent);
useOutsideClick(ref, hideDropdown);
return (
<div className={className}>
<div
onClick={toggleDropdown}
ref={ref}
>
<Address address={selectedAddress} />
</div>
<HiddenOptions visible={isDropdownVisible}>
{allAddresses.map((address) => (
<div
data-parent-option
key={address}
onClick={selectParent(address)}
>
<Address address={address} />
</div>
))}
</HiddenOptions>
</div>
);
}
const HiddenOptions = styled.div<{ visible: boolean }>`
position: absolute;
visibility: ${({ visible }): string => (visible ? 'visible' : 'hidden')};
width: 430px;
z-index: 100;
background: ${({ theme }): string => theme.bodyColor};
max-height: ${({ visible }): string => (visible ? '200px' : '0')};
overflow: scroll;
padding: 5px;
border: 1px solid ${({ theme }): string => theme.boxBorderColor};
box-sizing: border-box;
border-radius: 4px;
margin-top: -8px;
& > div {
cursor: pointer;
}
`;
export default styled(AddressDropdown)`
margin-bottom: 16px;
cursor: pointer;
& > div:first-child > ${Address}::after {
content: '';
position: absolute;
top: 50%;
transform: translateY(-50%);
right: 11px;
width: 30px;
height: 30px;
background: url(${arrow}) center no-repeat;
background-color: ${({ theme }): string => theme.inputBackground};
pointer-events: none;
border-radius: 4px;
border: 1px solid ${({ theme }): string => theme.boxBorderColor};
}
${Address} ${Svg} {
visibility: hidden;
}
`;

View File

@ -0,0 +1,186 @@
// Copyright 2019-2020 @polkadot/extension-ui authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import '../../../../../__mocks__/chrome';
import React from 'react';
import { MemoryRouter, Route } from 'react-router';
import { configure, mount, ReactWrapper } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import { act } from 'react-dom/test-utils';
import { ThemeProvider } from 'styled-components';
import * as messaging from '@polkadot/extension-ui/messaging';
import { AccountContext, ActionContext, themes } from '../../components';
import { buildHierarchy } from '../../utils/buildHierarchy';
import { flushAllPromises } from '../../testHelpers';
import AddressDropdown from './AddressDropdown';
import Derive from '.';
configure({ adapter: new Adapter() });
const accounts = [
{ address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', name: 'A' },
{ address: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', name: 'B' },
{ address: '5D2TPhGEy2FhznvzaNYW9AkuMBbg3cyRemnPsBvBY4ZhkZXA', name: 'BB', parentAddress: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5' },
{ address: '5GhGENSJBWQZ8d8mARKgqEkiAxiW3hHeznQDW2iG4XzNieb6', isExternal: true, name: 'C' },
{ address: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s', isExternal: false, name: 'D' },
{ address: '5HRKYp5anSNGtqC7cq9ftiaq4y8Mk7uHk7keaXUrQwZqDWLJ', name: 'DD', parentAddress: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s' }
];
describe('Derive', () => {
const mountComponent = async (locked = false): Promise<{
wrapper: ReactWrapper;
onActionStub: jest.Mock;
}> => {
const onActionStub = jest.fn();
const wrapper = mount(
<MemoryRouter initialEntries={ [`/account/derive/${accounts[1].address}`] }>
<ActionContext.Provider value={onActionStub}>
<AccountContext.Provider value={{
accounts: accounts,
hierarchy: buildHierarchy(accounts)
}}>
<ThemeProvider theme={themes.dark}>
<Route path='/account/derive/:address'>
<Derive isLocked={locked}/>
</Route>
</ThemeProvider>
</AccountContext.Provider>
</ActionContext.Provider>
</MemoryRouter>
);
await act(flushAllPromises);
return { onActionStub, wrapper };
};
const uncheck = (input: ReactWrapper): unknown => input.simulate('change', { target: { checked: false } });
const type = async (input: ReactWrapper, value: string): Promise<void> => {
input.simulate('change', { target: { value } });
await act(flushAllPromises);
input.update();
};
describe('Parent selection screen', () => {
// eslint-disable-next-line @typescript-eslint/require-await
jest.spyOn(messaging, 'validateAccount').mockImplementation(async (_, pass: string) => pass === 'pass');
it('"Derive" checkbox is selected by default', async () => {
const { wrapper } = await mountComponent();
const checkbox = wrapper.find('input[type="checkbox"]');
expect(checkbox.prop('checked')).toBe(true);
});
it('"Create new root" button becomes visible after checkbox is unchecked', async () => {
const { wrapper } = await mountComponent();
uncheck(wrapper.find('input[type="checkbox"]'));
expect(wrapper.exists('[data-button-action="create root account"]')).toBe(true);
expect(wrapper.exists('[data-button-action="create derived account"]')).toBe(false);
});
it('"Create new root" button redirects to /account/create', async () => {
const { onActionStub, wrapper } = await mountComponent();
uncheck(wrapper.find('input[type="checkbox"]'));
wrapper.find('[data-button-action="create root account"] button').simulate('click');
expect(onActionStub).toBeCalledWith('/account/create');
});
it('"Create derived account" is visible when checkbox is checked', async () => {
const { wrapper } = await mountComponent();
expect(wrapper.exists('[data-button-action="create derived account"]')).toBe(true);
expect(wrapper.exists('[data-button-action="create root account"]')).toBe(false);
});
it('"Create derived account" is disabled when password is incorrect', async () => {
const { wrapper } = await mountComponent();
const button = wrapper.find('[data-button-action="create derived account"] button');
expect(button.prop('disabled')).toBe(true);
});
it('"Create derived account" is enabled when password is correct', async () => {
const { wrapper } = await mountComponent();
await type(wrapper.find('input[type="password"]'), 'pass');
const button = wrapper.find('[data-button-action="create derived account"] button');
expect(button.prop('disabled')).toBe(false);
});
it('derivation path input is not present until password is correct', async () => {
const { wrapper } = await mountComponent();
expect(wrapper.exists('[data-input-suri]')).toBe(false);
await type(wrapper.find('input[type="password"]'), 'pass');
expect(wrapper.exists('[data-input-suri]')).toBe(true);
await type(wrapper.find('input[type="password"]'), 'pass2');
expect(wrapper.exists('[data-input-suri]')).toBe(false);
});
it('"Create derived account" is disabled when suri is incorrect', async () => {
const { wrapper } = await mountComponent();
await type(wrapper.find('input[type="password"]'), 'pass');
await type(wrapper.find('[data-input-suri] input'), '//');
const button = wrapper.find('[data-button-action="create derived account"] button');
expect(button.prop('disabled')).toBe(false);
});
it('takes selected address from URL as parent account', async () => {
const { wrapper } = await mountComponent();
expect(wrapper.find('[data-field="name"]').first().text()).toBe('B');
});
it('selects internal root accounts as other options', async () => {
const { wrapper } = await mountComponent();
const options = wrapper.find('[data-parent-option] [data-field="name"] div').map((el) => el.text());
expect(options).toEqual(['A', 'B', 'D']);
});
it('redirects to derive from next account when other option is selected', async () => {
const { onActionStub, wrapper } = await mountComponent();
wrapper.find('[data-parent-option]').first().simulate('click');
expect(onActionStub).toBeCalledWith(`/account/derive/${accounts[0].address}`);
});
});
describe('Locked parent selection', () => {
it('checkbox does not exist', async () => {
const { wrapper } = await mountComponent(true);
expect(wrapper.exists('[type="checkbox"]')).toBe(false);
});
it('address dropdown does not exist', async () => {
const { wrapper } = await mountComponent(true);
expect(wrapper.exists(AddressDropdown)).toBe(false);
});
it('parent is taken from URL', async () => {
const { wrapper } = await mountComponent(true);
expect(wrapper.find('[data-field="name"]').first().text()).toBe('B');
});
});
});

View File

@ -0,0 +1,124 @@
// Copyright 2019-2020 @polkadot/extension-ui authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import { AccountContext, ActionContext, Address, ButtonArea, Checkbox, InputWithLabel, Label, NextStepButton, VerticalSpace } from '../../components';
import { validateAccount } from '../../messaging';
import { DerivationPath } from '../../partials';
import { nextDerivationPath } from '../../utils/nextDerivationPath';
import AddressDropdown from './AddressDropdown';
interface Props {
isLocked?: boolean;
parentAddress: string;
onDerivationConfirmed: (derivation: { account: { address: string; suri: string }; parentPassword: string }) => void;
}
export function SelectParent ({ isLocked, onDerivationConfirmed, parentAddress }: Props): React.ReactElement<Props> {
const onAction = useContext(ActionContext);
const { accounts, hierarchy } = useContext(AccountContext);
const [account, setAccount] = useState<null | { address: string; suri: string }>(null);
const [parentPassword, setParentPassword] = useState<string>('');
const [isProperParentPassword, setIsProperParentPassword] = useState(false);
const [shouldAccountBeDerived, setShouldAccountBeDerived] = useState(true);
const passwordInputRef = useRef<HTMLDivElement>(null);
const _onParentPasswordEnter = useCallback(async (enteredPassword: string) => {
setParentPassword(enteredPassword);
setIsProperParentPassword(await validateAccount(parentAddress, enteredPassword));
}, [parentAddress]);
const _onParentChange = useCallback((address: string) => onAction(`/account/derive/${address}`), [onAction]);
const _onSubmit = useCallback(() => {
parentPassword && account && onDerivationConfirmed({ account, parentPassword });
}, [account, parentPassword, onDerivationConfirmed]);
useEffect(() => {
setParentPassword('');
setIsProperParentPassword(false);
// eslint-disable-next-line no-unused-expressions
passwordInputRef.current?.querySelector('input')?.focus();
}, [_onParentPasswordEnter]);
return (
<>
{!isLocked && (
<CheckboxWithSmallerMargins
checked={shouldAccountBeDerived}
label='Derive'
onChange={setShouldAccountBeDerived}
/>
)}
<DisableableArea isDisabled={!shouldAccountBeDerived}>
{isLocked ? (
<Address address={parentAddress} />
) : (
<Label label='Choose Parent Account:'>
<AddressDropdown
allAddresses={hierarchy.filter(({ isExternal }) => !isExternal).map(({ address }) => address)}
onSelect={_onParentChange}
selectedAddress={parentAddress}
/>
</Label>
)}
<div ref={passwordInputRef}>
<InputWithLabel
data-export-password
isError={!isProperParentPassword}
isFocused
label='enter the password for the account you want to derive from'
onChange={_onParentPasswordEnter}
type='password'
value={parentPassword}
/>
</div>
{isProperParentPassword && (
<DerivationPath
defaultPath={nextDerivationPath(accounts, parentAddress)}
onChange={setAccount}
parentAddress={parentAddress}
parentPassword={parentPassword}
/>
)}
</DisableableArea>
<VerticalSpace/>
<ButtonArea>
{shouldAccountBeDerived
? (
<NextStepButton
data-button-action='create derived account'
isDisabled={!isProperParentPassword || account === null}
onClick={_onSubmit}
>
Create a derived account
</NextStepButton>
)
: (
<NextStepButton
data-button-action='create root account'
onClick={(): void => onAction('/account/create')}
>
Create account from new seed
</NextStepButton>
)
}
</ButtonArea>
</>
);
}
const CheckboxWithSmallerMargins = styled(Checkbox)`
margin-top: 0;
margin-bottom: 10px;
`;
const DisableableArea = styled.div<{ isDisabled: boolean }>`
opacity: ${({ isDisabled }): string => isDisabled ? '0.2' : '1'};
${({ isDisabled }): string | false => isDisabled && 'pointer-events: none'};
`;

View File

@ -3,30 +3,24 @@
// of the Apache-2.0 license. See the LICENSE file for details.
import React, { useCallback, useContext, useState } from 'react';
import { RouteComponentProps, withRouter } from 'react-router';
import styled from 'styled-components';
import { useParams } from 'react-router';
import { ActionContext, Address, Button, ButtonArea, InputWithLabel, VerticalSpace } from '../../components';
import { deriveAccount, validateAccount } from '../../messaging';
import { DerivationPath, Name, Password } from '../../partials';
import Step from './Step';
import { ActionContext, Address, BackButton, ButtonArea, NextStepButton, VerticalSpace } from '../../components';
import { deriveAccount } from '../../messaging';
import { HeaderWithSteps, Name, Password } from '../../partials';
import { SelectParent } from './SelectParent';
type Props = RouteComponentProps<{ address: string }>;
interface Props {
isLocked?: boolean;
}
const DeriveButton = styled(Button)`
margin-left: 24px;
margin-right: 24px;
width: auto;
`;
function Derive ({ match: { params: { address: parentAddress } } }: Props): React.ReactElement<Props> {
function Derive ({ isLocked }: Props): React.ReactElement<Props> {
const onAction = useContext(ActionContext);
const { address: parentAddress } = useParams<{ address: string }>();
const [account, setAccount] = useState<null | { address: string; suri: string }>(null);
const [name, setName] = useState<string | null>(null);
const [password, setPassword] = useState<string | null>(null);
const [parentPassword, setParentPassword] = useState<string | null>(null);
const [isProperParentPassword, setIsProperParentPassword] = useState(false);
const [derivationConfirmed, setDerivationConfirmed] = useState(false);
const _onCreate = useCallback(async () => {
if (!account || !name || !password || !parentPassword) {
@ -38,53 +32,55 @@ function Derive ({ match: { params: { address: parentAddress } } }: Props): Reac
onAction('/');
}, [account, name, password, onAction, parentAddress, parentPassword]);
const _onParentPasswordEnter = useCallback(async (enteredPassword: string) => {
setParentPassword(enteredPassword);
setIsProperParentPassword(await validateAccount(parentAddress, enteredPassword));
}, [parentAddress]);
const _onDerivationConfirmed = useCallback(({ account, parentPassword }: {
account: { address: string; suri: string };
parentPassword: string;
}) => {
setAccount(account);
setParentPassword(parentPassword);
}, []);
const _onBackClick = useCallback(() => {
setAccount(null);
}, []);
return (
<>
<Step step={derivationConfirmed ? 2 : 1} />
{!derivationConfirmed && (
<InputWithLabel
data-export-password
isError={!isProperParentPassword}
isFocused
label='enter the password for the account you want to derive from'
onChange={_onParentPasswordEnter}
type='password'
<HeaderWithSteps
step={account ? 2 : 1}
text='Add new account:&nbsp;'
/>
{!account && (
<SelectParent
isLocked={isLocked}
onDerivationConfirmed={_onDerivationConfirmed}
parentAddress={parentAddress}
/>
)}
{!derivationConfirmed && (
<DeriveButton
isDisabled={!isProperParentPassword}
onClick={(): void => setDerivationConfirmed(true)}
>
I want to derive from this account
</DeriveButton>
)}
{isProperParentPassword && derivationConfirmed && (
{account && (
<Name
isFocused
onChange={setName}
/>
)}
{isProperParentPassword && derivationConfirmed && parentPassword && name && <DerivationPath
onChange={setAccount}
parentAddress={parentAddress}
parentPassword={parentPassword}
/>}
{isProperParentPassword && derivationConfirmed && account && name && <Password onChange={setPassword}/>}
{isProperParentPassword && derivationConfirmed && account && name && password && (
{account && name && <Password onChange={setPassword}/>}
{account && name && password && (
<Address
address={account.address}
name={name}
/>
)}
{account && (
<>
<Address
address={account.address}
name={name}
/>
<VerticalSpace/>
<ButtonArea>
<Button onClick={_onCreate}>Create derived account</Button>
<BackButton onClick={_onBackClick}/>
<NextStepButton
isDisabled={!password}
onClick={_onCreate}
>
Create derived account
</NextStepButton>
</ButtonArea>
</>
)}
@ -92,4 +88,4 @@ function Derive ({ match: { params: { address: parentAddress } } }: Props): Reac
);
}
export default withRouter(Derive);
export default Derive;

View File

@ -5,7 +5,7 @@
import React, { useContext, useState } from 'react';
import { QrScanAddress } from '@polkadot/react-qr';
import { ActionContext, Address, Button, ButtonArea, VerticalSpace } from '../components';
import { ActionContext, Address, NextStepButton, ButtonArea, VerticalSpace } from '../components';
import { createAccountExternal } from '../messaging';
import { Header, Name } from '../partials';
@ -49,9 +49,11 @@ export default function ImportQr (): React.ReactElement<Props> {
<VerticalSpace />
{name && (
<ButtonArea>
<Button onClick={_onCreate}>
<NextStepButton
onClick={_onCreate}
>
Add the account with identified address
</Button>
</NextStepButton>
</ButtonArea>
)}
</>

View File

@ -5,10 +5,10 @@
import React, { useCallback, useContext, useState } from 'react';
import styled from 'styled-components';
import { ActionContext, Address, Button, ButtonArea, TextAreaWithLabel, ValidatedInput, VerticalSpace } from '../components';
import { ActionContext, Address, ButtonArea, NextStepButton, TextAreaWithLabel, ValidatedInput, VerticalSpace } from '../components';
import { allOf, isNotShorterThan, Result } from '../validators';
import { createAccountSuri, validateSeed } from '../messaging';
import { Header, Name, Password } from '../partials';
import { allOf, isNotShorterThan, Result } from '../validators';
type Props = {};
@ -79,7 +79,11 @@ export default function Import (): React.ReactElement<Props> {
/>
<VerticalSpace />
<ButtonArea>
<Button onClick={_onCreate}>Add the account with the supplied seed</Button>
<NextStepButton
onClick={_onCreate}
>
Add the account with the supplied seed
</NextStepButton>
</ButtonArea>
</>
)}
@ -93,7 +97,6 @@ const HeaderWithSmallerMargin = styled(Header)`
const SeedInput = styled(TextAreaWithLabel)`
margin-bottom: 16px;
textarea {
height: unset;
}

View File

@ -2,7 +2,7 @@
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { AccountJson, AuthorizeRequest, MetadataRequest, SigningRequest } from '@polkadot/extension-base/background/types';
import { AccountJson, AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest } from '@polkadot/extension-base/background/types';
import React, { useEffect, useState } from 'react';
import { Route, Switch } from 'react-router';
@ -50,6 +50,18 @@ async function requestMediaAccess (cameraOn: boolean): Promise<boolean> {
return false;
}
function initAccountContext (accounts: AccountJson[]): AccountsContext {
const hierarchy = buildHierarchy(accounts);
const isNotExternal = (account: AccountJson): boolean => !account.isExternal;
const master = hierarchy.find(isNotExternal);
return {
accounts,
hierarchy,
master
};
}
export default function Popup (): React.ReactElement<{}> {
const [accounts, setAccounts] = useState<null | AccountJson[]>(null);
const [authRequests, setAuthRequests] = useState<null | AuthorizeRequest[]>(null);
@ -97,7 +109,7 @@ export default function Popup (): React.ReactElement<{}> {
return (
<Loading>{accounts && authRequests && metaRequests && signRequests && (
<ActionContext.Provider value={_onAction}>
<AccountContext.Provider value={{ accounts, hierarchy: buildHierarchy(accounts) }}>
<AccountContext.Provider value={initAccountContext(accounts)}>
<AuthorizeReqContext.Provider value={authRequests}>
<MediaContext.Provider value={cameraOn && mediaAllowed}>
<MetadataReqContext.Provider value={metaRequests}>
@ -109,6 +121,7 @@ export default function Popup (): React.ReactElement<{}> {
<Route path='/account/export/:address'><Export /></Route>
<Route path='/account/import-qr'><ImportQr /></Route>
<Route path='/account/import-seed'><ImportSeed /></Route>
<Route path='/account/derive/:address/locked'><Derive isLocked /></Route>
<Route path='/account/derive/:address'><Derive /></Route>
<Route
exact

View File

@ -0,0 +1,3 @@
<svg width="10" height="12" viewBox="0 0 10 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 7.33333L6 11.3333L5.05333 10.3867L7.44667 8H0V0H1.33333V6.66667H7.44667L5.05333 4.28L6 3.33333L10 7.33333Z" fill="#B3B3B5"/>
</svg>

After

Width:  |  Height:  |  Size: 243 B

View File

@ -0,0 +1,3 @@
<svg width="12" height="16" viewBox="0 0 12 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5001 7.09999H9.6001V4.39999C9.6001 2.41999 7.9801 0.799988 6.0001 0.799988C4.0201 0.799988 2.4001 2.41999 2.4001 4.39999V7.09999H1.5001C1.0501 7.09999 0.600098 7.54999 0.600098 7.99999V14.3C0.600098 14.75 1.0501 15.2 1.5001 15.2H10.5001C10.9501 15.2 11.4001 14.75 11.4001 14.3V7.99999C11.4001 7.54999 10.9501 7.09999 10.5001 7.09999ZM6.9001 13.4H5.1001L5.4601 11.42C5.0101 11.24 4.6501 10.7 4.6501 10.25C4.6501 9.52999 5.2801 8.89999 6.0001 8.89999C6.7201 8.89999 7.3501 9.52999 7.3501 10.25C7.3501 10.79 7.0801 11.24 6.5401 11.42L6.9001 13.4ZM7.8001 7.09999H4.2001V4.39999C4.2001 3.40999 5.0101 2.59999 6.0001 2.59999C6.9901 2.59999 7.8001 3.40999 7.8001 4.39999V7.09999Z" fill="#8D8D8D"/>
</svg>

After

Width:  |  Height:  |  Size: 809 B

View File

@ -0,0 +1,3 @@
<svg width="12" height="16" viewBox="0 0 12 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.6001 7.10005H10.5001C10.9501 7.10005 11.4001 7.55005 11.4001 8.00005V14.3C11.4001 14.75 10.9501 15.2 10.5001 15.2H1.5001C1.0501 15.2 0.600098 14.75 0.600098 14.3V8.00005C0.600098 7.55005 1.0501 7.10005 1.5001 7.10005H2.4001V4.40005C2.4001 2.42005 4.0201 0.800049 6.0001 0.800049C7.67121 0.800049 9.08589 1.95403 9.48661 3.50446L7.77075 4.07642C7.61719 3.23946 6.87969 2.60005 6.0001 2.60005C5.0101 2.60005 4.2001 3.41005 4.2001 4.40005V7.10005H7.8001V7.1H9.6001V7.10005ZM5.4601 11.42L5.1001 13.4H6.9001L6.5401 11.42C7.0801 11.24 7.3501 10.79 7.3501 10.25C7.3501 9.53005 6.7201 8.90005 6.0001 8.90005C5.2801 8.90005 4.6501 9.53005 4.6501 10.25C4.6501 10.7 5.0101 11.24 5.4601 11.42Z" fill="#8D8D8D"/>
</svg>

After

Width:  |  Height:  |  Size: 857 B

View File

@ -11,13 +11,14 @@ import styled from 'styled-components';
import settings from '@polkadot/ui-settings';
import { decodeAddress, encodeAddress } from '@polkadot/util-crypto';
import copy from '../assets/copy.svg';
import details from '../assets/details.svg';
import parentArrow from '../assets/arrowParentLabel.svg';
import { AccountContext } from './contexts';
import Identicon from './Identicon';
import Svg from './Svg';
import Menu from './Menu';
import CopyImg from '../assets/copy.svg';
import DetailsImg from '../assets/details.svg';
import Svg from './Svg';
import useOutsideClick from '../hooks/useOutsideClick';
import useMetadata from '../hooks/useMetadata';
import useToast from '../hooks/useToast';
@ -29,7 +30,7 @@ interface Props {
children?: React.ReactNode;
genesisHash?: string | null;
actions?: React.ReactNode;
parentName?: string;
parentName?: string | null;
}
interface Recoded {
@ -99,25 +100,37 @@ function Address ({ actions, address, children, className, genesisHash, name, pa
const _onClick = useCallback((): void => setShowActionsMenu(!showActionsMenu), [showActionsMenu]);
const _onCopy = useCallback((): void => show('Copied'), [show]);
const displayedName = name || (account && account.name) || '<unknown>';
return (
<div className={className}>
<div>
<AccountInfoRow>
<Identicon
iconTheme={theme}
onCopy={_onCopy}
prefix={prefix}
value={formatted || address}
/>
<Info>
<Name>{name || (account && account.name) || '<unknown>'}</Name>
{parentName ? (
<>
<Banner>
<ArrowLabel/>
<ParentName data-field='parent'>{parentName}</ParentName>
</Banner>
<DisplacedName>{displayedName}</DisplacedName>
</>
) : (
<Name data-field='name'>{displayedName}</Name>
)}
<CopyAddress>
<FullAddress>{formatted || '<unknown>'}</FullAddress>
{chain?.genesisHash && <ChainBanner>{chain.name}</ChainBanner>}
{parentName && <ParentBanner>↳ {parentName}</ParentBanner>}
<FullAddress data-field='address'>{formatted || '<unknown>'}</FullAddress>
{chain?.genesisHash && <ChainBanner data-field='chain'>{chain.name}</ChainBanner>}
<CopyToClipboard text={(formatted && formatted) || ''} >
<Svg
onClick={_onCopy}
src={CopyImg}
src={copy}
/>
</CopyToClipboard>
</CopyAddress>
@ -186,6 +199,10 @@ const Name = styled.div`
line-height: 22px;
`;
const DisplacedName = styled(Name)`
padding-top: 10px;
`;
const FullAddress = styled.div`
width: 270px;
overflow: hidden;
@ -229,13 +246,13 @@ const Settings = styled.div`
Settings.displayName = 'Details';
const ActionsIcon = styled(Svg).attrs(() => ({
src: DetailsImg
src: details
}))`
background: ${({ theme }): string => theme.accountDotsIconColor};
`;
const ActiveActionsIcon = styled(Svg).attrs(() => ({
src: DetailsImg
src: details
}))`
background: ${({ theme }): string => theme.primaryColor};
`;
@ -245,18 +262,35 @@ const Banner = styled.div`
color: white;
font-size: 12px;
line-height: 16px;
padding: 0.1rem 0.5rem;
position: absolute;
right: 40px;
top: 0;
`;
const ChainBanner = styled(Banner)`
padding: 0.1rem 0.5rem;
right: 40px;
background: ${({ theme }): string => theme.primaryColor};
`;
const ParentBanner = styled(Banner)`
background: ${({ theme }): string => theme.parentLabelColor};
const ParentName = styled.div`
padding: 0.25rem 0 0 0.8rem;
font-weight: 600;
font-size: 10px;
line-height: 14px;
color: ${({ theme }): string => theme.labelColor};
text-overflow: ellipsis;
overflow: hidden;
width: 270px;
`;
const ArrowLabel = styled(Svg).attrs(() => ({
src: parentArrow
}))`
position: absolute;
top: 5px;
width: 9px;
height: 9px;
background: ${({ theme }): string => theme.labelColor};
`;
const MovableMenu = styled(Menu) <{ isMoved: boolean }>`

View File

@ -0,0 +1,38 @@
// Copyright 2019-2020 @polkadot/extension-ui authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import React from 'react';
import styled from 'styled-components';
import arrowLeft from '../assets/arrowLeft.svg';
import Button from './Button';
import Svg from './Svg';
interface Props {
onClick: () => void;
}
export default function BackButton ({ onClick }: Props): React.ReactElement<Props> {
return (
<SmallButton onClick={onClick}>
<ArrowLeft />
</SmallButton>
);
}
const SmallButton = styled(Button)`
margin-right: 11px;
width: 42px;
background: ${({ theme }): string => theme.backButtonBackground};
`;
const ArrowLeft = styled(Svg).attrs(() => ({
src: arrowLeft
}))`
width: 12px;
height: 12px;
display: block;
margin: auto;
background: ${({ theme }): string => theme.backButtonTextColor};
`;

View File

@ -30,13 +30,15 @@ function Checkbox ({ checked, className, label, onChange }: Props): React.ReactE
}
export default styled(Checkbox)`
margin: ${({ theme }): string => theme.boxMargin};
label {
display: block;
position: relative;
cursor: pointer;
user-select: none;
margin: ${({ theme }): string => theme.boxMargin};
padding-left: 24px;
padding-top: 1px;
color: ${({ theme }): string => theme.subTextColor};
font-size: ${({ theme }): string => theme.fontSize};
line-height: ${({ theme }): string => theme.lineHeight};

View File

@ -4,8 +4,8 @@
import React from 'react';
import styled from 'styled-components';
import arrow from '../assets/arrow-down.svg';
import arrow from '../assets/arrow-down.svg';
import Label from './Label';
interface DropdownOption {
@ -90,7 +90,7 @@ export default styled(Dropdown)`
position: absolute;
top: 50%;
transform: translateY(-50%);
right: 5px;
right: 12px;
width: 8px;
height: 6px;
background: url(${arrow}) center no-repeat;

View File

@ -11,13 +11,15 @@ interface Props {
className?: string;
prefix?: number;
value?: string | null;
onCopy?: () => void;
}
function Identicon ({ className, iconTheme, prefix, value }: Props): React.ReactElement<Props> {
function Identicon ({ className, iconTheme, onCopy, prefix, value }: Props): React.ReactElement<Props> {
return (
<div className={className}>
<Icon
className='icon'
onCopy={onCopy}
prefix={prefix}
size={64}
theme={iconTheme}

View File

@ -11,6 +11,7 @@ import { Input } from './TextInputs';
interface Props {
className?: string;
defaultValue?: string | null;
disabled?: boolean;
isError?: boolean;
isFocused?: boolean;
isReadOnly?: boolean;
@ -22,7 +23,7 @@ interface Props {
placeholder?: string;
}
function InputWithLabel ({ className, defaultValue, isError, isFocused, isReadOnly, label, onBlur, onChange, placeholder, type = 'text', value }: Props): React.ReactElement<Props> {
function InputWithLabel ({ className, defaultValue, disabled, isError, isFocused, isReadOnly, label, onBlur, onChange, placeholder, type = 'text', value }: Props): React.ReactElement<Props> {
const _onChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>): void => {
onChange && onChange(value);
@ -40,6 +41,7 @@ function InputWithLabel ({ className, defaultValue, isError, isFocused, isReadOn
autoCorrect='off'
autoFocus={isFocused}
defaultValue={defaultValue || undefined}
disabled={disabled}
onBlur={onBlur}
onChange={_onChange}
placeholder={placeholder}

View File

@ -0,0 +1,32 @@
// Copyright 2019-2020 @polkadot/extension-ui authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import React from 'react';
import styled from 'styled-components';
import arrowLeft from '../assets/arrowLeft.svg';
import Svg from './Svg';
import Button from './Button';
type Props = React.ComponentProps<typeof Button>;
export default function NextStepButton ({ children, ...props }: Props): React.ReactElement<Props> {
return (
<Button {...props}>
{children}
<ArrowRight />
</Button>
);
}
const ArrowRight = styled(Svg).attrs(() => ({
src: arrowLeft
}))`
float: right;
width: 12px;
height: 12px;
margin: 4px 1px 0 0;
transform: rotate(180deg);
background: ${({ theme }): string => theme.buttonTextColor};
`;

View File

@ -18,18 +18,17 @@ type Props<T extends BasicProps> = T & {
validator: Validator<string>;
component: React.ComponentType<T>;
onValidatedChange: (value: string | null) => void;
defaultValue?: string;
}
function ValidatedInput<T extends object> ({ className, component: Input, onValidatedChange, validator, ...props }: Props<T>): React.ReactElement<Props<T>> {
const [value, setValue] = useState('');
function ValidatedInput<T extends object> ({ className, component: Input, defaultValue, onValidatedChange, validator, ...props }: Props<T>): React.ReactElement<Props<T>> {
const [value, setValue] = useState(defaultValue || '');
const [wasMounted, setWasMounted] = useState(false);
const [validationResult, setValidationResult] = useState<Result<string>>(Result.ok(''));
useEffect(() => {
if (!wasMounted) {
setWasMounted(true);
return;
}
(async (): Promise<void> => {

View File

@ -12,7 +12,8 @@ const noop = (): void => undefined;
const AccountContext = React.createContext<AccountsContext>({
accounts: [],
hierarchy: []
hierarchy: [],
master: undefined
});
const ActionContext = React.createContext<(to?: string) => void>(noop);
const AuthorizeReqContext = React.createContext<AuthorizeRequest[]>([]);

View File

@ -5,6 +5,7 @@
export { default as ActionBar } from './ActionBar';
export { default as ActionText } from './ActionText';
export { default as Address } from './Address';
export { default as BackButton } from './BackButton';
export { default as Box } from './Box';
export { default as Button } from './Button';
export { default as ButtonArea } from './ButtonArea';
@ -15,11 +16,13 @@ export { default as Fonts } from './Fonts';
export { default as Icon } from './Icon';
export { default as Identicon } from './Identicon';
export { default as InputWithLabel } from './InputWithLabel';
export { default as Label } from './Label';
export { default as Link } from './Link';
export { default as List } from './List';
export { default as Loading } from './Loading';
export { default as Menu } from './Menu';
export { default as MnemonicSeed } from './MnemonicSeed';
export { default as NextStepButton } from './NextStepButton';
export { default as Svg } from './Svg';
export { default as Switch } from './Switch';
export { default as Table } from './Table';

View File

@ -6,10 +6,12 @@ const darkTheme = {
accountBackground: '#1A1B20',
accountDotsIconColor: '#8E8E8E',
addAccountImageBackground: '#1A1B20',
backButtonBackground: '#3A3B41',
backButtonTextColor: '#FFFFFF',
background: '#26272C',
bodyColor: '#20222A',
borderRadius: '4px',
boxBorderColor: '#43444B',
boxBorderColor: '#303030',
boxMargin: '0.75rem 0',
boxPadding: '0 0.25rem',
boxShadow: 'rgba(0, 0, 0, 0.86)',
@ -47,6 +49,8 @@ const lightTheme: Theme = {
...darkTheme,
accountBackground: '#FFFFFF',
addAccountImageBackground: '#FFF',
backButtonBackground: '#D7D7D7',
backButtonTextColor: '#454545',
background: '#FAFAFA',
bodyColor: '#FFFFFF',
boxBorderColor: '#DADFEA',

View File

@ -3,45 +3,115 @@
// of the Apache-2.0 license. See the LICENSE file for details.
import React, { useCallback, useState } from 'react';
import styled from 'styled-components';
import { InputWithLabel, ValidatedInput } from '../components';
import { InputWithLabel, Svg, ValidatedInput, Button } from '../components';
import { validateDerivationPath } from '../messaging';
import { Result } from '../validators';
import lockIcon from '../assets/lock.svg';
import unlockIcon from '../assets/unlock.svg';
interface Props {
className?: string;
onChange: (derivedAccount: { address: string; suri: string } | null) => void;
parentAddress: string;
parentPassword: string;
defaultPath: string;
}
function DerivationPath ({ onChange, parentAddress, parentPassword }: Props): React.ReactElement<Props> {
const [path, setPath] = useState<string>('');
function DerivationPath ({ className, defaultPath, onChange, parentAddress, parentPassword }: Props): React.ReactElement<Props> {
const [path, setPath] = useState<string>(defaultPath);
const [isDisabled, setIsDisabled] = useState(false);
const isPathValid = useCallback(async (path: string): Promise<Result<string>> => {
const isPathValid = useCallback(async (newPath: string): Promise<Result<string>> => {
try {
await validateDerivationPath(parentAddress, path, parentPassword);
await validateDerivationPath(parentAddress, newPath, parentPassword);
return Result.ok(path);
return Result.ok(newPath);
} catch (error) {
return Result.error('Invalid derivation path');
return Result.error(newPath === path ? '' : 'Invalid derivation path');
}
}, [parentAddress, parentPassword]);
}, [path, parentAddress, parentPassword]);
const _onExpand = useCallback(() => setIsDisabled(!isDisabled), [isDisabled]);
const _onChange = useCallback(async (newPath: string | null) => {
newPath && setPath(newPath);
onChange(newPath ? await validateDerivationPath(parentAddress, newPath, parentPassword) : null);
newPath !== null && setPath(newPath);
onChange(newPath === null ? null : await validateDerivationPath(parentAddress, newPath, parentPassword));
}, [parentAddress, onChange, parentPassword]);
return (
<ValidatedInput
component={InputWithLabel}
label='Derivation path'
onValidatedChange={_onChange}
placeholder='//hard/soft'
validator={isPathValid}
value={path}
/>
<div className={className}>
<PathField>
<PathInput isUnlocked={isDisabled}>
<ValidatedInput
component={InputWithLabel}
data-input-suri
defaultValue={defaultPath}
disabled={!isDisabled}
label='Derivation Path'
onValidatedChange={_onChange}
placeholder='//hard/soft'
validator={isPathValid}
value={path}
/>
</PathInput>
<LockButton onClick={_onExpand}>
{!isDisabled ? <LockIcon/> : <UnlockIcon/>}
</LockButton>
</PathField>
</div>
);
}
const PathField = styled.div`
display: flex;
flex-direction: row;
`;
interface UnlockableComponentProps {
isUnlocked: boolean;
}
const PathInput = styled.div<UnlockableComponentProps>`
width: 100%;
& input {
opacity: ${({ isUnlocked }): string => isUnlocked ? '100' : '30'}%;
}
`;
const LockIcon = styled(Svg).attrs(() => ({
src: lockIcon
}))`
width: 11px;
height: 14px;
background: ${({ theme }): string => theme.iconNeutralColor};
`;
const UnlockIcon = styled(Svg).attrs(() => ({
src: unlockIcon
}))`
width: 11px;
height: 14px;
background: ${({ theme }): string => theme.iconNeutralColor};
`;
const LockButton = styled(Button)`
width: 11px;
height: 14px;
padding: 3px;
margin: 36px 2px 0 10px;
background: none;
&:not(:disabled):hover {
background: none;
}
&:active, &:focus {
outline: none;
}
&::-moz-focus-inner {
border: 0;
}
`;
export default DerivationPath;

View File

@ -2,38 +2,54 @@
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import React from 'react';
import React, { useCallback, useContext } from 'react';
import styled from 'styled-components';
import { Header } from '../../partials';
import { ActionContext, ActionText } from '../components';
import Header from './Header';
interface Props {
className?: string;
step: number;
text: string;
}
function Step ({ className, step }: Props): React.ReactElement<Props> {
export default function HeaderWithSteps ({ step, text }: Props): React.ReactElement<Props> {
const onAction = useContext(ActionContext);
const _onCancel = useCallback(() => {
onAction('/');
}, [onAction]);
return (
<Header
className={className}
showBackArrow
text={
<>
<span>Derive new account</span>
<Header text={text}>
<CreationSteps>
<div>
<CurrentStep>{step}</CurrentStep>
<TotalSteps>/2</TotalSteps>
</>
}
/>
</div>
<ActionText
onClick={_onCancel}
text='Cancel'
/>
</CreationSteps>
</Header>
);
}
const CreationSteps = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
flex-grow: 1;
padding-right: 24px;
margin-top: 3px;
`;
const CurrentStep = styled.span`
font-size: ${({ theme }): string => theme.labelFontSize};
line-height: ${({ theme }): string => theme.labelLineHeight};
color: ${({ theme }): string => theme.primaryColor};
font-weight: 600;
margin-left: 10px;
`;
const TotalSteps = styled.span`
@ -42,5 +58,3 @@ const TotalSteps = styled.span`
color: ${({ theme }): string => theme.textColor};
font-weight: 600;
`;
export default Step;

View File

@ -9,3 +9,4 @@ export { default as Header } from './Header';
export { default as Name } from './Name';
export { default as Password } from './Password';
export { default as Settings } from './Settings';
export { default as HeaderWithSteps } from './HeaderWithSteps';

View File

@ -10,14 +10,14 @@ const testHierarchy = (accounts: AccountJson[], expected: AccountWithChildren[])
};
describe('Use Account Hierarchy', () => {
const acc = (address: string, parentAddress?: string, derivationPath?: string): {
const acc = (address: string, parentAddress?: string, whenCreated?: number): {
address: string;
derivationPath?: string;
whenCreated?: number;
parentAddress?: string;
} => ({
address,
derivationPath,
parentAddress
parentAddress,
whenCreated
});
test('for empty account list, returns empty list', () => {
@ -56,4 +56,18 @@ describe('Use Account Hierarchy', () => {
}]
}]);
});
test('sorts accounts by creation time', () => {
testHierarchy(
[acc('a', undefined, 2), acc('b', 'a', 4), acc('c', 'a', 3), acc('d', undefined, 1)],
[acc('d', undefined, 1), { address: 'a', children: [acc('c', 'a', 3), acc('b', 'a', 4)], whenCreated: 2 }]
);
});
test('if creation time is missing, puts account at the back of a list', () => {
testHierarchy(
[acc('a'), acc('b', undefined, 2), acc('c', undefined, 1)],
[acc('c', undefined, 1), acc('b', undefined, 2), acc('a')]
);
});
});

View File

@ -6,11 +6,13 @@ import { AccountJson, AccountWithChildren } from '@polkadot/extension-base/backg
const isRoot = (account: AccountJson): boolean => !account.parentAddress;
const isChild = (parent: AccountJson) => (account: AccountJson): boolean => account.parentAddress === parent.address;
const compareByCreationTime = (a: AccountJson, b: AccountJson): number => (a.whenCreated || Infinity) - (b.whenCreated || Infinity);
export const accountWithChildren = (allAccounts: AccountJson[]) => (account: AccountJson): AccountWithChildren => {
const children = allAccounts
.filter(isChild(account))
.map(accountWithChildren(allAccounts));
.map(accountWithChildren(allAccounts))
.sort(compareByCreationTime);
if (children.length === 0) {
return account;
@ -25,4 +27,5 @@ export const accountWithChildren = (allAccounts: AccountJson[]) => (account: Acc
export const buildHierarchy = (accounts: AccountJson[]): AccountWithChildren[] =>
accounts
.filter(isRoot)
.map(accountWithChildren(accounts));
.map(accountWithChildren(accounts))
.sort(compareByCreationTime);

View File

@ -0,0 +1,31 @@
// Copyright 2019-2020 @polkadot/extension-ui authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { nextDerivationPath } from './nextDerivationPath';
describe('Generate Derivation Path', () => {
const acc = (address: string, parentAddress?: string): {
address: string;
parentAddress?: string;
} => ({
address,
parentAddress
});
test('generates path for first masters child', () => {
expect(nextDerivationPath([acc('a')], 'a')).toEqual('//0');
});
test('generates path for third masters child', () => {
expect(nextDerivationPath([acc('a'), acc('b', 'a'), acc('c', 'a')], 'a')).toEqual('//2');
});
test('generates path for masters child when another root exists', () => {
expect(nextDerivationPath([acc('a'), acc('b', 'a'), acc('c', 'a'), acc('d')], 'a')).toEqual('//2');
});
test('generates path for masters grandchild', () => {
expect(nextDerivationPath([acc('a'), acc('b', 'a'), acc('c', 'b'), acc('d', 'b')], 'b')).toEqual('//2');
});
});

View File

@ -0,0 +1,15 @@
// Copyright 2019-2020 @polkadot/extension-ui authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { AccountJson } from '@polkadot/extension-base/background/types';
function childrenCount (accounts: AccountJson[], parentAddress: string): number {
return accounts.filter((account) => account.parentAddress === parentAddress).length;
}
export const nextDerivationPath = (accounts: AccountJson[], parentAddress: string): string => {
const siblingsCount = childrenCount(accounts, parentAddress);
return `//${siblingsCount}`;
};