Show labels of account parent (#306)

* Show derived accounts indentation

* Add parent labels

* Update branch

* styling

* CR requests

* Slightly reword derive documentation

* Remove derivation path from account metadata
This commit is contained in:
Ivan Rukhavets
2020-04-03 10:45:30 +02:00
committed by GitHub
parent 5e13f5e57f
commit 53d3ebce37
14 changed files with 173 additions and 43 deletions
+1 -1
View File
@@ -151,7 +151,7 @@ That is, hard-derivation paths are prefixed with `//`, soft paths with `/`, and
The extension will still ask you to enter a password for this account. As before, this password only encrypts the private key on disk. It is not required to be the same password as the one that encrypts the mnemonic phrase.
Accounts can also be derived from existing accounts – `Derive New Account` option in account's dropdown menu should be selected. After providing the password of the derived account, along with name and password of the new account, enter derivation path in the following format:
Accounts can also be derived from existing accounts – `Derive New Account` option in account's dropdown menu should be selected. After providing the password of the parent account, along with name and password of the derived account, enter derivation path in the following format:
```
//<hard>/<soft>
@@ -303,7 +303,11 @@ export default class Extension {
}
private derivationCreate ({ genesisHash, name, parentAddress, parentPassword, password, suri }: RequestDeriveCreate): boolean {
const childPair = this.derive(parentAddress, suri, parentPassword, { genesisHash, name, parentAddress });
const childPair = this.derive(parentAddress, suri, parentPassword, {
genesisHash,
name,
parentAddress
});
keyring.addPair(childPair, password);
@@ -5,7 +5,7 @@
import { InjectedAccount, MetadataDef, ProviderList, ProviderMeta, InjectedMetadataKnown } from '@polkadot/extension-inject/types';
import { JsonRpcResponse } from '@polkadot/rpc-provider/types';
import { KeypairType } from '@polkadot/util-crypto/types';
import { KeyringPair } from '@polkadot/keyring/types';
import { KeyringPair, KeyringPair$Meta } from '@polkadot/keyring/types';
import { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types';
import { TypeRegistry } from '@polkadot/types';
@@ -23,11 +23,21 @@ type NullKeys<T> = { [K in keyof T]: IsNull<T, K> }[keyof T];
export type SeedLengths = 12 | 24;
export interface AccountJson {
export interface AccountJson extends KeyringPair$Meta {
address: string;
genesisHash?: string | null;
isExternal?: boolean;
name?: string;
parentAddress?: string;
}
export type AccountWithChildren = AccountJson & {
children?: AccountWithChildren[];
}
export type AccountsContext = {
accounts: AccountJson[];
hierarchy: AccountWithChildren[];
}
export interface AuthorizeRequest {
@@ -13,10 +13,11 @@ import { Name } from '../../partials';
interface Props extends AccountJson {
address: string;
parentName?: string;
className?: string;
}
function Account ({ address, className, isExternal }: Props): React.ReactElement<Props> {
function Account ({ address, className, isExternal, parentName }: Props): React.ReactElement<Props> {
const onAction = useContext(ActionContext);
const [isEditing, setEditing] = useState(false);
const [editedName, setName] = useState<string | null>(null);
@@ -61,6 +62,7 @@ function Account ({ address, className, isExternal }: Props): React.ReactElement
)}
address={address}
name={editedName}
parentName={parentName}
>
{isEditing && (
<Name
@@ -0,0 +1,27 @@
import React from 'react';
import { AccountWithChildren } from '@polkadot/extension-base/background/types';
import Account from './Account';
interface Props extends AccountWithChildren {
parentName?: string;
}
function AccountsTree ({ parentName, ...account }: Props): React.ReactElement<Props> {
return (
<>
<Account
{...account}
parentName={parentName}
/>
{account?.children?.map((child, index) => (
<AccountsTree
key={`${index}:${child.address}`}
{...child}
parentName={account.name}
/>
))}
</>
);
}
export default AccountsTree;
@@ -3,21 +3,21 @@
// of the Apache-2.0 license. See the LICENSE file for details.
import React, { useContext } from 'react';
import QrImage from '../../assets/qr.svg';
import { AccountContext, Button, Header, MediaContext, AddAccount, ButtonArea, Svg, ButtonWithSubtitle } from '../../components';
import Account from './Account';
import styled from 'styled-components';
import QrImage from '../../assets/qr.svg';
import AccountsTree from './AccountsTree';
import { AccountContext, Button, Header, MediaContext, AddAccount, ButtonArea, Svg, ButtonWithSubtitle } from '../../components';
type Props = {};
export default function Accounts (): React.ReactElement<Props> {
const accounts = useContext(AccountContext);
const { hierarchy } = useContext(AccountContext);
const mediaAllowed = useContext(MediaContext);
return (
<>
{(accounts.length === 0)
{(hierarchy.length === 0)
? <AddAccount />
: (
<>
@@ -26,8 +26,8 @@ export default function Accounts (): React.ReactElement<Props> {
text={'Accounts'}
/>
<AccountsArea>
{accounts.map((json, index): React.ReactNode => (
<Account
{hierarchy.map((json, index): React.ReactNode => (
<AccountsTree
{...json}
key={`${index}:${json.address}`}
/>
@@ -8,9 +8,9 @@ import Adapter from 'enzyme-adapter-react-16';
import { configure, mount, ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import * as messaging from '@polkadot/extension-ui/messaging';
import { AccountJson, SigningRequest } from '@polkadot/extension-base/background/types';
import { SigningRequest } from '@polkadot/extension-base/background/types';
import { flushAllPromises } from '@polkadot/extension-ui/testHelpers';
import { AccountContext, ActionContext, Address, Button, Input, SigningReqContext, themes } from '@polkadot/extension-ui/components';
import { ActionContext, Address, Button, Input, SigningReqContext, themes } from '@polkadot/extension-ui/components';
import { ThemeProvider } from 'styled-components';
import Signing from '.';
import TransactionIndex from './TransactionIndex';
@@ -25,17 +25,6 @@ describe.skip('Signing requests', () => {
let wrapper: ReactWrapper;
let onActionStub: jest.Mock;
let signRequests: SigningRequest[] = [];
const accounts: AccountJson[] = [{
address: '5D4bqjQRPgdMBK8bNvhX4tSuCtSGZS7rZjD5XH5SoKcFeKn5',
genesisHash: '0xdcd1346701ca8396496e52aa2785b1748deb6db09551b72159dcb3e08991025b',
isExternal: false,
name: 'acc1'
}, {
address: '5E9nq1yGJJFiP8C75ryD9J2R62q2cesz6NumLnuXRgmuN5DG',
genesisHash: '0xdcd1346701ca8396496e52aa2785b1748deb6db09551b72159dcb3e08991025b',
isExternal: false,
name: 'acc2'
}];
const emitter = new EventEmitter();
@@ -54,11 +43,9 @@ describe.skip('Signing requests', () => {
const mountComponent = async (): Promise<void> => {
wrapper = mount(
<ActionContext.Provider value={onActionStub}>
<AccountContext.Provider value={accounts}>
<ThemeProvider theme={themes.dark}>
<MockRequestsProvider />
</ThemeProvider>
</AccountContext.Provider>
<ThemeProvider theme={themes.dark}>
<MockRequestsProvider />
</ThemeProvider>
</ActionContext.Provider>
);
await act(flushAllPromises);
+2 -1
View File
@@ -13,6 +13,7 @@ import { Loading } from '../components';
import { AccountContext, ActionContext, AuthorizeReqContext, MediaContext, MetadataReqContext, SigningReqContext } from '../components/contexts';
import ToastProvider from '../components/Toast/ToastProvider';
import { subscribeAccounts, subscribeAuthorizeRequests, subscribeMetadataRequests, subscribeSigningRequests } from '../messaging';
import { buildHierarchy } from '../utils/buildHierarchy';
import Accounts from './Accounts';
import Authorize from './Authorize';
import CreateAccount from './CreateAccount';
@@ -96,7 +97,7 @@ export default function Popup (): React.ReactElement<{}> {
return (
<Loading>{accounts && authRequests && metaRequests && signRequests && (
<ActionContext.Provider value={_onAction}>
<AccountContext.Provider value={accounts}>
<AccountContext.Provider value={{ accounts, hierarchy: buildHierarchy(accounts) }}>
<AuthorizeReqContext.Provider value={authRequests}>
<MediaContext.Provider value={cameraOn && mediaAllowed}>
<MetadataReqContext.Provider value={metaRequests}>
@@ -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 } from '@polkadot/extension-base/background/types';
import { AccountJson, AccountWithChildren } from '@polkadot/extension-base/background/types';
import { Chain } from '@polkadot/extension-chains/types';
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
@@ -29,6 +29,7 @@ interface Props {
children?: React.ReactNode;
genesisHash?: string | null;
actions?: React.ReactNode;
parentName?: string;
}
interface Recoded {
@@ -47,7 +48,7 @@ function findAccount (accounts: AccountJson[], publicKey: Uint8Array): AccountJs
}
// recodes an supplied address using the prefix/genesisHash, include the actual saved account & chain
function recodeAddress (address: string, accounts: AccountJson[], chain: Chain | null): Recoded {
function recodeAddress (address: string, accounts: AccountWithChildren[], chain: Chain | null): Recoded {
// decode and create a shortcut for the encoded address
const publicKey = decodeAddress(address);
@@ -65,8 +66,8 @@ function recodeAddress (address: string, accounts: AccountJson[], chain: Chain |
const ACCOUNTS_SCREEN_HEIGHT = 500;
function Address ({ actions, address, children, className, genesisHash, name }: Props): React.ReactElement<Props> {
const accounts = useContext(AccountContext);
function Address ({ actions, address, children, className, genesisHash, name, parentName }: Props): React.ReactElement<Props> {
const { accounts } = useContext(AccountContext);
const chain = useMetadata(genesisHash);
const [{ account, formatted, prefix }, setRecoded] = useState<Recoded>({ account: null, formatted: null, prefix: 42 });
const [showActionsMenu, setShowActionsMenu] = useState(false);
@@ -111,9 +112,8 @@ function Address ({ actions, address, children, className, genesisHash, name }:
<Name>{name || (account && account.name) || '<unknown>'}</Name>
<CopyAddress>
<FullAddress>{formatted || '<unknown>'}</FullAddress>
{chain?.genesisHash && (
<Banner>{chain.name}</Banner>
)}
{chain?.genesisHash && <ChainBanner>{chain.name}</ChainBanner>}
{parentName && <ParentBanner>↳ {parentName}</ParentBanner>}
<CopyToClipboard text={(formatted && formatted) || ''} >
<Svg
onClick={_onCopy}
@@ -241,7 +241,6 @@ const ActiveActionsIcon = styled(Svg).attrs(() => ({
`;
const Banner = styled.div`
background: ${({ theme }): string => theme.primaryColor};
border-radius: 0 0 8px 8px;
color: white;
font-size: 12px;
@@ -252,6 +251,14 @@ const Banner = styled.div`
top: 0;
`;
const ChainBanner = styled(Banner)`
background: ${({ theme }): string => theme.primaryColor};
`;
const ParentBanner = styled(Banner)`
background: ${({ theme }): string => theme.parentLabelColor};
`;
const MovableMenu = styled(Menu) <{ isMoved: boolean }>`
${({ isMoved }): string => isMoved ? 'bottom: 50px' : ''};
`;
@@ -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 { AccountsContext, AuthorizeRequest, MetadataRequest, SigningRequest } from '@polkadot/extension-base/background/types';
import React from 'react';
import { AvailableThemes } from '.';
@@ -10,7 +10,10 @@ import { AvailableThemes } from '.';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const noop = (): void => undefined;
const AccountContext = React.createContext<AccountJson[]>([]);
const AccountContext = React.createContext<AccountsContext>({
accounts: [],
hierarchy: []
});
const ActionContext = React.createContext<(to?: string) => void>(noop);
const AuthorizeReqContext = React.createContext<AuthorizeRequest[]>([]);
const MediaContext = React.createContext<boolean>(false);
@@ -34,6 +34,7 @@ const darkTheme = {
labelFontSize: '13px',
labelLineHeight: '18px',
lineHeight: '26px',
parentLabelColor: '#4A7463',
popupBackground: '#38393F',
primaryColor: '#FF7D01',
readonlyInputBackground: '#1A1B20',
@@ -59,6 +60,7 @@ const lightTheme: Theme = {
inputBackground: '#FFFFFF',
inputBorderColor: '#DDE1EB',
labelColor: '#333333',
parentLabelColor: '#215B4F',
popupBackground: '#FFFFFF',
readonlyInputBackground: '#FFF',
subTextColor: '#454545',
+1 -1
View File
@@ -19,7 +19,7 @@ interface Props {
const isNameValid = isNotShorterThan(3, 'Account name is too short');
export default function Name ({ address, className, isFocused, label = 'A descriptive name for your account', onBlur, onChange }: Props): React.ReactElement<Props> {
const accounts = useContext(AccountContext);
const { accounts } = useContext(AccountContext);
const account = accounts.find((account): boolean => account.address === address);
const startValue = account && account.name;
@@ -0,0 +1,59 @@
// 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, AccountWithChildren } from '@polkadot/extension-base/background/types';
import { buildHierarchy } from './buildHierarchy';
const testHierarchy = (accounts: AccountJson[], expected: AccountWithChildren[]): void => {
expect(buildHierarchy(accounts)).toEqual(expected);
};
describe('Use Account Hierarchy', () => {
const acc = (address: string, parentAddress?: string, derivationPath?: string): {
address: string;
derivationPath?: string;
parentAddress?: string;
} => ({
address,
derivationPath,
parentAddress
});
test('for empty account list, returns empty list', () => {
testHierarchy([], []);
});
test('returns one account', () => {
testHierarchy([acc('a')], [acc('a')]);
});
test('puts child account into children field of parent: single child', () => {
testHierarchy([acc('a'), acc('b', 'a')], [
{ address: 'a', children: [acc('b', 'a')] }
]);
});
test('puts child account into children field of parent: more children', () => {
testHierarchy([acc('a'), acc('b', 'a'), acc('c', 'a')], [
{ address: 'a', children: [acc('b', 'a'), acc('c', 'a')] }
]);
});
test('puts child account into children field of parent: 2 roots', () => {
testHierarchy([acc('a'), acc('b', 'a'), acc('c', 'a'), acc('d')], [
{ address: 'a', children: [acc('b', 'a'), acc('c', 'a')] },
acc('d')
]);
});
test('handles grandchildren', () => {
testHierarchy([acc('a'), acc('b', 'a'), acc('c', 'b')], [{
address: 'a',
children: [{
...acc('b', 'a'),
children: [acc('c', 'b')]
}]
}]);
});
});
@@ -0,0 +1,28 @@
// 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, AccountWithChildren } from '@polkadot/extension-base/background/types';
const isRoot = (account: AccountJson): boolean => !account.parentAddress;
const isChild = (parent: AccountJson) => (account: AccountJson): boolean => account.parentAddress === parent.address;
export const accountWithChildren = (allAccounts: AccountJson[]) => (account: AccountJson): AccountWithChildren => {
const children = allAccounts
.filter(isChild(account))
.map(accountWithChildren(allAccounts));
if (children.length === 0) {
return account;
}
return {
...account,
children
};
};
export const buildHierarchy = (accounts: AccountJson[]): AccountWithChildren[] =>
accounts
.filter(isRoot)
.map(accountWithChildren(accounts));